#!/usr/bin/env python3
"""Agent-neutral CLI. From big-egg-3d: python tools/dev/dev.py --help."""
from __future__ import annotations

import argparse
import difflib
import json
import os
from pathlib import Path
import re
import subprocess
import sys

sys.dont_write_bytecode = True
from common import (PROJECT, DEV, RUNS, atomic_write, changed_files, digest, executable,
                    git, new_run, now, project_path, read_json, source_state,
                    state_digest, write_json)
from checks import AREAS, run_checks

HANDOFF = PROJECT / 'docs/current-task.md'
STATE = PROJECT / 'docs/current-task.state.json'


def git_summary():
    result = git('status', '--short', '--untracked-files=normal', check=False)
    if result.returncode:
        print('Git unavailable: ' + result.stderr.strip())
        return
    print('Git HEAD: ' + git('log', '-1', '--format=%h %s').stdout.strip())
    lines = result.stdout.splitlines()
    print(f'Working tree: {len(lines)} changed entry/entries')
    for line in lines[:15]:
        print(line)
    if len(lines) > 15:
        print('Remaining changes: git status --short')


def doctor(_args):
    missing = []
    print('Project: ' + str(PROJECT))
    for name in ['git', 'godot', 'php', 'php_cgi', 'node', 'blender', 'rg']:
        try:
            print(f'OK {name}: {executable(name)}')
        except ValueError as error:
            missing.append(name)
            print('MISSING ' + str(error))
    repository = git('rev-parse', '--absolute-git-dir', check=False)
    print('Git database: ' + (repository.stdout.strip() or 'not available'))
    print('Playwright: ' + ('installed' if (DEV / 'node_modules/playwright/package.json').exists()
                            else 'optional; run npm ci in tools/dev for browser captures'))
    print('MCP/LSP: optional; validate active project through the connected agent before using them.')
    return 2 if missing else 0


def handoff(_args):
    if not HANDOFF.exists():
        raise ValueError('Write docs/current-task.md first: goal, done, next, checks and limits')
    if HANDOFF.stat().st_size > 12000:
        raise ValueError('Keep the handoff below 12 KB; link to detailed logs instead')
    latest = read_json(RUNS / 'latest.json') if (RUNS / 'latest.json').exists() else None
    write_json(STATE, {'captured_at': now(), 'git_head': git('rev-parse', 'HEAD').stdout.strip(),
                      'handoff_sha256': digest(HANDOFF.read_bytes()), 'sources': source_state(),
                      'latest_validation': latest})
    print('Handoff captured: docs/current-task.md + docs/current-task.state.json')
    return 0


def resume(_args):
    if HANDOFF.exists():
        print(HANDOFF.read_text(encoding='utf-8-sig')[:12000])
    else:
        print('No shared handoff yet.')
    current = source_state()
    if STATE.exists():
        state = read_json(STATE)
        changes = changed_files(state['sources'], current)
        print(f'Files changed since handoff ({state["captured_at"]}): {len(changes)}')
        for name in changes[:20]:
            print('  ' + name)
        if HANDOFF.exists() and digest(HANDOFF.read_bytes()) != state['handoff_sha256']:
            print('Handoff text changed after its state capture; run handoff after finishing edits.')
        last = state.get('latest_validation')
        if last:
            report_path = PROJECT / last['report']
            if report_path.exists():
                report = read_json(report_path)
                same = report['source_digest'] == state_digest(current)
                selection = ', '.join(item['id'] for item in report['checks'])
                print(f'Last validation [{selection}]: {report.get("status", "INCOMPLETE")}; source inputs '
                      f'{"unchanged" if same else "changed"}. Tool/fixture conditions are recorded in {last["report"]}')
            else:
                print('Previous validation report is absent on this machine; do not assume it passed.')
    git_summary()
    backup_notice()
    return 0


def trace(args):
    groups = {
        'PHP validation/API': ['tools/game-data-manager/config.php', 'tools/game-data-manager/api.php'],
        'JS editor': ['tools/game-data-manager/assets/app.js'],
        'Godot runtime / 3D': ['autoload', 'scripts'],
        'Targeted tests': ['tools/game-data-manager/tests'],
    }
    for label, paths in groups.items():
        command = [executable('rg'), '-n', '-H', '-F', '--max-count', str(args.limit),
                   '--glob', '*.gd', '--glob', '*.js', '--glob', '*.php', '--', args.field, *paths]
        result = subprocess.run(command, cwd=PROJECT, capture_output=True, text=True, errors='replace')
        if result.returncode not in (0, 1):
            raise ValueError(result.stderr.strip())
        lines = result.stdout.splitlines()
        print(f'{label}: {len(lines)} returned match(es)' + (' (display capped)' if len(lines) > args.limit else ''))
        for line in lines[:args.limit]:
            print('  ' + line[:260])
    data = read_json(PROJECT / 'data/game_data_master.json')
    matches = []
    def visit(value, path):
        if isinstance(value, dict):
            for key, child in value.items():
                pointer = path + '/' + key.replace('~', '~0').replace('/', '~1')
                if key == args.field:
                    matches.append(pointer)
                visit(child, pointer)
        elif isinstance(value, list):
            for index, child in enumerate(value):
                visit(child, path + '/' + str(index))
    visit(data, '')
    print(f'Master JSON: {len(matches)} matching field(s)')
    for match in matches[:args.limit]:
        print('  ' + match)
    return 0


def record(args):
    path = PROJECT / ('data/game_data_master.json' if args.source == 'master'
                      else 'tools/game-data-manager/data/draft.json')
    data = read_json(path)
    if not args.pointer.startswith('/'):
        raise ValueError('Use a JSON pointer, e.g. /player_configs/default_player/model_3d')
    for part in args.pointer[1:].split('/'):
        part = part.replace('~1', '/').replace('~0', '~')
        data = data[int(part)] if isinstance(data, list) else data[part]
    text = json.dumps(data, ensure_ascii=False, indent=2)
    if len(text) > 16000:
        raise ValueError('Record exceeds 16 KB; select a more specific JSON pointer')
    print(text)
    return 0


def replacement_bytes(original, replacements):
    """Match LF/CRLF anchors while preserving all untouched bytes, including BOM."""
    bom = original.startswith(b'\xef\xbb\xbf')
    text = original.decode('utf-8-sig')
    counts = []
    for replacement in replacements:
        old = replacement['old'].replace('\r\n', '\n').replace('\r', '\n')
        if not old:
            raise ValueError('Empty edit anchor is not allowed')
        normalized, offsets = [], []
        position = 0
        while position < len(text):
            offsets.append(position)
            if text[position:position + 2] == '\r\n':
                normalized.append('\n')
                position += 2
            else:
                normalized.append('\n' if text[position] == '\r' else text[position])
                position += 1
        offsets.append(len(text))
        matches = list(re.finditer(re.escape(old), ''.join(normalized)))
        expected = replacement.get('count', 1)
        if not isinstance(expected, int) or expected < 1 or len(matches) != expected:
            raise ValueError(f'Anchor count mismatch: expected {expected}, found {len(matches)}; no file written')
        counts.append(len(matches))
        for match in reversed(matches):
            start, end = offsets[match.start()], offsets[match.end()]
            region = text[start:end]
            sample = region if '\n' in region or '\r' in region else text
            crlf = sample.count('\r\n')
            newline = '\r\n' if crlf > sample.count('\n') - crlf else '\n'
            new = replacement['new'].replace('\r\n', '\n').replace('\r', '\n').replace('\n', newline)
            text = text[:start] + new + text[end:]
    return (b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8'), counts


def edit(args):
    plan = read_json(args.plan)
    path = project_path(plan['path'])
    original = path.read_bytes()
    if digest(original) != plan['sha256']:
        raise ValueError('Stale edit plan: target SHA-256 changed; read it again')
    modified, counts = replacement_bytes(original, plan['replacements'])
    if path.suffix == '.json':
        json.loads(modified.decode('utf-8-sig'))
    diff = ''.join(difflib.unified_diff(original.decode('utf-8-sig').splitlines(True),
                                      modified.decode('utf-8-sig').splitlines(True),
                                      fromfile=plan['path'], tofile=plan['path']))
    run = new_run('edit')
    atomic_write(run / 'change.diff', diff.encode('utf-8'))
    print(f'Anchors: {counts}. Diff: {run.relative_to(PROJECT).as_posix()}/change.diff')
    for line in diff.splitlines()[:60]:
        print(line)
    if args.apply:
        atomic_write(run / 'before.bin', original)
        atomic_write(path, modified, expected=plan['sha256'])
        print('Written and verified: ' + digest(path.read_bytes()))
    else:
        print('Preview only. Add --apply to write this plan.')
    return 0


def checkpoint(args):
    paths = [project_path(value, must_exist=False) for value in args.files]
    names = [path.relative_to(PROJECT).as_posix() for path in paths]
    if any(path.is_dir() for path in paths):
        raise ValueError('List individual files explicitly; directories are not accepted')
    staged = git('diff', '--cached', '--name-only', '-z').stdout.split('\0')
    outside = set(filter(None, staged)) - set(names)
    if outside:
        raise ValueError('Other files are already staged; preserve/review them first: ' + ', '.join(sorted(outside)))
    pattern = re.compile(rb'(?:msy_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{24,}|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)')
    for path in paths:
        if path.exists() and pattern.search(path.read_bytes()):
            raise ValueError('Possible credential in ' + str(path.relative_to(PROJECT)) + '; not staged')
    git('add', '--', *names)
    result = git('commit', '-m', args.message, check=False)
    if result.returncode:
        raise ValueError(result.stdout.strip() + '\n' + result.stderr.strip())
    print(git('log', '-1', '--format=%h %s').stdout.strip())
    return 0


def bundle(_args):
    from datetime import datetime
    directory = PROJECT / '_backups/git-history'
    directory.mkdir(parents=True, exist_ok=True)
    target = directory / ('big-egg-3d-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '.bundle')
    git('bundle', 'create', str(target), '--all')
    git('bundle', 'verify', str(target))
    print('Verified Git history backup: ' + str(target))
    print('Contains committed history only; checkpoint desired changes before creating a bundle.')
    return 0


ROOT = PROJECT.parent
REPORTS = PROJECT / 'docs/rapports'
BACKUP_STATE = REPORTS / 'etat.json'
BACKUP_TAG = 'sauvegarde-'
JOURNAL = ROOT / 'docs/journal-conception.md'
# Weekly full archive, kept outside Drive: a 1 GB file uploaded every week is
# exactly the sync storm the directives forbid. History alone goes to Drive
# through the Git bundle, since the repository database lives only on C:.
BACKUP_DIR = Path(os.environ.get('BIG_EGG_BACKUP_DIR', 'E:/BIG EGG SAUVEGARDES'))
# big-egg/ is a frozen reference and _backups/ holds 7 GB of older copies;
# caches and run logs regenerate. Everything else, Git-ignored files included, is archived.
ARCHIVE_ITEMS = ['big-egg-3d', 'docs', '.claude', '.mcp.json', 'AGENTS.md', 'CLAUDE.md']
ARCHIVE_EXCLUDES = [r'-x!big-egg-3d\_backups', r'-x!big-egg-3d\.godot', r'-x!big-egg-3d\tools\dev\runs',
                    '-xr!node_modules', '-xr!__pycache__']
KEEP_RECENT = 4
SEVEN_ZIP = Path('C:/Program Files/7-Zip/7z.exe')


def backup_stamp(name):
    match = re.search(r'(\d{8})-(\d{4,6})', name)
    return match.group(1) + match.group(2)[:4] if match else None


def backups_to_prune(names, keep_recent=KEEP_RECENT):
    """Keep the newest few and the first backup of every month; unstamped files are never touched."""
    dated = sorted((name for name in names if backup_stamp(name)), key=backup_stamp)
    keep = set(dated[-keep_recent:]) if keep_recent else set()
    first_of_month = {}
    for name in dated:
        first_of_month.setdefault(backup_stamp(name)[:6], name)
    keep |= set(first_of_month.values())
    return [name for name in dated if name not in keep]


def last_backup():
    line = git('for-each-ref', '--sort=-creatordate', '--count=1',
               '--format=%(refname:short)|%(creatordate:iso-strict)', 'refs/tags/' + BACKUP_TAG + '*').stdout.strip()
    if not line:
        return None
    from datetime import datetime
    tag, date = line.split('|')
    return {'tag': tag, 'date': datetime.fromisoformat(date)}


def backup_notice():
    from datetime import datetime
    last = last_backup()
    if not last:
        print('Weekly backup: none yet. Propose it: python tools/dev/dev.py backup --facts')
        return
    days = (datetime.now(last['date'].tzinfo) - last['date']).days
    due = '; DUE, propose it to the author (backup --facts)' if days >= 7 else ''
    print(f'Weekly backup: {last["tag"]}, {days} day(s) ago{due}')


def journal_headings():
    if not JOURNAL.exists():
        return []
    return [line[3:].strip() for line in JOURNAL.read_text(encoding='utf-8-sig').splitlines()
            if line.startswith('## ')]


def schema_version(revision=None):
    path = 'scripts/core/data_catalog.gd'
    text = (git('show', f'{revision}:{path}', check=False).stdout if revision
            else (PROJECT / path).read_text(encoding='utf-8-sig'))
    match = re.search(r'SCHEMA_VERSION\s*:?=\s*(\d+)', text)
    return match.group(1) if match else '?'


def backup_facts():
    """Raw material for the weekly report; the agent writes the narrative."""
    from datetime import datetime
    last = last_backup()
    since = last['tag'] if last else None
    span = f'{since}..HEAD' if since else 'HEAD'
    commits = git('log', '--reverse', '--date=short', '--format=%h %ad %s', span).stdout.splitlines()
    start = last['date'].date().isoformat() if last else (commits[0].split()[1] if commits else '?')
    print(f'# Faits depuis {since or "le premier commit"} ({start} -> {datetime.now().date().isoformat()})')
    print(f'Schema: {schema_version(since) if since else "-"} -> {schema_version()}')
    base = since or git('rev-list', '--max-parents=0', 'HEAD').stdout.split()[0]
    print('Volume: ' + git('diff', '--shortstat', base, 'HEAD').stdout.strip())
    print('Dossiers les plus touches:')
    for line in git('diff', '--dirstat=files,4', base, 'HEAD').stdout.splitlines()[:12]:
        print('  ' + line.strip())
    print(f'\n## {len(commits)} commit(s)')
    for line in commits:
        print('  ' + line)
    state = read_json(BACKUP_STATE) if BACKUP_STATE.exists() else {}
    known = set(state.get('journal_headings', []))
    fresh = [heading for heading in journal_headings() if heading not in known]
    print(f'\n## Journal de conception: {len(fresh)} section(s) nouvelle(s) ou renommee(s)')
    for heading in fresh[-40:]:
        print('  ' + heading)
    if last:
        limit = last['date'].timestamp()
        touched = sorted(path.relative_to(ROOT).as_posix() for path in (ROOT / 'docs').rglob('*')
                         if path.is_file() and path.stat().st_mtime > limit)
        print(f'\n## Documents racine (hors Git) modifies: {len(touched)}')
        for name in touched[:30]:
            print('  ' + name)
    sheet = state.get('sheet', {})
    print(f'\n## Sheet a la sauvegarde precedente: {len(sheet)} ligne(s) connue(s)')
    if sheet:
        print('  ' + ', '.join(f'{row}={value}' for row, value in sorted(sheet.items(), key=lambda item: int(item[0]))))
    print('\nEnsuite: lire le Sheet, ecrire docs/rapports/AAAA-MM-JJ.md, mettre "sheet" a jour dans '
          'docs/rapports/etat.json ({"ligne": "etat"}), puis: python tools/dev/dev.py backup')
    return 0


def prune(directory, pattern):
    removed = backups_to_prune([path.name for path in directory.glob(pattern)])
    for name in removed:
        (directory / name).unlink()
    return removed


def backup(args):
    from datetime import datetime
    if args.facts:
        return backup_facts()
    stamp = datetime.now().strftime('%Y%m%d-%H%M')
    report = REPORTS / (datetime.now().strftime('%Y-%m-%d') + '.md')
    if not report.exists() and not args.sans_rapport:
        raise ValueError(f'Write {report.relative_to(PROJECT).as_posix()} first (backup --facts gives the raw material), '
                         'or pass --sans-rapport')
    if not SEVEN_ZIP.exists():
        raise ValueError('7-Zip not found: ' + str(SEVEN_ZIP))
    BACKUP_DIR.mkdir(parents=True, exist_ok=True)
    state = read_json(BACKUP_STATE) if BACKUP_STATE.exists() else {}
    state.update({'last_backup': stamp, 'journal_headings': journal_headings()})
    state.setdefault('sheet', {})
    write_json(BACKUP_STATE, state)
    staged = set(filter(None, git('diff', '--cached', '--name-only', '-z').stdout.split('\0')))
    if staged:
        raise ValueError('Other files are already staged; preserve/review them first: ' + ', '.join(sorted(staged)))
    git('add', '--', 'docs/rapports')
    if git('diff', '--cached', '--quiet', check=False).returncode:
        git('commit', '-m', f'Sauvegarde hebdomadaire {stamp}: rapport et etat')
    tag = BACKUP_TAG + stamp
    git('tag', '-a', tag, '-m', f'Sauvegarde hebdomadaire complete {stamp}')
    print('Tag: ' + tag + ' on ' + git('log', '-1', '--format=%h %s').stdout.strip())
    bundle(args)
    archive = BACKUP_DIR / f'big-egg-{stamp}.7z'
    command = [str(SEVEN_ZIP), 'a', '-t7z', '-mx=5', '-bso0', '-bsp0', str(archive)] + ARCHIVE_ITEMS + ARCHIVE_EXCLUDES
    result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, errors='replace')
    if result.returncode:
        raise ValueError('7-Zip failed: ' + (result.stderr or result.stdout).strip()[-2000:])
    tested = subprocess.run([str(SEVEN_ZIP), 't', '-bso0', '-bsp0', str(archive)], capture_output=True, text=True)
    if tested.returncode:
        raise ValueError('Archive written but its test failed: ' + str(archive))
    print(f'Verified archive: {archive} ({archive.stat().st_size / 2**20:.0f} MB)')
    removed = prune(BACKUP_DIR, 'big-egg-*.7z') + prune(PROJECT / '_backups/git-history', '*.bundle')
    print('Pruned (4 newest + first of each month kept): ' + (', '.join(removed) or 'nothing'))
    return 0


def preview(args):
    run = new_run('preview')
    command = [executable('node'), str(DEV / 'preview.cjs'), '--output', str(run),
               '--php', executable('php'), '--width', str(args.width), '--height', str(args.height)]
    if args.tab:
        command += ['--tab', args.tab] + (['--id', args.id] if args.id else [])
    elif args.collection:
        command += ['--collection', args.collection, '--id', args.id]
    if args.fixture:
        command += ['--fixture', str(Path(args.fixture).resolve())]
    if args.compare:
        command += ['--compare', str(Path(args.compare).resolve())]
    return subprocess.call(command, cwd=PROJECT)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    commands = parser.add_subparsers(dest='command', required=True)
    for name, function, description in [
        ('doctor', doctor, 'Verify local tools and repository location'),
        ('resume', resume, 'Read shared handoff and detect changed inputs'),
        ('handoff', handoff, 'Capture state after editing docs/current-task.md'),
        ('git-backup', bundle, 'Create and verify a portable bundle of committed history')]:
        commands.add_parser(name, help=description).set_defaults(function=function)
    check = commands.add_parser('check', help='Run existing targeted checks; isolated Godot saves')
    check.add_argument('--area', choices=AREAS, action='append')
    check.add_argument('--full', action='store_true')
    check.add_argument('--match', help='Substring filter for manager test filenames')
    check.add_argument('--plan', action='store_true', help='List selected checks without executing')
    check.add_argument('--timeout', type=float, default=180)
    check.add_argument('--seed', type=int, default=1709)
    check.add_argument('--fixture', help='Explicit user:// directory fixture; default is fresh saves')
    check.add_argument('--baseline', help='Previous report.json; failures remain failures')
    check.set_defaults(function=run_checks)
    find = commands.add_parser('trace', help='Locate a field across the authoring/runtime pipeline')
    find.add_argument('field')
    find.add_argument('--limit', type=int, default=6)
    find.set_defaults(function=trace)
    get = commands.add_parser('record', help='Read one JSON subtree without dumping the master')
    get.add_argument('pointer')
    get.add_argument('--source', choices=['master', 'draft'], default='master')
    get.set_defaults(function=record)
    patch = commands.add_parser('edit', help='Preview/apply an exact, hash-guarded JSON edit plan')
    patch.add_argument('plan')
    patch.add_argument('--apply', action='store_true')
    patch.set_defaults(function=edit)
    commit = commands.add_parser('checkpoint', help='Commit only explicitly named files')
    commit.add_argument('--message', required=True)
    commit.add_argument('files', nargs='+')
    commit.set_defaults(function=checkpoint)
    weekly = commands.add_parser('backup', help='Weekly full archive (outside Drive), history bundle and report')
    weekly.add_argument('--facts', action='store_true', help='Print report material since the last backup; writes nothing')
    weekly.add_argument('--sans-rapport', action='store_true', help='Archive even without this week\'s report')
    weekly.set_defaults(function=backup)
    capture = commands.add_parser('preview', help='Capture the real manager UI with intercepted API data')
    capture.add_argument('--collection')
    capture.add_argument('--tab', help='Open this navigation tab by its button; --id then selects in its list')
    capture.add_argument('--id')
    capture.add_argument('--fixture', help='Master-shaped JSON; defaults to current draft')
    capture.add_argument('--compare', help='Earlier PNG to create a before/after contact sheet')
    capture.add_argument('--width', type=int, default=1440)
    capture.add_argument('--height', type=int, default=1000)
    capture.set_defaults(function=preview)
    args = parser.parse_args()
    if args.command == 'preview' and not args.tab and bool(args.collection) != bool(args.id):
        parser.error('--collection and --id must be provided together')
    if args.command == 'check' and (args.timeout <= 0 or not (args.area or args.full)):
        parser.error('check requires --area or --full and a positive timeout')
    try:
        return args.function(args)
    except (ValueError, OSError, KeyError, IndexError, subprocess.SubprocessError) as error:
        print('BLOCKED: ' + str(error), file=sys.stderr)
        return 2


if __name__ == '__main__':
    sys.stdout.reconfigure(encoding='utf-8')
    sys.stderr.reconfigure(encoding='utf-8')
    raise SystemExit(main())
