#!/usr/bin/env python3
"""Own one isolated real API + Next instance, then exact-file cleanup on exit."""
import argparse
import base64
import json
import hashlib
import os
from pathlib import Path
import secrets
import signal
import socket
import sqlite3
import subprocess
import sys
import time
import urllib.request

HERE = Path(__file__).resolve().parent
WEB = HERE.parent
API = WEB.parent / 'alqove-api' / 'api'
ROOT = WEB / '.cache' / 'scheduling-qa'
PHP = '/opt/homebrew/bin/php'


def save(path, data):
    path.write_text(json.dumps(data, indent=2) + '\n')
    path.chmod(0o600)


def guard(run):
    if run.is_symlink() or run.parent.resolve() != ROOT.resolve():
        raise RuntimeError('Unsafe QA directory')
    marker = (run / 'marker').read_text()
    if marker != run.name or len(marker) != 20 or not marker.startswith('E2E-'):
        raise RuntimeError('QA marker mismatch')
    if any(c not in '0123456789abcdef' for c in marker[4:]):
        raise RuntimeError('Invalid marker')
    if (run / 'database.sqlite').is_symlink():
        raise RuntimeError('Database symlink refused')
    return marker


def cleanup(run):
    marker = guard(run)
    db = run / 'database.sqlite'
    counts = {}
    if db.exists():
        with sqlite3.connect(f'file:{db}?mode=ro', uri=True) as connection:
            tables = {r[0] for r in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
            for table in ('users', 'stores', 'store_memberships', 'shifts', 'time_punches', 'timesheets', 'payroll_exports'):
                if table in tables:
                    counts[table] = connection.execute(f'SELECT count(*) FROM "{table}"').fetchone()[0]
            if counts.get('users', 0) > 12 or counts.get('stores', 0) > 4:
                raise RuntimeError('Cleanup fixture cardinality exceeded')
            if 'users' in tables:
                bad = connection.execute('SELECT count(*) FROM users WHERE name NOT LIKE ? OR email NOT LIKE ?', (marker+'%', '%@example.test')).fetchone()[0]
                if bad:
                    raise RuntimeError('Cleanup found nonfixture users')
    removed = []
    for name in ('database.sqlite', 'database.sqlite-wal', 'database.sqlite-shm', 'database.sqlite-journal', 'manifest.json', 'environment.json', 'isolated.env'):
        candidate = run / name
        if candidate.exists():
            if candidate.is_symlink() or candidate.parent != run:
                raise RuntimeError('Unsafe cleanup child')
            candidate.unlink()
            removed.append(name)
    save(run / 'cleanup.json', {'marker': marker, 'counts_before': counts, 'removed': removed, 'database_absent': not db.exists(), 'credentials_absent': not (run / 'manifest.json').exists()})


def source_snapshot():
    roots = [API / 'app', API / 'routes', API / 'database', API / 'bootstrap', API / 'config', WEB / 'web/src', HERE]
    roots += [p / 'src' for p in (WEB / 'packages').iterdir() if p.is_dir()]
    files = {}
    for root in roots:
        for file in root.rglob('*'):
            if file.is_file() and not file.is_symlink() and file.suffix in ('.php', '.ts', '.tsx', '.py'):
                files[str(file.relative_to(WEB.parent))] = hashlib.sha256(file.read_bytes()).hexdigest()
    # Source roots alone miss runtime headers/build settings, dependencies and
    # contracts. Never include .env or the private fixture credentials here.
    configs = [WEB / 'web/next.config.ts', WEB / 'web/tsconfig.json',
               WEB / 'package.json', WEB / 'package-lock.json', WEB / 'web/package.json',
               WEB / 'contracts/openapi.yaml', API / 'contracts/openapi.yaml',
               API / 'composer.json', API / 'composer.lock', HERE / 'tsconfig.json']
    configs += [p / 'package.json' for p in (WEB / 'packages').iterdir() if p.is_dir()]
    for file in configs:
        if file.is_file() and not file.is_symlink():
            files[str(file.relative_to(WEB.parent))] = hashlib.sha256(file.read_bytes()).hexdigest()
    files = dict(sorted(files.items()))
    return {'files': files, 'sha256': hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()}


def environment(run, marker):
    # Explicit allowlist: no inherited service credentials, no root Laravel .env.
    env = {k: os.environ[k] for k in ('HOME', 'PATH', 'TMPDIR', 'USER', 'LANG') if k in os.environ}
    env.update({
        'QA_API_ROOT': str(API), 'QA_RUN_DIR': str(run), 'QA_MARKER': marker,
        'APP_ENV': 'testing', 'APP_DEBUG': 'false', 'APP_KEY': 'base64:'+base64.b64encode(secrets.token_bytes(32)).decode(),
        'APP_URL': 'http://127.0.0.1:8105', 'FRONTEND_URL': 'http://localhost:3100',
        'DB_CONNECTION': 'sqlite', 'DB_DATABASE': str(run / 'database.sqlite'), 'DB_URL': '',
        'CACHE_STORE': 'array', 'SCOUT_DRIVER': 'null', 'QUEUE_CONNECTION': 'sync', 'MAIL_MAILER': 'array',
        'SESSION_DRIVER': 'array', 'BROADCAST_CONNECTION': 'null', 'FILESYSTEM_DISK': 'local',
        'SANCTUM_STATEFUL_DOMAINS': '', 'LOG_CHANNEL': 'single', 'XDEBUG_MODE': 'off',
        'APP_CONFIG_CACHE': str(run / 'no-config-cache.php'), 'APP_ROUTES_CACHE': str(run / 'no-route-cache.php'),
        'APP_EVENTS_CACHE': str(run / 'no-event-cache.php'),
        'NEXT_PUBLIC_API_URL': 'http://127.0.0.1:8105', 'NEXT_TELEMETRY_DISABLED': '1',
    })
    return env


def ready(url, proc, timeout=90):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if proc.poll() is not None:
            raise RuntimeError(f'Server exited {proc.returncode}; see private logs')
        try:
            with urllib.request.urlopen(url, timeout=3) as response:
                if response.status == 200:
                    return
        except (OSError, TimeoutError):
            pass
        time.sleep(0.3)
    raise RuntimeError(f'Readiness failed: {url}')


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--serve', action='store_true', help='Keep servers alive for iterative tests until SIGINT/SIGTERM')
    parser.add_argument('--grep', default=None)
    args = parser.parse_args()
    os.umask(0o077)
    for port in (8105, 3100):
        with socket.socket() as sock:
            # A stopped private server may leave TIME_WAIT; active listeners still refuse bind.
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            try:
                sock.bind(('127.0.0.1', port))
            except OSError:
                raise RuntimeError(f'Port {port} already occupied: refusing reuse or termination')
    ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
    marker = 'E2E-' + secrets.token_hex(8)
    run = ROOT / marker
    run.mkdir(mode=0o700)
    (run / 'marker').write_text(marker)
    (run / 'isolated.env').write_text('# Intentionally empty: isolated process environment only.\n')
    (run / 'database.sqlite').touch(mode=0o600)
    for child in ('storage/logs', 'storage/framework/cache/data', 'storage/framework/sessions', 'storage/framework/views'):
        (run / child).mkdir(parents=True, mode=0o700)
    guard(run)
    env = environment(run, marker)
    save(run / 'environment.json', env)
    print(f'QA_RUN_DIR={run}', flush=True)
    children = []
    logs = []
    def stop(signum, frame):
        raise KeyboardInterrupt
    signal.signal(signal.SIGTERM, stop)
    signal.signal(signal.SIGINT, stop)
    result = 1
    initial_snapshot = None
    try:
        initial_snapshot = source_snapshot()
        save(run / 'snapshot-start.json', initial_snapshot)
        with (run / 'fixtures.log').open('w') as log:
            subprocess.run([PHP, str(HERE / 'fixtures.php')], cwd=API, env=env, check=True, stdout=log, stderr=subprocess.STDOUT)
        for name, command, cwd in (
            ('api', [PHP, '-S', '127.0.0.1:8105', str(HERE / 'router.php')], API),
            ('web', [str(WEB / 'node_modules/.bin/next'), 'dev', '--hostname', '127.0.0.1', '--port', '3100'], WEB / 'web'),
        ):
            log = (run / f'{name}.log').open('w')
            logs.append(log)
            children.append(subprocess.Popen(command, cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True))
        save(run / 'processes.json', {'runner_pid': os.getpid(), 'api_pid': children[0].pid, 'web_pid': children[1].pid})
        ready('http://127.0.0.1:8105/v1/health', children[0])
        ready('http://localhost:3100/login', children[1], 180)
        save(run / 'readiness.json', {'api': 'http://127.0.0.1:8105/v1/health', 'web': 'http://localhost:3100/login', 'ready': True})
        print('READY: isolated API :8105 and Next :3100', flush=True)
        if args.serve:
            while all(p.poll() is None for p in children):
                time.sleep(1)
            raise RuntimeError('Owned server unexpectedly exited')
        command = [str(WEB / 'node_modules/.bin/playwright'), 'test', '-c', str(HERE / 'playwright.config.ts')]
        if args.grep:
            command += ['--grep', args.grep]
        tester = subprocess.Popen(command, cwd=WEB, env=env, start_new_session=True)
        children.append(tester)
        save(run / 'processes.json', {'runner_pid': os.getpid(), 'api_pid': children[0].pid, 'web_pid': children[1].pid, 'test_pid': tester.pid})
        result = tester.wait()
    except KeyboardInterrupt:
        result = 0
    finally:
        for child in reversed(children):
            if child.poll() is None:
                os.killpg(child.pid, signal.SIGTERM)
                try:
                    child.wait(timeout=10)
                except subprocess.TimeoutExpired:
                    os.killpg(child.pid, signal.SIGKILL)
                    child.wait(timeout=5)
        for log in logs:
            log.close()
        cleanup(run)
        print(f'Cleanup verified; evidence retained privately: {run}', flush=True)
        if initial_snapshot is not None:
            final_snapshot = source_snapshot()
            save(run / 'snapshot-end.json', final_snapshot)
            before, after = initial_snapshot['files'], final_snapshot['files']
            changed = sorted(p for p in before.keys() | after.keys() if before.get(p) != after.get(p))
            save(run / 'freshness.json', {'source_changed_during_run': bool(changed), 'changed_paths': changed, 'start_sha256': initial_snapshot['sha256'], 'end_sha256': final_snapshot['sha256']})
            if changed:
                print(f'SOURCE CHANGED during run ({len(changed)} paths); rerun after implementation settles.', flush=True)
                if not args.serve and result == 0:
                    result = 3
    return result


if __name__ == '__main__':
    sys.exit(main())
