#!/usr/bin/env python3
"""sim: standalone, dependency-free Android workspace CLI (Python 3.10+)."""
import argparse
import ast
import fnmatch
import getpass
import hashlib
import http.cookiejar
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zipfile

VERSION = '0.9.0'
DEFAULT_URL = 'https://sim.davidhe.de'
EXCLUDED_DIRS = {'.git', '.sim', '.gradle', '.dart_tool', '.idea', '.vscode', 'node_modules', 'build', 'dist', '.expo', '.next', '__pycache__', '.venv', 'venv', 'Pods', '.ssh', '.aws'}
EXCLUDED_FILES = {'local.properties', '.npmrc', '.yarnrc', '.netrc', '.pypirc', 'key.properties', 'google-services.json', 'GoogleService-Info.plist'}


def read_json(path, default=None):
    return json.loads(path.read_text()) if path.exists() else ({} if default is None else default)


def write_private(path, value):
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    path.write_text(json.dumps(value, indent=2)+'\n')
    path.chmod(0o600)


def git_value(root, *args):
    try:
        return subprocess.run(['git', *args], cwd=root, capture_output=True, text=True, check=True).stdout.strip()
    except (OSError, subprocess.CalledProcessError):
        return ''


def git_context(root):
    top = git_value(root, 'rev-parse', '--show-toplevel')
    common = git_value(root, 'rev-parse', '--git-common-dir')
    if not top or not common:
        return None
    common = (root / common).resolve()
    gitdir = (root / git_value(root, 'rev-parse', '--git-dir')).resolve()
    return {'top': Path(top).resolve(), 'common': common, 'linked': gitdir != common}


def slug(value):
    return re.sub('[^a-zA-Z0-9_-]', '-', value).strip('-')[:48] or 'app'


def validate_slug(value, label):
    if not re.fullmatch('[a-zA-Z0-9_-]{1,80}', value):
        raise ValueError('Ungültiger ' + label + ' (1–80 Buchstaben, Ziffern, - oder _).')
    return value


def shared_app(root, url, candidate=None, select=False):
    """One local project identity across worktrees; no remote URL or token is read."""
    git = git_context(root)
    if not git:
        identity = root / '.sim/projects' / (hashlib.sha256(url.encode()).hexdigest()[:24] + '.json')
    else:
        relative = root.resolve().relative_to(git['top']).as_posix()
        key = hashlib.sha256((url + '\0' + relative).encode()).hexdigest()[:24]
        identity = git['common'] / 'sim/projects' / (key + '.json')
        # If a linked worktree is the first v0.4 invocation, adopt the original
        # checkout's pre-v0.4 workspace so existing Android state stays reachable.
        if not candidate and git['common'].name == '.git':
            legacy = read_json(git['common'].parent / relative / '.sim/session.json')
            if legacy.get('url', url) == url:
                candidate = legacy.get('workspace')
    existing = read_json(identity)
    if not select and existing.get('url') == url and existing.get('workspace'):
        return existing['workspace']
    workspace = candidate or slug(git['common'].parent.name if git and root.resolve() == git['top'] else root.name) + '-' + uuid.uuid4().hex[:8]
    identity.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    # Initial callers agree through link(); an explicit app switch atomically
    # replaces the shared default without exposing a partial JSON document.
    with tempfile.NamedTemporaryFile(dir=identity.parent, delete=False) as output:
        temporary = Path(output.name)
        output.write((json.dumps({'workspace': workspace, 'url': url}) + '\n').encode())
        output.flush(); os.fsync(output.fileno())
    try:
        if select:
            os.replace(temporary, identity)
        else:
            try:
                os.link(temporary, identity)
            except FileExistsError:
                pass
    finally:
        temporary.unlink(missing_ok=True)
    return workspace if select else read_json(identity)['workspace']


def project_context(root, url, workspace=None, environment=None):
    cfg = read_json(root / 'sim.json')
    legacy_path = root / '.sim/session.json'
    legacy = read_json(legacy_path)
    if legacy.get('url', url) != url:
        legacy = {}
    if workspace:
        validate_slug(workspace, 'App-Name')
        workspace = shared_app(root, url, workspace, select=True)
    else:
        workspace = cfg.get('app') or cfg.get('workspace') or shared_app(root, url, legacy.get('workspace'))
    validate_slug(workspace, 'App-Name')
    git = git_context(root)
    automatic = 'wt-' + slug(root.name) + '-' + hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:10] if git and git['linked'] else 'main'
    if legacy.get('workspace') == workspace and (legacy.get('session_id') or legacy.get('build_id')):
        automatic = legacy.get('environment', 'main')
    environment = environment or cfg.get('environment') or automatic
    validate_slug(environment, 'Umgebungsname')
    scope = hashlib.sha256((url + '\0' + workspace).encode()).hexdigest()[:24]
    local_path = root / '.sim/environments' / scope / environment / 'session.json'
    local = read_json(local_path)
    if not local and legacy.get('workspace') == workspace and legacy.get('environment', 'main') == environment:
        local = legacy.copy()
    if local.get('url', url) != url or local.get('workspace', workspace) != workspace or local.get('environment', environment) != environment:
        local = {}
    local.update(workspace=workspace, environment=environment, url=url)
    return cfg, local_path, local


def source_metadata(root, summary):
    return {'branch': git_value(root, 'branch', '--show-current'),
            'commit': git_value(root, 'rev-parse', 'HEAD'),
            'dirty': bool(git_value(root, 'status', '--porcelain', '--untracked-files=normal', '--', '.', ':(exclude).sim')),
            'source_sha256': summary['sha256']}


def detect(root):
    cfg=read_json(root/'sim.json')
    package=read_json(root/'package.json')
    deps={**package.get('dependencies',{}),**package.get('devDependencies',{})}
    if cfg.get('framework'): framework=cfg['framework']
    elif (root/'pubspec.yaml').exists(): framework='flutter'
    elif 'expo' in deps: framework='expo'
    elif 'react-native' in deps: framework='react-native'
    elif '@capacitor/core' in deps: framework='capacitor'
    elif (root/'gradlew').exists() or (root/'android/gradlew').exists(): framework='gradle'
    elif cfg.get('command'): framework='custom'
    else: raise ValueError('Kein Android-Projekt erkannt. sim.json mit framework/command/apk anlegen oder sim up --apk app.apk nutzen.')
    if framework not in ('gradle','expo','react-native','capacitor','flutter','custom'): raise ValueError('Unbekanntes Framework in sim.json.')
    result={k:cfg[k] for k in ('command','apk','package','variant','java','flutter_version','web_command') if k in cfg}
    result['framework']=framework
    return result


def source_paths(root):
    patterns=[s.strip() for s in (root/'.simignore').read_text().splitlines() if s.strip() and not s.startswith('#')] if (root/'.simignore').exists() else []
    def allowed(rel):
        parts=Path(rel).parts
        name=parts[-1]
        if any(p in EXCLUDED_DIRS for p in parts) or name in EXCLUDED_FILES: return False
        if name.startswith('.env') or name.endswith(('.keystore','.jks','.p12','.pem','.key','.apk','.aab')): return False
        return not any(fnmatch.fnmatch(rel,p) or rel.startswith(p.rstrip('/')+'/') for p in patterns)
    try:
        inside=subprocess.run(['git','rev-parse','--show-toplevel'],cwd=root,capture_output=True,check=True,text=True).stdout.strip()
        # Running in a subdirectory must never upload neighbouring projects.
        raw=subprocess.run(['git','ls-files','--cached','--others','--exclude-standard','-z','.'],cwd=root,capture_output=True,check=True).stdout
        candidates=[p.decode() for p in raw.split(b'\0') if p]
    except (OSError,subprocess.CalledProcessError):
        candidates=[]
        for parent,dirs,files in os.walk(root,followlinks=False):
            dirs[:]=[d for d in dirs if d not in EXCLUDED_DIRS and not (Path(parent)/d).is_symlink()]
            candidates.extend((Path(parent)/f).relative_to(root).as_posix() for f in files)
    for rel in sorted(set(candidates)):
        path=root/rel
        if not allowed(rel) or path.is_symlink() or not path.is_file(): continue
        if not path.resolve().is_relative_to(root.resolve()): continue
        if path.stat().st_size>100*1024*1024: raise ValueError(f'Datei zu groß: {rel}; bei Bedarf in .simignore ausschließen.')
        yield rel


def pack(root, destination, config):
    digest=hashlib.sha256(json.dumps(config,sort_keys=True).encode())
    total=0;count=0
    with zipfile.ZipFile(destination,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=3) as archive:
        for rel in source_paths(root):
            content=(root/rel).read_bytes();total+=len(content);count+=1
            if total>1024**3 or count>30000: raise ValueError('Quellstand zu groß; .simignore verwenden (max. 1 GiB / 30.000 Dateien).')
            digest.update(rel.encode()+b'\0'+hashlib.sha256(content).digest())
            info=zipfile.ZipInfo(rel,date_time=(2020,1,1,0,0,0))
            info.compress_type=zipfile.ZIP_DEFLATED;info.external_attr=0o100644<<16
            archive.writestr(info,content)
    if not count: raise ValueError('Keine Quelldateien zum Hochladen gefunden.')
    return {'sha256':digest.hexdigest(),'files':count,'source_bytes':total,'upload_bytes':destination.stat().st_size}


def pack_flows(root, destination):
    """Upload explicitly selected Maestro assets, never execute caller files."""
    if root.is_symlink() or not root.is_dir():
        raise ValueError('Flow-Verzeichnis muss ein echtes Verzeichnis sein.')
    root=root.resolve();total=0;count=0;flows=0
    with zipfile.ZipFile(destination,'w',compression=zipfile.ZIP_DEFLATED) as archive:
        for parent,dirs,files in os.walk(root,followlinks=False):
            dirs[:]=[d for d in dirs if d not in EXCLUDED_DIRS and not (Path(parent)/d).is_symlink()]
            for name in sorted(files):
                path=Path(parent)/name;relative=path.relative_to(root).as_posix()
                if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root): continue
                if name in EXCLUDED_FILES or name.startswith('.env') or name.endswith(('.key','.pem','.p12','.jks','.keystore')): continue
                if path.suffix.lower() not in ('.yaml','.yml','.js','.json','.png','.jpg','.jpeg','.webp','.txt'): continue
                data=path.read_bytes();total+=len(data);count+=1
                if total>32*1024*1024 or count>256: raise ValueError('Flow-Paket zu groß (32 MiB / 256 Dateien).')
                flows+=path.suffix.lower() in ('.yaml','.yml')
                archive.writestr(relative,data)
    if not flows: raise ValueError('Keine Maestro YAML-Abläufe im ausgewählten Verzeichnis.')
    if destination.stat().st_size>8*1024*1024: raise ValueError('Komprimiertes Flow-Paket zu groß (8 MiB).')
    return {'files':count,'bytes':total}


def json_file(path, expected=dict):
    value=json.loads(path.read_text())
    if not isinstance(value,expected): raise ValueError('JSON-Datei muss '+('ein Array' if expected is list else 'ein Objekt')+' enthalten.')
    return value


def safe_artifact_name(value):
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,179}',value) or '..' in value:
        raise ValueError('Ungültiger Artefaktname; Namen aus dem Manifest verwenden.')
    return value


class Client:
    def __init__(self,url,key=''):
        self.url=url.rstrip('/');self.key=key
        parsed=urllib.parse.urlparse(self.url)
        if parsed.scheme!='https' and parsed.hostname not in ('localhost','127.0.0.1'): raise ValueError('SIM_URL muss HTTPS verwenden.')
        self.opener=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
    def call(self,method,path,body=None,file=None,binary=False):
        headers={'Origin':self.url}
        if self.key: headers['Authorization']='Bearer '+self.key
        if file:
            # urllib streams file objects with explicit Content-Length (no copy in RAM).
            body=file.open('rb');headers.update({'Content-Type':'application/octet-stream','Content-Length':str(file.stat().st_size)})
        elif body is not None:
            body=json.dumps(body).encode();headers['Content-Type']='application/json'
        try:
            req=urllib.request.Request(self.url+path,data=body,headers=headers,method=method)
            with self.opener.open(req,timeout=300) as r:
                data=r.read()
                return data if binary else json.loads(data)
        except urllib.error.HTTPError as e:
            try: message=json.loads(e.read()).get('detail',str(e))
            except Exception: message=str(e)
            raise ValueError(f'HTTP {e.code}: {message}') from None
        finally:
            if file: body.close()


def fetch_cli(url):
    # The public client download needs neither account tokens nor login cookies.
    source=Client(url).url+'/cli'
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            raise ValueError('CLI-Download wurde umgeleitet. Bitte die Server-URL prüfen.')
    request=urllib.request.Request(source,headers={'Accept':'text/x-python'})
    with urllib.request.build_opener(NoRedirect()).open(request,timeout=60) as response:
        content=response.read(1024*1024+1)
    if len(content)>1024*1024: raise ValueError('CLI-Download ist unerwartet groß; nichts geändert.')
    return content


def self_update(url,check=False):
    target=Path(__file__).resolve()
    content=fetch_cli(url)
    try:
        tree=ast.parse(content)
        # Compile without executing: reject syntax incompatible with this Python.
        compile(tree,str(target),'exec')
        versions=[node.value.value for node in tree.body
                  if isinstance(node,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='VERSION' for t in node.targets)
                  and isinstance(node.value,ast.Constant) and isinstance(node.value.value,str)]
        if len(versions)!=1 or not re.fullmatch(r'\d+\.\d+\.\d+',versions[0]): raise ValueError()
        if not any(isinstance(node,ast.FunctionDef) and node.name=='main' for node in tree.body): raise ValueError()
        version=versions[0]
    except (SyntaxError,ValueError,TypeError):
        raise ValueError('Der Download ist keine gültige sim-CLI. Die installierte Version bleibt erhalten.') from None
    if tuple(map(int,version.split('.')))<tuple(map(int,VERSION.split('.'))):
        raise ValueError('Der Server bietet eine ältere CLI-Version an. Kein Downgrade durchgeführt.')
    available=content!=target.read_bytes()
    result={'previous_version':VERSION,'version':version,'updated':False,'update_available':available,'path':str(target)}
    if check or not available: return result
    temporary=None
    try:
        mode=stat.S_IMODE(target.stat().st_mode)
        with tempfile.NamedTemporaryFile(prefix='.'+target.name+'-',suffix='.update',dir=target.parent,delete=False) as output:
            temporary=Path(output.name)
            output.write(content);output.flush();os.fsync(output.fileno())
        temporary.chmod(mode)
        os.replace(temporary,target)
    except PermissionError:
        raise ValueError(f'Keine Schreibrechte für {target}. Die CLI bitte in einem eigenen, beschreibbaren Ordner installieren (siehe {url}/docs/cli#install).') from None
    finally:
        if temporary: temporary.unlink(missing_ok=True)
    result['updated']=True
    return result


def display_value(value):
    try:
        width,height,density=map(int,value.split(','))
        if min(width,height,density)<=0: raise ValueError()
        return {'width':width,'height':height,'density':density}
    except (ValueError,TypeError): raise argparse.ArgumentTypeError('Use WIDTH,HEIGHT,DENSITY, e.g. 1080,2400,420.')


def region_value(value):
    try:
        x,y,width,height=map(float,value.split(','))
        if not (0<=x<1 and 0<=y<1 and 0<width<=1-x and 0<height<=1-y): raise ValueError()
        return dict(x=x,y=y,width=width,height=height)
    except (ValueError,TypeError): raise argparse.ArgumentTypeError('Use normalized X,Y,WIDTH,HEIGHT within 0..1, e.g. 0.1,0.2,0.8,0.3.')


def optional_timeout(value):
    if value.lower() in ('none','null'): return None
    try:
        seconds=int(value)
        if not 60<=seconds<=86400: raise ValueError()
        return seconds
    except ValueError: raise argparse.ArgumentTypeError('Use 60–86400 seconds or none.')


def tag_values(values):
    result={}
    for value in values:
        key,separator,text=value.partition('=')
        if not separator or not key: raise ValueError('--tag erwartet KEY=VALUE.')
        if key in result: raise ValueError('Tag mehrfach angegeben: '+key)
        result[key]=text
    return result


def pack_dev(root, destination, previous=None, full=False):
    """Snapshot filtered project files; archive only changes plus explicit removals."""
    previous=previous or {};snapshot={};changed=[];total=0;changed_bytes=0
    with zipfile.ZipFile(destination,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=3) as archive:
        for rel in source_paths(root):
            if rel=='__sim_sync__.json': raise ValueError('__sim_sync__.json ist für den Synchronisierungsvertrag reserviert.')
            path=root/rel;stat=path.stat();total+=stat.st_size
            if total>500*1024*1024 or len(snapshot)>=20000: raise ValueError('Dev-Quellstand überschreitet 500 MiB / 20000 Dateien; .simignore verwenden.')
            old=previous.get(rel)
            signature=(stat.st_size,stat.st_mtime_ns)
            data=None
            if old and tuple(old[:2])==signature: digest=old[2]
            else: data=path.read_bytes();digest=hashlib.sha256(data).hexdigest()
            snapshot[rel]=(*signature,digest)
            if full or not old or old[2]!=digest:
                if data is None: data=path.read_bytes();snapshot[rel]=(*signature,hashlib.sha256(data).hexdigest())
                changed_bytes+=len(data)
                archive.writestr(rel,data);changed.append(rel)
        deleted=sorted(set(previous)-set(snapshot))
        if deleted: archive.writestr('__sim_sync__.json',json.dumps({'delete_paths':deleted}))
    if full and not snapshot: raise ValueError('Keine Quelldateien für den Dev-Server gefunden.')
    if destination.stat().st_size>100*1024*1024: raise ValueError('Dev-ZIP überschreitet 100 MiB.')
    return {'snapshot':snapshot,'changed':changed,'deleted':deleted,'upload_bytes':destination.stat().st_size}


class DevTransport:
    """Retry this exact lease; source receipts are reconciled by content revision."""
    def __init__(self,client,endpoint,say):
        self.client=client;self.endpoint=endpoint;self.say=say
        self.heartbeat_path=endpoint.removesuffix('/dev-server')+'/heartbeat'
        self.last_heartbeat=time.monotonic()

    def transient(self,error):
        if isinstance(error,(OSError,urllib.error.URLError)): return True
        message=str(error).lower()
        if re.match(r'http (408|425|429|5\d\d):',message): return True
        return message.startswith('http 409:') and any(text in message for text in ('laufenden job','den test stoppen','busy','sync is already running','noch nicht bereit'))

    def heartbeat(self):
        if time.monotonic()-self.last_heartbeat>=25:
            self.call('POST',self.heartbeat_path,{})
            self.last_heartbeat=time.monotonic()

    def call(self,method,path,body=None,file=None,expected_revision=None):
        warned=False
        while True:
            try: return self.client.call(method,path,body,file=file)
            except (ValueError,OSError,urllib.error.URLError) as error:
                message=str(error).lower()
                if expected_revision is not None and (self.transient(error) or ('http 409:' in message and 'revision' in message)):
                    current=self.call('GET',self.endpoint)
                    if current.get('revision')==expected_revision: return current
                    base=parse_dev_base_revision(path)
                    if current.get('revision')!=base: raise ValueError('Quellrevision wurde von einem anderen Aufrufer geändert. Status prüfen; kein automatisches Überschreiben.') from error
                if not self.transient(error): raise
                if not warned:self.say('Dev-Synchronisierung wartet: '+str(error));warned=True
                if path!=self.heartbeat_path:self.heartbeat()
                time.sleep(2)


def parse_dev_base_revision(path):
    return urllib.parse.parse_qs(urllib.parse.urlsplit(path).query).get('base_revision',[None])[0]


def dev_revision(snapshot):
    files={name:value[2] for name,value in snapshot.items()}
    return hashlib.sha256(json.dumps(files,sort_keys=True,separators=(',',':')).encode()).hexdigest()


def dev_loop(client, endpoint, root, args, config, say):
    state_path=getattr(args,'_dev_state_path',None)
    if args.status or args.stop or args.open:
        result=client.call('DELETE' if args.stop else 'POST' if args.open else 'GET',endpoint+('/open' if args.open else ''),{} if args.open else None)
        if args.stop and state_path: state_path.unlink(missing_ok=True)
        return result
    client=DevTransport(client,endpoint,say)
    framework=args.framework or config.get('framework')
    if framework not in ('expo','react-native','custom'): raise ValueError('sim dev unterstützt Expo, React Native oder --framework custom mit --command.')
    if args.interval<1: raise ValueError('--interval muss mindestens eine Sekunde sein.')
    previous_state=read_json(state_path) if state_path else {}
    saved_app=previous_state.get('app',{}) if previous_state.get('endpoint')==endpoint else {}
    body={'framework':framework}
    for key in ('command','install_command','package','scheme'):
        value=getattr(args,key,None) or (config.get(key) if key=='package' else None) or (saved_app.get(key) if key in ('package','scheme') else None)
        if value: body[key]=value
    if framework=='custom' and not body.get('command'): raise ValueError('--framework custom benötigt --command.')
    with tempfile.TemporaryDirectory(prefix='sim-dev-') as temporary:
        archive=Path(temporary)/'source.zip'
        fingerprint=hashlib.sha256(json.dumps(body,sort_keys=True).encode()).hexdigest()
        saved=previous_state
        resuming=saved.get('endpoint')==endpoint and saved.get('revision')
        if resuming and saved.get('config_sha256')!=fingerprint: raise ValueError('Dev-Konfiguration wurde geändert. Den bisherigen Server erst bewusst mit sim dev --stop beenden.')
        state=pack_dev(root,archive,previous=saved.get('snapshot') if resuming else None,full=not resuming)
        say(f"Dev-Quellstand: {len(state['snapshot'])} Dateien · {state['upload_bytes']/1024/1024:.1f} MiB. Lokale Geheimnisfilter und .simignore gelten.")
        if resuming:
            result=client.call('GET',endpoint)
            if result.get('revision') not in (saved['revision'],dev_revision(state['snapshot'])): raise ValueError('Quellrevision wurde von einem anderen Aufrufer geändert. Kein automatisches Überschreiben; Status prüfen.')
            query={'full':'false','base_revision':result['revision']}
            needs_upload=result['revision']!=dev_revision(state['snapshot'])
        else:
            result=client.call('POST',endpoint,body);query={'full':'true'};needs_upload=True
            if result.get('revision'): query['base_revision']=result['revision']
        if needs_upload: result=client.call('PUT',endpoint+'/source?'+urllib.parse.urlencode(query),file=archive,expected_revision=dev_revision(state['snapshot']))
        def remember_source(result,snapshot):
            if state_path: write_private(state_path,{'endpoint':endpoint,'config_sha256':fingerprint,'app':{k:body[k] for k in ('package','scheme') if k in body},'revision':result.get('revision'),'snapshot':snapshot})
        last_status=None
        while not result.get('ready') or result.get('status') in ('syncing','pending'):
            if result.get('status') in ('failed','error','stopped'): raise ValueError(result.get('error') or result.get('logs') or 'Der Dev-Server ist nicht gestartet.')
            if result.get('status')!=last_status: say('Dev-Server: '+str(result.get('status','preparing')));last_status=result.get('status')
            client.heartbeat();time.sleep(2);result=client.call('GET',endpoint)
        remember_source(result,state['snapshot'])
        result['open_result']=client.call('POST',endpoint+'/open',{})
        if args.once: return result
        print(json.dumps({'event':'ready',**result},ensure_ascii=False),flush=True)
        say('Remote-Dev-Server läuft. Änderungen werden synchronisiert; Strg+C beendet nur die lokale Synchronisierung. sim dev --stop beendet den Server.')
        revision=result.get('revision')
        if not revision: raise ValueError('Dev-Server meldet keine Quellrevision; Synchronisierung nicht sicher möglich.')
        previous=state['snapshot']
        while True:
            time.sleep(args.interval);client.heartbeat()
            try: state=pack_dev(root,archive,previous=previous)
            except FileNotFoundError:
                # Editors often replace a file atomically between enumeration and read.
                continue
            if not state['changed'] and not state['deleted']:
                previous=state['snapshot'];continue
            query={'full':'false'}
            if revision is not None: query['base_revision']=revision
            result=client.call('PUT',endpoint+'/source?'+urllib.parse.urlencode(query),file=archive,expected_revision=dev_revision(state['snapshot']))
            while not result.get('ready') or result.get('status') in ('syncing','pending'):
                if result.get('status') in ('failed','error','stopped'): raise ValueError(result.get('error') or 'Der Dev-Server wurde beendet.')
                client.heartbeat();time.sleep(2);result=client.call('GET',endpoint)
            revision=result.get('revision');previous=state['snapshot'];remember_source(result,previous)
            if result.get('native_changed'): say('Native Konfiguration oder Abhängigkeiten wurden geändert. Ein neuer kompatibler Development-Build kann erforderlich sein; Fast Refresh ersetzt keinen nativen Build.')
            print(json.dumps({'event':'synced','changed':state['changed'],'deleted':state['deleted'],**result},ensure_ascii=False),flush=True)
            if result.get('status') in ('failed','error','stopped'): raise ValueError(result.get('error') or 'Der Dev-Server wurde beendet.')


def timestamp_value(value):
    from datetime import datetime
    import math
    try:
        try: number=float(value)
        except ValueError:
            parsed=datetime.fromisoformat(value.replace('Z','+00:00'))
            if parsed.tzinfo is None: raise ValueError()
            number=parsed.timestamp()
        if not math.isfinite(number) or number<0: raise ValueError()
        return number
    except (ValueError,TypeError,OverflowError): raise argparse.ArgumentTypeError('Use nonnegative Unix seconds or an ISO-8601 timestamp with timezone, e.g. 2026-10-05T12:00:00Z.')


def flow_key(value):
    if not re.fullmatch('[a-f0-9]{64}',value): raise ValueError('Flow-Key aus sim flakes verwenden (64 kleine Hex-Zeichen).')
    return value


def snapshot_id(value):
    if not re.fullmatch(r'n_[a-f0-9]{32}',value): raise ValueError('Snapshot-ID aus sim snapshots verwenden (n_ gefolgt von 32 kleinen Hex-Zeichen).')
    return value


def main(argv=None):
    parser=argparse.ArgumentParser(prog='sim',description='Build an Android app remotely, open it, delegate tests. Run from your project directory.')
    parser.add_argument('--version',action='version',version=VERSION)
    parser.add_argument('--url',default=os.getenv('SIM_URL'))
    parser.add_argument('--json',action='store_true',help='Machine-readable stdout; progress goes to stderr')
    subs=parser.add_subparsers(dest='action',required=True)
    update=subs.add_parser('update',help='Update this CLI installation; login and project not required')
    update.add_argument('--check',action='store_true',help='Check for an update without changing the installed CLI')
    login=subs.add_parser('login',help='Connect your account and save a private API key')
    login.add_argument('--email',help='Account email address')
    login.add_argument('--token',action='store_true',help='Paste an existing API key securely instead of using a password')
    up=subs.add_parser('up',help='Build, install and open the app (remote by default)')
    up.add_argument('--apk',type=Path);up.add_argument('--fresh',action='store_true');up.add_argument('--package');up.add_argument('--no-cache',action='store_true');up.add_argument('--variant',help='Android build variant, e.g. Debug for a development client')
    up.add_argument('--from-snapshot',help='Start a separate environment from this named snapshot');up.add_argument('--persistence',choices=['save','discard'],help='Default disposition when this session is released')
    up.add_argument('--device-profile');up.add_argument('--display',type=display_value,metavar='WIDTH,HEIGHT,DENSITY');up.add_argument('--locale')
    build=subs.add_parser('build',help='Remote build only; download the resulting APK')
    build.add_argument('--output',type=Path);build.add_argument('--no-cache',action='store_true');build.add_argument('--variant',help='Android build variant, e.g. Debug')
    dev=subs.add_parser('dev',help='Run an isolated remote dev server and sync this project; requires a session with a compatible development APK')
    dev_operation=dev.add_mutually_exclusive_group();dev_operation.add_argument('--status',action='store_true');dev_operation.add_argument('--stop',action='store_true');dev_operation.add_argument('--open',action='store_true',help='Reopen the installed development app on its remote server')
    dev.add_argument('--once',action='store_true',help='Upload and start, then exit without watching local changes');dev.add_argument('--interval',type=float,default=2)
    dev.add_argument('--apk',type=Path,help='Install this compatible development APK before starting; allocates a project session if needed')
    dev.add_argument('--framework',choices=['expo','react-native','custom']);dev.add_argument('--command');dev.add_argument('--install-command');dev.add_argument('--package');dev.add_argument('--scheme')
    subs.add_parser('status');down=subs.add_parser('down',help='Release this project’s simulator; defaults to the session save/discard policy')
    disposition=down.add_mutually_exclusive_group();disposition.add_argument('--discard',action='store_true',help='Discard this session changes and preserve the previous saved environment');disposition.add_argument('--save',action='store_true',help='Explicitly save even if the session default is discard')
    snapshots=subs.add_parser('snapshots',help='List immutable named Android snapshots or inspect an ID');snapshots.add_argument('snapshot_id',nargs='?')
    snapshot=subs.add_parser('snapshot',help='Create a named local Android checkpoint; active Android pauses and resumes in the same session')
    snapshot.add_argument('--name',required=True);snapshot.add_argument('--from-saved',action='store_true',help='Copy a stopped saved environment instead of checkpointing the active session');snapshot.add_argument('--no-wait',action='store_true')
    snapshot_delete=subs.add_parser('snapshot-delete',help='Delete an explicitly selected named snapshot');snapshot_delete.add_argument('snapshot_id')
    saved_tests=subs.add_parser('tests',help='List reusable tests or inspect one definition and learned plan');saved_tests.add_argument('test_id',nargs='?')
    create_test=subs.add_parser('test-create',help='Create a reusable parameterized test from a JSON definition');create_test.add_argument('--file',type=Path,required=True)
    update_test=subs.add_parser('test-update',help='Update a saved test, invalidating its learned plan');update_test.add_argument('test_id');update_test.add_argument('--file',type=Path,required=True);update_test.add_argument('--expected-version',type=int)
    delete_test=subs.add_parser('test-delete',help='Delete a saved test definition and learned plan');delete_test.add_argument('test_id')
    run_test=subs.add_parser('test-run',help='Run a reusable test, learning or reusing its guarded path');run_test.add_argument('test_id');run_test.add_argument('--mode',choices=['auto','learn','replay'],default='auto');run_test.add_argument('--bindings-file',type=Path);run_test.add_argument('--options-file',type=Path);run_test.add_argument('--request-id');run_test.add_argument('--no-wait',action='store_true')
    test=subs.add_parser('test',help='Delegate an app test and return the report')
    test.add_argument('task');test.add_argument('--model',choices=['robot','qwen','astra','opus'],default='robot');test.add_argument('--no-wait',action='store_true');test.add_argument('--context-file',type=Path)
    shot=subs.add_parser('screenshot');shot.add_argument('--output',type=Path)
    logs=subs.add_parser('logs',help='Show the last build status and log');logs.add_argument('--build-id')
    cancel=subs.add_parser('cancel-build');cancel.add_argument('--build-id')
    subs.add_parser('environments', help='List this app’s isolated test environments and saved baseline')
    subs.add_parser('baseline', help='Publish the selected stopped environment as the starting point for new environments')
    test.add_argument('--kind',choices=['model','maestro'],default='model')
    test.add_argument('--flows',type=Path,help='Upload this Maestro flow directory')
    test.add_argument('--flow-id');test.add_argument('--flow',action='append',dest='flow_paths',help='Maestro entrypoint path inside the uploaded bundle; repeat for multiple flows');test.add_argument('--criteria-file',type=Path)
    test.add_argument('--repeat',type=int,default=1,dest='repeat_count')
    test.add_argument('--reset-between',choices=['none','restart_app','clear_data'],default='none')
    test.add_argument('--package');test.add_argument('--recovery-after',type=int,default=3)
    test.add_argument('--no-video',action='store_true');test.add_argument('--revision-probe-file',type=Path)
    test.add_argument('--webhook-id');test.add_argument('--request-id',help='Reuse on retries of this job in this session')
    test.add_argument('--no-store-artifacts',action='store_true',help='Do not persist screenshots, UI trees, logs or video')
    test.add_argument('--retention-days',type=int,default=30);test.add_argument('--needs-input-timeout',type=optional_timeout,default=900,metavar='SECONDS|none')
    test.add_argument('--test-key',help='Stable identity for a named model test across jobs; independent of task wording')
    test.add_argument('--external-id',help='Ticket or pipeline correlation ID');test.add_argument('--tag',action='append',default=[],metavar='KEY=VALUE')
    flow=subs.add_parser('flows',help='Upload a directory of Maestro YAML flows and referenced assets');flow.add_argument('directory',type=Path,nargs='?');flow.add_argument('--delete',metavar='ID')
    briefing=subs.add_parser('briefing',help='Read, replace or delete persistent private app context')
    change=briefing.add_mutually_exclusive_group();change.add_argument('--file',type=Path);change.add_argument('--delete',action='store_true')
    configure=subs.add_parser('configure',help='Configure display, locale, permissions, deep links, network and device clock');configure.add_argument('--file',type=Path)
    configure.add_argument('--device-profile');configure.add_argument('--display',type=display_value,metavar='WIDTH,HEIGHT,DENSITY');configure.add_argument('--locale')
    revision=subs.add_parser('revision',help='Inspect installed APK/version and runtime revision evidence');revision.add_argument('--package',required=True);revision.add_argument('--probe-uri')
    for action in ('job','answer','takeover','actions','resume','observe','artifacts','watch','divergence','compare'):
        command=subs.add_parser(action,help={'job':'Read a job report','answer':'Answer a blocking agent question','takeover':'Pause device agent and acquire control','actions':'Execute native actions while owning control','resume':'Return device control to the testing agent','observe':'Capture current screenshot and UI tree','artifacts':'List, download, delete or retain job evidence','watch':'Follow device-agent actions and evidence without taking control','divergence':'Find the first different screen across repetitions','compare':'Compare a screen region with a green baseline'}[action])
        command.add_argument('job_id')
        if action=='answer': command.add_argument('question_id');command.add_argument('answer')
        if action=='actions': command.add_argument('--file',type=Path,required=True)
        if action=='resume': command.add_argument('--message',default='')
        if action=='artifacts':
            operation=command.add_mutually_exclusive_group();operation.add_argument('--filename');operation.add_argument('--delete',action='store_true');operation.add_argument('--pin',action='store_true');operation.add_argument('--unpin',action='store_true')
            command.add_argument('--output',type=Path);command.add_argument('--retention-days',type=int)
        if action=='watch': command.add_argument('--after',type=int,default=0);command.add_argument('--once',action='store_true');command.add_argument('--interval',type=float,default=2)
        if action=='divergence': command.add_argument('--threshold',type=float)
        if action=='compare':
            command.add_argument('--region',type=region_value,required=True,metavar='X,Y,WIDTH,HEIGHT')
            command.add_argument('--baseline-job');command.add_argument('--baseline-session');command.add_argument('--current-filename');command.add_argument('--baseline-filename');command.add_argument('--threshold',type=float,default=.03)
    subs.add_parser('device-profiles',help='List available Android display presets')
    subs.add_parser('limits',help='Inspect API concurrency, upload and rate limits')
    files=subs.add_parser('files',help='List, upload or download files in shared Android media directories')
    files.add_argument('path',nargs='?',default='Download');transfer=files.add_mutually_exclusive_group();transfer.add_argument('--upload',type=Path);transfer.add_argument('--output',type=Path)
    clipboard=subs.add_parser('clipboard',help='Read Android clipboard or set it from a selected UTF-8 text file');clipboard.add_argument('--file',type=Path)
    updates=subs.add_parser('updates',help='Ask an app-supported transport to check/reload an OTA channel and inspect revision evidence')
    updates.add_argument('package');updates.add_argument('--channel',required=True);updates.add_argument('--action',choices=['check','reload'],default='check',dest='update_action')
    update_transport=updates.add_mutually_exclusive_group(required=True);update_transport.add_argument('--deep-link');update_transport.add_argument('--provider-uri');updates.add_argument('--revision-probe-uri')
    logcat=subs.add_parser('logcat',help='Read incremental Android logs without clearing the device buffer');logcat.add_argument('--after');logcat.add_argument('--limit',type=int,default=200);logcat.add_argument('--package');logcat.add_argument('--follow',action='store_true');logcat.add_argument('--interval',type=float,default=2)
    flakes=subs.add_parser('flakes',help='Read cross-job test history and flake metrics for this app/environment')
    flakes.add_argument('flow_key',nargs='?');flakes.add_argument('--window',type=int,default=20);flakes.add_argument('--kind',choices=['model','maestro']);flakes.add_argument('--model');flakes.add_argument('--since',type=timestamp_value);flakes.add_argument('--until',type=timestamp_value);flakes.add_argument('--revision')
    quarantine=subs.add_parser('quarantine',help='Manually annotate a flow as nonblocking for an explicitly opted-in monitoring gate; never skips execution')
    quarantine.add_argument('flow_key');quarantine.add_argument('--reason',default='');quarantine.add_argument('--until',type=timestamp_value);quarantine.add_argument('--clear',action='store_true')
    jobs=subs.add_parser('jobs',help='Search account job history, including released sessions')
    jobs.add_argument('--query',dest='q');jobs.add_argument('--external-id');jobs.add_argument('--request-id');jobs.add_argument('--outcome');jobs.add_argument('--tag',metavar='KEY:VALUE');jobs.add_argument('--limit',type=int,default=50);jobs.add_argument('--offset',type=int,default=0)
    subs.add_parser('capacity',help='Inspect available device capacity and queue')
    reserve=subs.add_parser('reserve',help='Queue a simulator reservation');reserve.add_argument('--request-id');reserve.add_argument('--ttl-seconds',type=int,default=1800);reserve.add_argument('--fresh',action='store_true');reserve.add_argument('--wait-timeout-seconds',type=int,default=3600);reserve.add_argument('--from-snapshot');reserve.add_argument('--persistence',choices=['save','discard'])
    subs.add_parser('reservations',help='List queued and allocated reservations')
    reservation=subs.add_parser('reservation',help='Read or cancel a queued reservation');reservation.add_argument('reservation_id');reservation.add_argument('--cancel',action='store_true')
    webhooks=subs.add_parser('webhooks',help='List, register or delete completion webhooks')
    webhook_change=webhooks.add_mutually_exclusive_group();webhook_change.add_argument('--file',type=Path);webhook_change.add_argument('--delete',metavar='ID');webhook_change.add_argument('--deliveries',metavar='ID')
    subs.add_parser('keep',help='Renew the selected idle simulator lease')
    subs.add_parser('stop',help='Stop the current test without releasing Android')
    for name, command in subs.choices.items():
        if name not in ('login', 'update'):
            command.add_argument('--session-id', help='Use an existing session ID, including sessions reserved through MCP/API')
            command.add_argument('--app', '--workspace', dest='workspace', help='App/project identifier (shared by its environments)')
            command.add_argument('--environment', default=os.getenv('SIM_ENVIRONMENT'), help='Isolated test environment; auto-selected per Git worktree')
    args=parser.parse_args(argv)
    user_path=Path(os.getenv('XDG_CONFIG_HOME',Path.home()/'.config'))/'sim/config.json'
    user=read_json(user_path)
    url=args.url or user.get('url',DEFAULT_URL)
    if args.action=='update':
        if not args.json: print('CLI-Update wird geprüft …',file=sys.stderr,flush=True)
        result=self_update(url,check=args.check)
        if args.json: print(json.dumps(result))
        elif result['updated']: print(f"sim wurde aktualisiert: {result['previous_version']} → {result['version']}")
        elif result['update_available']: print(f"Update verfügbar: {result['previous_version']} → {result['version']}. Mit sim update installieren.")
        else: print('sim ist aktuell ('+result['version']+').')
        return
    client=Client(url,os.getenv('SIM_API_KEY',user.get('key','')))
    def say(message): print(message,file=sys.stderr,flush=True)
    if args.action=='login':
        client.key=''
        if args.token:
            client.key=getpass.getpass('API-Token: ').strip()
            if not client.key.startswith('sim_'): raise ValueError('Ungültiger API-Token.')
            client.call('GET','/v1/me')
            result={'token':client.key}
        else:
            email=args.email or input('E-Mail: ').strip()
            client.call('POST','/api/auth/login',{'email':email,'password':getpass.getpass('Passwort: ')})
            result=client.call('POST','/v1/keys',{'name':'CLI'})
            client.call('POST','/api/auth/logout',{})
        write_private(user_path,{'url':client.url,'key':result['token']})
        print(json.dumps({'logged_in':True,'url':client.url}) if args.json else 'Verbunden. Im App-Ordner: sim up');return
    if not client.key: raise ValueError('Zuerst sim login ausführen oder SIM_API_KEY setzen.')
    root=Path.cwd()
    cfg,local_path,local=project_context(root,client.url,args.workspace,args.environment)
    workspace=local['workspace'];environment=local['environment']
    if args.session_id:
        if not re.fullmatch('[a-f0-9]{32}',args.session_id): raise ValueError('Ungültige Sitzungs-ID.')
        local['session_id']=args.session_id
    def remember():
        write_private(local_path,local)
        ignore=root/'.sim/.gitignore'
        if not ignore.exists(): ignore.write_text('*\n')
    if hasattr(args,'output') and args.output is None and args.action!='files':
        args.output=local_path.parent/('app.apk' if args.action=='build' else (safe_artifact_name(args.filename) if args.action=='artifacts' and args.filename else 'screenshot.jpg'))
    def session():
        if not local.get('session_id'): raise ValueError('Noch keine Sitzung für diese Umgebung. Zuerst sim up --environment '+environment+'.')
        return '/v1/sessions/'+local['session_id']
    if args.action in ('up','build'):
        if getattr(args,'from_snapshot',None):
            snapshot_id(args.from_snapshot)
            if args.fresh or args.session_id: raise ValueError('--from-snapshot nicht mit --fresh oder --session-id kombinieren; eine eigene neue Umgebung wählen.')
        if getattr(args,'persistence',None) and args.session_id: raise ValueError('--persistence gilt für neue Sessions; beim Beenden einer vorhandenen Session --save oder --discard wählen.')
        if getattr(args,'device_profile',None) is not None and getattr(args,'display',None) is not None: raise ValueError('--device-profile und --display getrennt verwenden.')
        if getattr(args,'apk',None) and not args.apk.is_file(): raise ValueError('APK nicht gefunden.')
        attached_session=None
        if args.action=='up' and args.session_id:
            if args.fresh: raise ValueError('--fresh kann nicht mit --session-id verwendet werden. Für ein leeres Gerät eine neue Session anlegen.')
            attached_session=client.call('GET',session())
            if attached_session.get('workspace')!=workspace or attached_session.get('environment')!=environment:
                raise ValueError('Die Session gehört zu einer anderen App oder Testumgebung. --app und --environment passend zur Session angeben.')
        remember();build_id=None
        restore_only=args.action=='up' and args.from_snapshot and not args.apk
        if not getattr(args,'apk',None) and not restore_only:
            config=detect(root)
            if args.variant: config['variant']=args.variant
            say('Projekt erkannt: '+config['framework']+'. Quellstand wird vorbereitet …')
            with tempfile.TemporaryDirectory(prefix='sim-') as tmp:
                archive=Path(tmp)/'source.zip';summary=pack(root,archive,config);source=source_metadata(root,summary)
                say(f"{summary['files']} Dateien · {summary['upload_bytes']/1024/1024:.1f} MB Upload. .simignore wird berücksichtigt.")
                job=client.call('POST','/v1/builds',{'workspace':workspace,'environment':environment,'config':config,'no_cache':args.no_cache,'source':source})
                build_id=job['id'];local['build_id']=build_id;remember()
                job=client.call('PUT',f'/v1/builds/{build_id}/source',file=archive)
            offset=0;previous=''
            while job['status'] not in ('succeeded','failed','cancelled','interrupted'):
                state=(job['status'],job.get('waiting_reason','') if job['status']=='waiting' else '')
                if state!=previous:
                    say('Build: '+state[0]+(' — '+state[1] if state[1] else ''));previous=state
                log=job.get('log','')
                if len(log)>offset: say(log[offset:].rstrip());offset=len(log)
                time.sleep(3);job=client.call('GET',f'/v1/builds/{build_id}')
            if job['status']!='succeeded': raise ValueError(job.get('error') or job.get('log','')[-3000:] or job['status'])
            say('APK aus Cache.' if job.get('cached') else f"Build fertig ({job.get('duration_seconds',0):.1f} s).")
            if args.action=='build':
                args.output.parent.mkdir(parents=True,exist_ok=True)
                args.output.write_bytes(client.call('GET',f'/v1/builds/{build_id}/apk',binary=True))
                result={'build_id':build_id,'workspace':workspace,'environment':environment,'source':job.get('source',source),'apk':str(args.output.resolve()),'cached':job.get('cached',False)}
                print(json.dumps(result) if args.json else 'APK: '+result['apk']);return
        if attached_session is not None:
            s=attached_session
        else:
            body={'workspace':workspace,'environment':environment,'name':root.name,'fresh':args.fresh,'request_id':uuid.uuid4().hex}
            body.update({k:getattr(args,k) for k in ('device_profile','display','locale') if getattr(args,k) is not None})
            if args.from_snapshot: body['snapshot_id']=args.from_snapshot
            if args.persistence: body['persistence']=args.persistence
            s=client.call('POST','/v1/sessions',body)
        local['session_id']=s['id'];remember()
        say('Android wird vorbereitet …')
        while not s['ready']:
            if s['state']=='failed': raise ValueError(s.get('error') or 'Android-Start fehlgeschlagen.')
            time.sleep(3);s=client.call('POST',session()+'/heartbeat',{})
        if attached_session is not None:
            device={k:getattr(args,k) for k in ('device_profile','display','locale') if getattr(args,k) is not None}
            if device: client.call('POST',session()+'/configure',device)
        if build_id:
            result=client.call('POST',session()+'/builds/'+build_id,{})
        elif args.apk:
            result=client.call('POST',session()+'/apk?'+urllib.parse.urlencode({'package':args.package or cfg.get('package','')}),file=args.apk)
        else: result={'restored':True,'snapshot_id':args.from_snapshot}
        result.update(session_id=s['id'],workspace=workspace,environment=environment,observer_url=s['observer_url'],build_id=build_id)
        print(json.dumps(result) if args.json else ('Snapshot bereit: ' if restore_only else 'App läuft: ' if result.get('launched') else 'APK installiert: ')+s['observer_url'])
    elif args.action=='environments':
        result=client.call('GET','/v1/apps/'+workspace+'/environments')
        print(json.dumps(result,indent=None if args.json else 2))
    elif args.action=='baseline':
        result=client.call('POST','/v1/apps/'+workspace+'/baseline',{'environment':environment})
        print(json.dumps(result) if args.json else 'Ausgangszustand gespeichert. Neue Umgebungen erhalten eine unabhängige Kopie.')
    elif args.action=='status':
        if local.get('session_id'):
            result=client.call('GET',session())
        else:
            environments=client.call('GET','/v1/apps/'+workspace+'/environments')
            result=next((row for row in environments.get('environments',[]) if row['id']==environment),{'id':environment,'state':'not_created'})
            result={**result,'workspace':workspace,'environment':environment}
        print(json.dumps(result,indent=None if args.json else 2))
    elif args.action=='down':
        disposition='discard' if args.discard else 'save' if args.save else None
        result=client.call('DELETE',session()+('?' + urllib.parse.urlencode({'disposition':disposition}) if disposition else ''));local.pop('session_id',None);remember()
        print(json.dumps(result) if args.json else ('Sitzung beendet. Änderungen werden verworfen; der vorherige gespeicherte Stand bleibt erhalten.' if disposition=='discard' or result.get('disposition')=='discard' else 'Sitzung beendet. Die gespeicherte Session-Regel wird angewendet.'))
    elif args.action=='screenshot':
        args.output.parent.mkdir(parents=True,exist_ok=True);args.output.write_bytes(client.call('GET',session()+'/screenshot',binary=True))
        print(json.dumps({'screenshot':str(args.output.resolve())}) if args.json else str(args.output.resolve()))
    elif args.action in ('logs','cancel-build'):
        bid=args.build_id or local.get('build_id')
        if not bid: raise ValueError('Kein Build in diesem Ordner.')
        result=client.call('DELETE' if args.action=='cancel-build' else 'GET','/v1/builds/'+bid)
        print(json.dumps(result) if args.json else result.get('log') or result['status'])
    elif args.action=='test':
        context=args.context_file.read_text() if args.context_file else ''
        body={'task':args.task,'model':args.model,'context':context,'kind':args.kind,
              'repeat_count':args.repeat_count,'reset_between':args.reset_between,'recovery_after':args.recovery_after,
              'capture_video':not args.no_video,'request_id':args.request_id or uuid.uuid4().hex,
              'store_artifacts':not args.no_store_artifacts,'artifact_retention_days':args.retention_days,
              'needs_input_timeout_seconds':args.needs_input_timeout,'tags':tag_values(args.tag)}
        if args.external_id is not None: body['external_id']=args.external_id
        if args.test_key is not None: body['test_key']=args.test_key
        if args.criteria_file: body['criteria']=json_file(args.criteria_file,list)
        if args.package: body['package']=args.package
        if args.webhook_id: body['webhook_id']=args.webhook_id
        if args.revision_probe_file: body['revision_probe']=json_file(args.revision_probe_file)
        if args.flows:
            if args.flow_id: raise ValueError('Entweder --flows oder --flow-id verwenden.')
            with tempfile.TemporaryDirectory(prefix='sim-flows-') as tmp:
                archive=Path(tmp)/'flows.zip';pack_flows(args.flows,archive)
                body['flow_id']=client.call('POST','/v1/flows',file=archive)['id']
            body['kind']='maestro'
        elif args.flow_id: body.update(flow_id=args.flow_id,kind='maestro')
        if body['kind']=='maestro' and not body.get('flow_id'): raise ValueError('Maestro benötigt --flows oder --flow-id.')
        if args.flow_paths:
            if body['kind']!='maestro': raise ValueError('--flow gilt nur für Maestro-Jobs (--flows oder --flow-id).')
            body['flow_paths']=args.flow_paths
        if body['kind']=='maestro' and body.get('criteria'): raise ValueError('Maestro-Abnahmekriterien als YAML-Assertions angeben; --criteria-file gilt für Modelltests.')
        result=client.call('POST',session()+'/jobs',body)
        local['job_id']=result['id'];remember()
        if not args.no_wait:
            say('Test läuft. Strg+C beendet nur das Warten; der Auftrag läuft weiter.')
            while result['status'] not in ('completed','passed','failed','error','stopped','interrupted','needs_input','awaiting_input','paused','takeover') and (result.get('control') or {}).get('mode')!='caller':
                time.sleep(3);result=client.call('GET',session()+'/jobs/'+result['id'])
        print(json.dumps(result,indent=None if args.json else 2))
    else:
        action=args.action
        if action=='flows' and args.directory:
            if args.delete: raise ValueError('Verzeichnis-Upload und --delete nicht kombinieren.')
            with tempfile.TemporaryDirectory(prefix='sim-flows-') as tmp:
                archive=Path(tmp)/'flows.zip';pack_flows(args.directory,archive)
                result=client.call('POST','/v1/flows',file=archive)
        elif action=='flows':
            if args.delete: validate_slug(args.delete,'Flow-Paket')
            result=client.call('DELETE' if args.delete else 'GET','/v1/flows'+('/'+args.delete if args.delete else ''))
        elif action=='briefing':
            result=client.call('DELETE' if args.delete else 'PUT' if args.file else 'GET','/v1/apps/'+workspace+'/briefing',json_file(args.file) if args.file else None)
        elif action=='configure':
            body=json_file(args.file) if args.file else {}
            body.update({k:getattr(args,k) for k in ('device_profile','display','locale') if getattr(args,k) is not None})
            if body.get('device_profile') is not None and body.get('display') is not None: raise ValueError('device_profile und display getrennt verwenden.')
            if not body: raise ValueError('Eine Konfiguration mit --file, --device-profile, --display oder --locale angeben.')
            result=client.call('POST',session()+'/configure',body)
        elif action=='revision': result=client.call('GET',session()+'/revision'+'?' + urllib.parse.urlencode({'package':args.package,**({'probe_uri':args.probe_uri} if args.probe_uri else {})}))
        elif action in ('job','answer','takeover','actions','resume','observe','artifacts','watch','divergence','compare'):
            if not re.fullmatch('[a-f0-9]{32}',args.job_id): raise ValueError('Ungültige Auftrags-ID.')
            path=session()+'/jobs/'+args.job_id
            if action=='job': result=client.call('GET',path)
            elif action=='answer': result=client.call('POST',path+'/answer',{'question_id':args.question_id,'answer':args.answer})
            elif action=='takeover': result=client.call('POST',path+'/takeover',{})
            elif action=='actions': result=client.call('POST',path+'/actions',{'actions':json_file(args.file,list)})
            elif action=='resume': result=client.call('POST',path+'/resume',{'message':args.message})
            elif action=='observe': result=client.call('GET',path+'/observe')
            elif action=='watch':
                if args.interval<1: raise ValueError('--interval muss mindestens eine Sekunde sein.')
                cursor=args.after
                while True:
                    result=client.call('GET',path+'/live?'+urllib.parse.urlencode({'after':cursor}))
                    print(json.dumps(result,ensure_ascii=False),flush=True)
                    cursor=result.get('cursor',cursor)
                    terminal=result.get('terminal') or result.get('status') in ('completed','passed','failed','error','stopped','interrupted','cancelled')
                    if args.once or (terminal and not result.get('has_more')): return
                    if not result.get('has_more'): time.sleep(args.interval)
            elif action=='divergence': result=client.call('GET',path+'/divergence'+('?' + urllib.parse.urlencode({'threshold':args.threshold}) if args.threshold is not None else ''))
            elif action=='compare':
                body={'region':args.region,'threshold':args.threshold}
                for flag,key in (('baseline_job','baseline_job_id'),('baseline_session','baseline_session_id'),('current_filename','current_filename'),('baseline_filename','baseline_filename')):
                    value=getattr(args,flag)
                    if value is not None: body[key]=value
                result=client.call('POST',path+'/compare',body)
            elif args.delete:
                if args.retention_days is not None: raise ValueError('--delete nicht mit --retention-days kombinieren.')
                result=client.call('DELETE',path+'/artifacts')
            elif args.pin or args.unpin or args.retention_days is not None:
                if args.filename: raise ValueError('Download und Aufbewahrung getrennt aufrufen.')
                body={}
                if args.pin or args.unpin: body['pinned']=args.pin
                if args.retention_days is not None: body['artifact_retention_days']=args.retention_days
                result=client.call('PATCH',path+'/retention',body)
            elif args.filename:
                args.output.parent.mkdir(parents=True,exist_ok=True)
                args.output.write_bytes(client.call('GET',path+'/artifacts/'+safe_artifact_name(args.filename),binary=True))
                result={'path':str(args.output.resolve()),'filename':args.filename}
            else: result=client.call('GET',path+'/artifacts')
        elif action=='dev':
            config={} if args.status or args.stop or args.open else (cfg if args.framework=='custom' else detect(root))
            if not (args.status or args.stop or args.open):
                app_config=read_json(root/'app.json').get('expo',{})
                configured_scheme=app_config.get('scheme')
                if not args.scheme and isinstance(configured_scheme,str): args.scheme=configured_scheme
                if not args.package and isinstance(app_config.get('android'),dict): args.package=app_config['android'].get('package')
            if not (args.status or args.stop or args.open or args.apk or local.get('session_id')):
                if config.get('framework')=='expo':
                    project_package=read_json(root/'package.json');dependencies={**project_package.get('dependencies',{}),**project_package.get('devDependencies',{})}
                    if 'expo-dev-client' not in dependencies: raise ValueError('Für sim dev zuerst expo-dev-client zum Projekt hinzufügen oder eine kompatible Development-APK mit --apk angeben. Eine Release-APK/Expo Go ersetzt keinen projektspezifischen Dev-Client.')
                if config.get('framework') not in ('expo','react-native'): raise ValueError('Für dieses Projekt zuerst eine Development-APK mit sim dev --apk DATEI installieren.')
                import contextlib,io
                say('Einmaliger nativer Development-Build wird remote erstellt. Danach laufen JS-Änderungen über Fast Refresh.')
                captured=io.StringIO()
                with contextlib.redirect_stdout(captured): main(['--url',client.url,'--json','up','--app',workspace,'--environment',environment,'--variant','Debug'])
                local.update(read_json(local_path))
                built=json.loads(captured.getvalue())
                if not args.package and isinstance(built.get('launched'),str): args.package=built['launched']
            if args.apk:
                if args.status or args.stop or args.open: raise ValueError('--apk ist nur beim Start des Dev-Servers möglich.')
                if not args.apk.is_file(): raise ValueError('Development-APK nicht gefunden.')
                if local.get('session_id'):
                    attached=client.call('GET',session())
                    if attached.get('workspace')!=workspace or attached.get('environment')!=environment: raise ValueError('Session passt nicht zu --app / --environment.')
                else:
                    attached=client.call('POST','/v1/sessions',{'workspace':workspace,'environment':environment,'name':root.name,'request_id':uuid.uuid4().hex})
                    local['session_id']=attached['id'];remember()
                while not attached.get('ready'):
                    if attached.get('state')=='failed': raise ValueError(attached.get('error') or 'Android-Start fehlgeschlagen.')
                    time.sleep(3);attached=client.call('POST',session()+'/heartbeat',{})
                client.call('POST',session()+'/apk?'+urllib.parse.urlencode({'package':args.package or config.get('package','')}),file=args.apk)
            args._dev_state_path=local_path.parent/'dev-source.json'
            result=dev_loop(client,session()+'/dev-server',root,args,config,say)
        elif action=='updates':
            body={'package':args.package,'channel':args.channel,'action':args.update_action}
            if args.deep_link: body['deep_link']=args.deep_link
            if args.provider_uri: body['provider_uri']=args.provider_uri
            if args.revision_probe_uri: body['revision_probe']={'uri':args.revision_probe_uri}
            result=client.call('POST',session()+'/updates',body)
        elif action=='files':
            path=args.path
            if args.upload:
                if not args.upload.is_file(): raise ValueError('Die gewählte lokale Datei existiert nicht.')
                if args.upload.stat().st_size>50*1024*1024: raise ValueError('Gerätedateien dürfen höchstens 50 MiB groß sein.')
                if path in ('Download','Pictures','DCIM','Movies'): path+='/'+args.upload.name
                result=client.call('PUT',session()+'/files?'+urllib.parse.urlencode({'path':path}),file=args.upload)
            elif args.output:
                content=client.call('GET',session()+'/files?'+urllib.parse.urlencode({'path':path}),binary=True)
                args.output.parent.mkdir(parents=True,exist_ok=True);args.output.write_bytes(content)
                result={'path':path,'output':str(args.output.resolve()),'bytes':len(content)}
            else: result=client.call('GET',session()+'/files/list?'+urllib.parse.urlencode({'path':path}))
        elif action=='clipboard':
            body={'text':args.file.read_text(encoding='utf-8')} if args.file else None
            if body and (len(body['text'])>16384 or len(body['text'].encode('utf-8'))>65536): raise ValueError('Zwischenablage: maximal 16384 Zeichen / 65536 UTF-8-Bytes.')
            result=client.call('PUT' if body is not None else 'GET',session()+'/clipboard',body)
        elif action=='logcat':
            if args.interval<1: raise ValueError('--interval muss mindestens eine Sekunde sein.')
            cursor=args.after
            while True:
                query={'limit':args.limit}
                if cursor is not None: query['after']=cursor
                if args.package: query['package']=args.package
                result=client.call('GET',session()+'/logcat?'+urllib.parse.urlencode(query))
                if not args.follow: break
                print(json.dumps(result,ensure_ascii=False),flush=True);cursor=result.get('next_cursor')
                if not result.get('has_more'): time.sleep(args.interval)
        elif action in ('tests','test-create','test-update','test-delete','test-run'):
            ident=getattr(args,'test_id',None)
            if ident is not None: validate_slug(ident,'Test-ID')
            path='/v1/apps/'+workspace+'/tests'+('/'+ident if ident else '')
            if action=='tests': result=client.call('GET',path)
            elif action=='test-delete': result=client.call('DELETE',path)
            elif action in ('test-create','test-update'):
                body=json_file(args.file)
                if action=='test-update' and args.expected_version is not None: body['expected_version']=args.expected_version
                result=client.call('POST' if action=='test-create' else 'PATCH',path,body)
            else:
                session()
                body={'session_id':local['session_id'],'mode':args.mode,'bindings':json_file(args.bindings_file) if args.bindings_file else {},'options':json_file(args.options_file) if args.options_file else {},'request_id':args.request_id or uuid.uuid4().hex}
                if any(not isinstance(value,str) for value in body['bindings'].values()): raise ValueError('Bindings müssen ein JSON-Objekt mit Textwerten sein.')
                result=client.call('POST',path+'/run',body);local['job_id']=result['id'];remember()
                if not args.no_wait:
                    say('Gespeicherter Test läuft. Strg+C beendet nur das Warten; der Auftrag läuft weiter.')
                    while result['status'] not in ('completed','passed','failed','error','stopped','interrupted','needs_input','awaiting_input','paused','takeover') and (result.get('control') or {}).get('mode')!='caller':
                        time.sleep(3);result=client.call('GET',session()+'/jobs/'+result['id'])
        elif action=='snapshots':
            result=client.call('GET','/v1/apps/'+workspace+'/snapshots'+('/'+snapshot_id(args.snapshot_id) if args.snapshot_id else ''))
        elif action=='snapshot-delete': result=client.call('DELETE','/v1/apps/'+workspace+'/snapshots/'+snapshot_id(args.snapshot_id))
        elif action=='snapshot':
            if args.from_saved and args.session_id: raise ValueError('--from-saved nicht mit --session-id kombinieren.')
            if not args.name.strip() or len(args.name)>100: raise ValueError('Snapshot-Name: 1–100 Zeichen.')
            if local.get('session_id') and not args.from_saved:
                result=client.call('POST',session()+'/snapshots',{'name':args.name})
            else: result=client.call('POST','/v1/apps/'+workspace+'/snapshots',{'name':args.name,'environment':environment})
            if not args.no_wait:
                while result.get('status') not in ('ready','error'):
                    say('Snapshot: '+str(result.get('status','creating'))+'. Android wird beim aktiven Checkpoint kurz angehalten und danach fortgesetzt.')
                    time.sleep(2);result=client.call('GET','/v1/apps/'+result.get('workspace',workspace)+'/snapshots/'+snapshot_id(result['id']))
                if result.get('status')=='error': raise ValueError(result.get('error') or 'Snapshot konnte nicht erstellt werden.')
        elif action=='flakes':
            if not 1<=args.window<=200: raise ValueError('--window muss zwischen 1 und 200 liegen.')
            if args.since is not None and args.until is not None and args.since>args.until: raise ValueError('--since darf nicht nach --until liegen.')
            query={'environment':environment,'window':args.window}
            query.update({k:getattr(args,k) for k in ('kind','model','since','until','revision') if getattr(args,k) is not None})
            result=client.call('GET','/v1/apps/'+workspace+'/flakes'+('/'+flow_key(args.flow_key) if args.flow_key else '')+'?'+urllib.parse.urlencode(query))
        elif action=='quarantine':
            if args.clear and args.until is not None: raise ValueError('--clear nicht mit --until kombinieren.')
            if not args.clear and not args.reason.strip(): raise ValueError('Eine Begründung mit --reason angeben.')
            body={'quarantined':not args.clear,'reason':args.reason,'expires_at':None if args.clear else args.until}
            result=client.call('PATCH','/v1/apps/'+workspace+'/flakes/'+flow_key(args.flow_key)+'/quarantine?'+urllib.parse.urlencode({'environment':environment}),body)
        elif action=='jobs':
            query={k:getattr(args,k) for k in ('q','external_id','request_id','outcome','tag','limit','offset') if getattr(args,k) is not None}
            if args.workspace: query['workspace']=workspace
            if args.environment: query['environment']=environment
            result=client.call('GET','/v1/jobs?'+urllib.parse.urlencode(query))
        elif action in ('capacity','device-profiles','limits'): result=client.call('GET','/v1/'+action)
        elif action=='reserve':
            if args.from_snapshot:
                snapshot_id(args.from_snapshot)
                if args.fresh: raise ValueError('--from-snapshot nicht mit --fresh kombinieren.')
            result=client.call('POST','/v1/reservations',{'workspace':workspace,'environment':environment,'name':root.name,'request_id':args.request_id or uuid.uuid4().hex,'ttl_seconds':args.ttl_seconds,'wait_timeout_seconds':args.wait_timeout_seconds,'fresh':args.fresh,**({'snapshot_id':args.from_snapshot} if args.from_snapshot else {}),**({'persistence':args.persistence} if args.persistence else {})})
            local['reservation_id']=result['id']
            if result.get('session_id'): local['session_id']=result['session_id']
            remember()
        elif action=='reservations': result=client.call('GET','/v1/reservations')
        elif action=='reservation':
            validate_slug(args.reservation_id,'Reservierung')
            result=client.call('DELETE' if args.cancel else 'GET','/v1/reservations/'+args.reservation_id)
            if result.get('workspace',workspace)==workspace and result.get('environment',environment)==environment:
                if result.get('status')=='allocated' and result.get('session_id'):
                    local['session_id']=result['session_id'];remember()
                elif result.get('status') in ('released','cancelled','expired','failed') and local.get('session_id')==result.get('session_id'):
                    local.pop('session_id',None);remember()
        elif action=='webhooks' and args.deliveries:
            validate_slug(args.deliveries,'Webhook')
            result=client.call('GET','/v1/webhooks/'+args.deliveries+'/deliveries')
        elif action=='webhooks':
            if args.delete: validate_slug(args.delete,'Webhook')
            result=client.call('DELETE' if args.delete else 'POST' if args.file else 'GET','/v1/webhooks'+('/'+args.delete if args.delete else ''),json_file(args.file) if args.file else None)
        elif action in ('keep','stop'): result=client.call('POST',session()+('/heartbeat' if action=='keep' else '/stop'),{})
        else: raise ValueError('Unbekannter Befehl.')
        print(json.dumps(result,indent=None if args.json else 2))



if __name__=='__main__':
    try: main()
    except KeyboardInterrupt: print('\nWarten beendet. sim status / sim logs zeigt den Stand.',file=sys.stderr);sys.exit(130)
    except (ValueError,OSError,urllib.error.URLError) as e: print('sim: '+str(e),file=sys.stderr);sys.exit(1)
