#!/usr/bin/env python3
"""Unified time-ordered replay of every recorded file operation (Claude Write/Edit/Read-full/Bash heredocs, Codex apply_patch/heredocs).

usage: replay_all.py WORKDIR
WORKDIR must contain:  bash.json (Claude Bash commands), release_files.txt (runtime files = authoritative),
                       sbx/ (initial sandbox: the release files + the 2026-09-27 nightly files).
Everything happens inside WORKDIR/sbx. Python heredocs run statement by statement (a failing assert does not stop the rest),
only after a deny-list check; nothing outside the sandbox is touched. Result: WORKDIR/sbx (+ WORKDIR/report.txt).
"""
import glob
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys

N = os.path.abspath(sys.argv[1])
SBX = N + '/sbx'
D = '/Users/chudy/Library/Application Support/Kodi/addons/plugin.video.milionar'
CLAUDE = os.path.expanduser('~/.claude/projects/-Users-chudy-Library-Application-Support-Kodi-addons-plugin-video-milionar/')
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location('codex_replay', os.path.join(HERE, 'replay_codex_sessions.py'))
codex = importlib.util.module_from_spec(spec)
spec.loader.exec_module(codex)
# Replayed code runs under macOS sandbox-exec: no network, writes only below the sandbox directory (and system temp dirs).
PROFILE = ('(version 1)(allow default)(deny network*)(deny file-write* (require-not (require-any (subpath "%s") '
           '(subpath "/private/var/folders") (subpath "/dev"))))' % N)
BAD = ()   # nothing is skipped by keyword any more: the OS sandbox is the guard
SANDBOX = ['sandbox-exec', '-p', PROFILE]
RUNNER = r'''
import ast, sys
src = sys.stdin.read()
glb = {'__name__': '__main__'}
failed = 0
for node in ast.parse(src).body:
    try:
        exec(compile(ast.Module([node], []), '<heredoc>', 'exec'), glb)
    except SystemExit:
        pass
    except BaseException as error:
        failed += 1
        print('stmt line %d: %s: %s' % (node.lineno, type(error).__name__, str(error)[:80]), file=sys.stderr)
sys.exit(1 if failed else 0)
'''
log = []
stats = {}


def count(key):
    stats[key] = stats.get(key, 0) + 1


def fix(text):
    return text.replace(D, SBX)


def inside(path):
    path = os.path.normpath(path)
    return path == SBX or path.startswith(SBX + '/')


def write(rel, content):
    path = os.path.join(SBX, rel)
    if not inside(path):
        return False
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, 'w', encoding='utf-8') as handle:
        handle.write(content)
    return True


def read(rel):
    try:
        with open(os.path.join(SBX, rel), encoding='utf-8') as handle:
            return handle.read()
    except (OSError, UnicodeDecodeError):
        return None


def strip_cat(text):
    out = []
    for line in text.split('\n'):
        match = re.match(r'^\s*(\d+)\t(.*)$', line)
        if match:
            out.append(match.group(2))
        elif re.match(r'^\s*(\d+)$', line):
            out.append('')
        else:
            return None
    return '\n'.join(out)


def cwd_at(cmd, pos):
    cur = SBX
    for match in re.finditer(r'(?:^|[;&|]\s*|&&\s*)cd\s+(?:"([^"]+)"|\'([^\']+)\'|([^\s;&|]+))', cmd[:pos]):
        target = fix(match.group(1) or match.group(2) or match.group(3))
        if target.startswith('~') or target.startswith('$'):
            cur = SBX
            continue
        cur = target if target.startswith('/') else os.path.normpath(os.path.join(cur, target))
        if not inside(cur):
            cur = SBX + '/__outside__'
    return cur


def bash_ops(cmd):
    ops = []
    for match in re.finditer(r"python3?(?: -[A-Za-z]*)?(?: -)?(?: \"?\$?\w+\"?)* ?<<-?['\"]?(\w+)['\"]?\n(.*?)\n\1(?:\s|$)", cmd, re.S):
        ops.append((match.start(), 'py', match.group(2), cwd_at(cmd, match.start())))
    for match in re.finditer(r"cat >>? ?(\"?[^\s\"<]+\"?) <<-?['\"]?(\w+)['\"]?\n(.*?)\n\2(?:\s|$)", cmd, re.S):
        ops.append((match.start(), 'cat', (match.group(1).strip('"'), match.group(3), '>>' in cmd[match.start():match.start() + 8]),
                    cwd_at(cmd, match.start())))
    for match in re.finditer(r"(?:^|&&|;|\n)\s*(sed -i ?(?:''|\"\")? ?(?:\"(?:[^\"\\]|\\.)*\"|'[^']*') +[^\s;&|]+)", cmd):
        ops.append((match.start(1), 'sed', match.group(1), cwd_at(cmd, match.start(1))))
    return sorted(ops, key=lambda op: op[0])


def run_bash(cmd, sess):
    if not any(word in cmd for word in ('plugin.video.milionar', 'scripts/', 'wcs/', 'resources/', 'docs/', '.agents')):
        return
    for _, kind, body, cwd in bash_ops(cmd):
        if kind == 'cat':
            path, content, append = body
            path = fix(path)
            if not path.startswith('/'):
                path = os.path.join(cwd, path)
            if not inside(path):
                count('cat_skipped')
                continue
            os.makedirs(os.path.dirname(path), exist_ok=True)
            with open(path, 'a' if append else 'w', encoding='utf-8') as handle:
                handle.write(content + '\n')
            count('cat')
        elif kind == 'py':
            source = fix(body)
            if any(word in source for word in BAD) or '__outside__' in cwd:
                count('py_skipped')
                continue
            os.makedirs(cwd, exist_ok=True)
            try:
                result = subprocess.run(SANDBOX + ['python3', '-c', RUNNER], input=source, text=True, capture_output=True, cwd=cwd, timeout=120)
            except subprocess.TimeoutExpired:
                count('py_timeout')
                continue
            count('py_ok' if result.returncode == 0 else 'py_partial')
            if result.returncode:
                log.append('py %s: %s' % (sess, result.stderr.strip().replace('\n', ' | ')[:300]))
        else:
            if os.path.isdir(cwd):
                subprocess.run(SANDBOX + ['bash', '-c', fix(body)], cwd=cwd, capture_output=True, text=True, timeout=30)
                count('sed')


def claude_events():
    uses, results, order = {}, {}, 0
    for path in glob.glob(CLAUDE + '*.jsonl'):
        for line in open(path, encoding='utf-8', errors='ignore'):
            try:
                record = json.loads(line)
            except ValueError:
                continue
            stamp = record.get('timestamp') or ''
            content = (record.get('message') or {}).get('content')
            if not isinstance(content, list):
                continue
            for block in content:
                if block.get('type') == 'tool_use':
                    order += 1
                    uses[block['id']] = (stamp, order, os.path.basename(path)[:8], block['name'], block.get('input', {}))
                elif block.get('type') == 'tool_result':
                    body = block.get('content')
                    if isinstance(body, list):
                        body = ''.join(x.get('text', '') for x in body if isinstance(x, dict))
                    results[block.get('tool_use_id')] = body if isinstance(body, str) else ''
    for key, (stamp, order, sess, name, inp) in uses.items():
        yield (stamp, order, 'claude', sess, name, inp, results.get(key))


def codex_events():
    order = 0
    for path in codex.sessions():
        with open(path, encoding='utf-8', errors='ignore') as handle:
            for line in handle:
                if 'apply_patch' not in line and 'EOF' not in line and "<<'" not in line:
                    continue
                try:
                    record = json.loads(line)
                except ValueError:
                    continue
                payload = record.get('payload') or {}
                text = payload.get('input') if isinstance(payload.get('input'), str) else payload.get('arguments')
                if isinstance(text, str):
                    order += 1
                    yield (record.get('timestamp') or '', order, 'codex', os.path.basename(path)[8:24], 'patch', text, None)


def relative(path):
    path = path.strip()
    if path.startswith(D + '/'):
        return path[len(D) + 1:]
    return None if path.startswith('/') else path


def apply_claude(sess, name, inp, result):
    if name == 'Bash':
        run_bash(inp.get('command', ''), sess)
        return
    rel = relative(inp.get('file_path', '')) if inp.get('file_path') else None
    if not rel:
        return
    if name == 'Write':
        write(rel, inp.get('content', ''))
        count('write')
    elif name == 'Edit':
        text = read(rel)
        old, new = inp.get('old_string', ''), inp.get('new_string', '')
        if text is not None and old in text:
            write(rel, text.replace(old, new) if inp.get('replace_all') else text.replace(old, new, 1))
            count('edit')
        else:
            count('edit_failed')
            log.append('edit failed %s %s' % (sess, rel))
    elif name == 'Read' and result and not result.startswith('Error') and not inp.get('offset') and not inp.get('limit'):
        text = strip_cat(result.split('\n\n<system-reminder>')[0].rstrip('\n'))
        if text is not None:
            before = read(rel)
            if before is not None and before.rstrip('\n') != text.rstrip('\n'):
                log.append('read-anchor differs %s %s (sandbox %d chars, read %d)' % (sess, rel, len(before), len(text)))
                count('anchor_differs')
            elif before is not None:
                count('anchor_equal')
            write(rel, text + ('\n' if not text.endswith('\n') else ''))


def apply_codex(sess, text):
    patches = list(codex.js_strings_after(text, 'apply_patch(')) + [m for m in re.findall(r'\*\*\* Begin Patch.*?\*\*\* End Patch', text, re.S)
                                                                      if '\\n' not in m]
    for body in patches:
        if '*** Begin Patch' not in body:
            continue
        for op in codex.parse_patch(body):
            kind, rel = op[0], codex.rel(op[1])
            if kind == 'add':
                write(rel, '\n'.join(l[1:] if l.startswith('+') else l for l in op[2]).rstrip('\n') + '\n')
                count('c_add')
            elif kind == 'delete':
                try:
                    os.unlink(os.path.join(SBX, rel))
                except OSError:
                    pass
            else:
                before = read(rel)
                after = codex.apply_update(before, op[2]) if before is not None else None
                if after is None:
                    count('c_update_failed')
                    log.append('codex update failed %s %s (%s)' % (sess, rel, 'no base' if before is None else 'hunk mismatch'))
                    continue
                write(rel, after)
                count('c_update')
                if op[3]:
                    write(codex.rel(op[3]), after)
    for command in codex.js_strings_after(text, 'cmd:'):      # shell commands of tools.exec_command({cmd: "..."})
        run_bash(command, 'codex ' + sess)
    for match in re.finditer(r"cat > ?(\S+) <<'?(\w+)'?\n(.*?)\n\2\b", text.replace('\\n', '\n'), re.S):
        rel = codex.rel(match.group(1).strip('"\''))
        if write(rel, match.group(3) + '\n'):
            count('c_cat')


def main():
    release = set(open(N + '/release_files.txt').read().split('\n'))
    events = []
    # Claude Bash commands are already extracted in bash.json (same data as in the transcripts); take them from there
    for event in claude_events():
        events.append(event)
    for event in codex_events():
        events.append(event)
    events.sort(key=lambda e: (e[0], 0 if e[2] == 'claude' else 1, e[1]))
    print('events', len(events), file=sys.stderr)
    for stamp, _, source, sess, name, inp, result in events:
        if source == 'claude':
            apply_claude(sess, name, inp, result)
        else:
            apply_codex(sess, inp)
        # runtime files are authoritative: never let a replayed patch leave a different copy behind
        for rel in release:
            runtime, sandbox = os.path.join(D, rel), os.path.join(SBX, rel)
            try:
                if os.path.exists(runtime) and os.path.getsize(runtime) != os.path.getsize(sandbox):
                    shutil.copy2(runtime, sandbox)
            except OSError:
                if os.path.exists(runtime):
                    os.makedirs(os.path.dirname(sandbox), exist_ok=True)
                    shutil.copy2(runtime, sandbox)
    for rel in release:   # final byte-exact pass
        runtime, sandbox = os.path.join(D, rel), os.path.join(SBX, rel)
        if os.path.exists(runtime):
            os.makedirs(os.path.dirname(sandbox), exist_ok=True)
            shutil.copy2(runtime, sandbox)
    with open(N + '/report.txt', 'w') as handle:
        handle.write(json.dumps(stats) + '\n' + '\n'.join(log) + '\n')
    print(stats, file=sys.stderr)


if __name__ == '__main__':
    main()
