"""Bounded private 1.0.1 -> 1.0.2 preparation. Never publishes or runs a game.

init verifies accepted 1.0.1 and makes NEW ordinary copies. The human-facing
controls/pages/notes are then edited in that candidate. freeze requires actual
source/local-UI evidence, preserves all historical bytes and emits an exact
project-only archive, complete manifest and delta. Shared discovery is separate.
"""
from __future__ import annotations

import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import sys
import zipfile
from zoneinfo import ZoneInfo

VERSION = '1.0.2'
DATE = '2026-10-31'
ZONE = 'America/New_York'
PROJECT = 'n64/goldeneye-usa'
BASE = Path('L:/Game Preservation/GoldenEye-USA-Website/diagnostics/render-2026-10-31/release-prep/candidate-1.0.1')
DIAGNOSTIC = Path(__file__).resolve().parent
BASE_MANIFEST_SHA = '3fedf21d4a43a5f7753bf22d30d88d4ff8c79ea225a7e949594f6a8645250224'
BASE_RELEASE_SHA = 'c7765d1d0d35fcbf3879f6bf8e87e71d6878752e3b2d7ae22cb4f11d2e81886d'
BASE_RELEASE_ID = 'goldeneye-usa-1.0.1-810e2b1ab3af'
ARTICLE = 'dev-blog/2026-10-31-modern-controls.html'
VERIFY_FILE = 'data/modern-controls-verification-1.0.2.json'
PLAYER = 'player-1.0.2.js'
BRIDGE = 'original/modern-controls-1.0.2.js'
HEX = re.compile(r'^[0-9a-f]{64}$')
REPLACE_ALLOWED = {'original/index.html', 'index.html', 'credits/index.html',
                   'dev-blog/index.html', 'dev-blog/history.json', 'release.json',
                   'source/README.txt'}
ADD_ALLOWED = {PLAYER, BRIDGE, 'original/modern-controls-1.0.2.css', ARTICLE,
               VERIFY_FILE, 'releases/1.0.1.json',
               'source/player-1.0.2.js', 'source/modern-controls-1.0.2.js',
               'source/modern-controls-1.0.2.css',
               'source/prepare-modern-controls-source.txt',
               'source/history/1.0.1/README.txt',
               'source/history/1.0.1/upload-manifest-public.json',
               'downloads/goldeneye-usa-website-1.0.2.zip',
               'downloads/goldeneye-usa-website-1.0.2.json'}
REQUIRED_CHECKS = {
    'syntheticMapping': 'synthetic',
    'syntheticNeutralRelease': 'synthetic',
    'syntheticSpeedPrecedence': 'synthetic',
    'localPlayerInterface': 'local-ui',
}


def need(value, message):
    if not value:
        raise RuntimeError(message)


def digest(path):
    h = hashlib.sha256()
    with path.open('rb') as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b''):
            h.update(chunk)
    return h.hexdigest()


def raw_sha(data):
    return hashlib.sha256(data).hexdigest()


def no_reparse(path):
    s = path.lstat()
    need(not stat.S_ISLNK(s.st_mode) and not (getattr(s, 'st_file_attributes', 0) & 0x400),
         'Reparse point/symlink refused: ' + str(path))


def rel(value):
    need(isinstance(value, str) and value and '\\' not in value and ':' not in value,
         'Expected relative POSIX path.')
    p = PurePosixPath(value)
    need(not p.is_absolute() and value == p.as_posix() and
         all(x not in ('', '.', '..') for x in p.parts), 'Unsafe relative path: ' + value)
    return value


def under(root, path):
    root, path = root.resolve(), path.resolve()
    need(path != root and root in path.parents, 'Path is outside bounded root: ' + str(path))
    return path


def read_json(path):
    no_reparse(path)
    return json.loads(path.read_text(encoding='utf-8-sig'))


def write_json(path, data):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')


def compact(data):
    return json.dumps(data, ensure_ascii=False, separators=(',', ':')).encode('utf-8')


def checked(path, expected, size=None):
    no_reparse(path)
    need(path.is_file() and digest(path) == expected and
         (size is None or path.stat().st_size == size), 'Pinned input changed: ' + str(path))


def records(root):
    no_reparse(root)
    result = []
    for directory, dirs, files in os.walk(root, followlinks=False):
        for name in dirs + files:
            no_reparse(Path(directory) / name)
        for name in files:
            p = Path(directory) / name
            result.append({'path': p.relative_to(root).as_posix(),
                           'bytes': p.stat().st_size, 'sha256': digest(p)})
    return sorted(result, key=lambda x: x['path'])


def baseline():
    path = BASE / 'upload-manifest-1.0.1.json'
    checked(path, BASE_MANIFEST_SHA)
    m = read_json(path)
    need(m.get('releaseId') == BASE_RELEASE_ID and m.get('version') == '1.0.1',
         'Baseline release ID/version changed.')
    expected = {}
    for record in m['files']:
        p = rel(record['path'])
        need(p.startswith(PROJECT + '/'), 'Baseline includes another project.')
        p = p[len(PROJECT) + 1:]
        need(p not in expected and HEX.fullmatch(record['sha256']), 'Invalid baseline record.')
        expected[p] = {'path': p, 'bytes': record['bytes'], 'sha256': record['sha256']}
    source = BASE / 'site' / PROJECT
    actual = records(source)
    need({x['path']: x for x in actual} == expected,
         'Accepted 1.0.1 tree differs from its complete manifest.')
    checked(source / 'release.json', BASE_RELEASE_SHA)
    need(len(actual) == m['filesCount'] and sum(x['bytes'] for x in actual) == m['bytes'],
         'Baseline inventory totals differ.')
    return source, m, actual


def candidate(name):
    need(re.fullmatch(r'candidate-1\.0\.2(?:-[A-Za-z0-9-]+)?', name), 'Bad candidate name.')
    return under(DIAGNOSTIC, DIAGNOSTIC / name)


def shared_assets():
    provenance = read_json(BASE / 'shared-assets/provenance.json')
    files = provenance['files']
    accepted_archive = read_json(BASE / 'site' / PROJECT / 'downloads/goldeneye-usa-website-1.0.1.json')
    need(files == accepted_archive['sharedDependencies'],
         'Offline shared provenance differs from pinned accepted archive metadata.')
    need(len(files) == 6, 'Expected six offline shared assets.')
    for item in files:
        need('/' not in rel(item['path']), 'Shared asset must be a single filename.')
        checked(BASE / 'shared-assets' / item['path'], item['sha256'], item['bytes'])
    return provenance, files


def init(output):
    source, manifest, old = baseline()
    provenance, assets = shared_assets()
    need(not output.exists(), 'Candidate exists; init never overwrites.')
    output.mkdir(parents=True, exist_ok=False)
    target = output / 'site' / PROJECT
    shutil.copytree(source, target, copy_function=shutil.copy2, symlinks=False)
    shutil.copytree(BASE / 'shared-assets', output / 'shared-assets',
                    copy_function=shutil.copy2, symlinks=False)
    (target / 'releases').mkdir(exist_ok=True)
    shutil.copy2(source / 'release.json', target / 'releases/1.0.1.json')
    history = target / 'source/history/1.0.1'
    history.mkdir(parents=True, exist_ok=False)
    shutil.copy2(source / 'source/README.txt', history / 'README.txt')
    public_manifest = {key: manifest[key] for key in
                       ('schemaVersion', 'project', 'releaseId', 'contentSha256',
                        'files', 'filesCount', 'bytes')}
    public_manifest['sourcePrivateManifestSha256'] = BASE_MANIFEST_SHA
    public_manifest['scope'] = 'Public file identities only; private root/chat/coordination fields omitted.'
    write_json(history / 'upload-manifest-public.json', public_manifest)
    copied = {x['path']: x for x in records(target)}
    need(all(copied[x['path']] == x for x in old), 'Ordinary-copy readback differs.')
    _, _, after = baseline()
    need(after == old, 'Baseline changed during ordinary-copy initialization.')
    receipt = {'schemaVersion': 1, 'kind': 'goldeneye-modern-controls-init-v1',
               'initializedAtUtc': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
               'baselineManifestSha256': BASE_MANIFEST_SHA, 'sourceTreeUnchanged': True,
               'ordinaryCopiesUsed': True, 'hardlinksRequested': False,
               'publicWritesPerformed': False, 'browserOrNativeUiUsed': False,
               'userSavesRead': False, 'output': str(output),
               'baselineFilesCount': len(old), 'baselineBytes': sum(x['bytes'] for x in old)}
    write_json(output / 'initialization-receipt.json', receipt)
    print(json.dumps({'result': 'initialized-private-copy', 'output': str(output),
                      'projectRoot': str(target), 'published': False}))


def public_text(value, field):
    need(isinstance(value, str) and 0 < len(value.strip()) <= 6000, field + ' needs reviewed text.')
    need(not re.search(r'[A-Za-z]:[\\/]|file://|/(?:Users|home|private)/|\\\\', value),
         field + ' contains a private/local path.')
    return value.strip()


def acceptance(path, target):
    path = under(DIAGNOSTIC, path)
    r = read_json(path)
    need(r.get('schemaVersion') == 1 and r.get('kind') == 'goldeneye-modern-controls-acceptance-v1'
         and r.get('decision') == 'pass', 'Passing modern-controls acceptance receipt required.')
    need(r.get('baselineManifestSha256') == BASE_MANIFEST_SHA and r.get('timezone') == ZONE,
         'Wrong receipt baseline/timezone.')
    stamp = r.get('acceptedAtUtc', '')
    need(isinstance(stamp, str) and stamp.endswith('Z'), 'acceptedAtUtc must be UTC Z.')
    accepted = datetime.fromisoformat(stamp[:-1] + '+00:00')
    need(accepted <= datetime.now(timezone.utc) and
         accepted.astimezone(ZoneInfo(ZONE)).date().isoformat() == DATE and
         datetime.now(ZoneInfo(ZONE)).date().isoformat() == DATE,
         'Author fresh actual-date notes; acceptance/preparation date mismatch.')
    need(r.get('isolatedOrigin') is True and r.get('userSavesRead') is False and
         r.get('physicalControllerTested') is False,
         'Receipt must distinguish isolated UI/source checks from physical controller acceptance.')
    tested, seen = [], set()
    for entry in r.get('candidateFiles', []):
        p = rel(entry['path'])
        need(p not in seen and p in {PLAYER, BRIDGE, 'original/index.html'},
             'Unexpected/duplicate tested source path.')
        seen.add(p)
        checked(target / p, entry['sha256'], entry['bytes'])
        tested.append({'path': p, 'bytes': entry['bytes'], 'sha256': entry['sha256']})
    need(seen == {PLAYER, BRIDGE, 'original/index.html'}, 'Receipt must pin player, bridge, player HTML.')
    checks, seen = [], set()
    for check in r.get('checks', []):
        key, kind, state = check.get('id'), check.get('kind'), check.get('status')
        need(isinstance(key, str) and re.fullmatch(r'[A-Za-z][A-Za-z0-9]{0,79}', key) and
             key not in seen and kind in ('synthetic', 'local-ui') and
             state in ('pass', 'unverified', 'not-tested'), 'Invalid/duplicate observed check.')
        seen.add(key)
        if key in REQUIRED_CHECKS:
            need(kind == REQUIRED_CHECKS[key] and state == 'pass', 'Required check did not pass: ' + key)
        evidence_hashes = []
        for item in check.get('evidence', []):
            p = under(DIAGNOSTIC, Path(item['path']))
            need(p.suffix.lower() in ('.json', '.png', '.jpg', '.jpeg', '.webp', '.txt', '.md', '.log'),
                 'Only retained evidence artifacts accepted.')
            checked(p, item['sha256'])
            evidence_hashes.append(item['sha256'])
        need(state != 'pass' or evidence_hashes, 'Passing checks require retained evidence.')
        checks.append({'id': key, 'kind': kind, 'status': state,
                       'label': public_text(check.get('label'), 'Check label'),
                       'evidenceSha256': evidence_hashes})
    need(set(REQUIRED_CHECKS) <= seen, 'Receipt omitted a required source/local-UI check.')
    need(isinstance(r.get('limitations'), list) and r['limitations'], 'Remaining limits required.')
    return {'schemaVersion': 1, 'kind': r['kind'], 'decision': 'pass',
            'acceptedAtUtc': stamp, 'timezone': ZONE, 'receiptSha256': digest(path),
            'baselineManifestSha256': BASE_MANIFEST_SHA,
            'candidateFiles': sorted(tested, key=lambda x: x['path']), 'checks': checks,
            'publicSummary': public_text(r.get('publicSummary'), 'Public summary'),
            'limitations': [public_text(x, 'Limitation') for x in r['limitations']],
            'isolatedOrigin': True, 'userSavesRead': False,
            'physicalControllerTested': False,
            'actualGameExecuted': r.get('actualGameExecuted') is True,
            'nativeSaveReload': 'unverified', 'fullCampaign': 'unverified', 'cheatEffects': 'unverified'}


def check_candidate(target, old):
    current = records(target)
    oldmap, newmap = {x['path']: x for x in old}, {x['path']: x for x in current}
    need(set(oldmap) <= set(newmap), 'Baseline files were removed.')
    changed = {p for p in oldmap if oldmap[p] != newmap[p]}
    added = set(newmap) - set(oldmap)
    need(changed <= REPLACE_ALLOWED, 'Unrelated baseline file changed: ' + repr(changed - REPLACE_ALLOWED))
    need(added <= ADD_ALLOWED, 'Unreviewed new file: ' + repr(added - ADD_ALLOWED))
    checked(target / 'releases/1.0.1.json', BASE_RELEASE_SHA)
    return current


def freeze(output, receipt_path):
    source, oldmanifest, old = baseline()
    need(output.is_dir(), 'Initialize private copy first.')
    no_reparse(output)
    need(not (output / 'preparation-receipt.json').exists(), 'Candidate already frozen; do not overwrite.')
    target = output / 'site' / PROJECT
    check_candidate(target, old)
    public = acceptance(receipt_path, target)
    need((target / ARTICLE).is_file(), 'New dated modern-controls article missing.')
    history = read_json(target / 'dev-blog/history.json')
    old_history = read_json(source / 'dev-blog/history.json')
    need(history.get('entries', [])[:len(old_history['entries'])] == old_history['entries'],
         'Existing journal entries changed/reordered.')
    newer = history['entries'][len(old_history['entries']):]
    need(len(newer) == 1 and newer[0].get('version') == VERSION and newer[0].get('date') == DATE and
         newer[0].get('article') == ARTICLE.split('/')[-1], 'Expected one actual-date 1.0.2 journal entry.')
    for entry in history['entries']:
        checked(target / 'dev-blog' / rel(entry['article']), entry['sha256'])
    need('player-1.0.2.js' in (target / 'original/index.html').read_text(encoding='utf-8'),
         'Public player does not select new versioned entry module.')
    for publicpath, sourcepath in [(PLAYER, 'source/player-1.0.2.js'),
                                   (BRIDGE, 'source/modern-controls-1.0.2.js')]:
        need((target / publicpath).is_file(), 'New input missing: ' + publicpath)
        shutil.copy2(target / publicpath, target / sourcepath)
    if (target / 'original/modern-controls-1.0.2.css').exists():
        shutil.copy2(target / 'original/modern-controls-1.0.2.css', target / 'source/modern-controls-1.0.2.css')
    shutil.copy2(Path(__file__).resolve(), target / 'source/prepare-modern-controls-source.txt')
    write_json(target / VERIFY_FILE, public)
    provenance, assets = shared_assets()
    for item in assets:
        checked(output / 'shared-assets' / item['path'], item['sha256'], item['bytes'])
    config = read_json(target / 'data/runtime-config-1.0.1.json')
    old_release = read_json(source / 'release.json')
    need(config['identity'] == old_release['runtimeIdentity'], 'Runtime/state identity changed.')
    content = [x for x in records(target) if x['path'] != 'release.json' and not x['path'].startswith('downloads/')]
    content_hash = raw_sha(compact(content))
    verification = dict(old_release['verification'])
    verification.update({'modernControls': public['publicSummary'], 'modernControlsReceipt': VERIFY_FILE,
                         'physicalController': 'unverified',
                         'publicDelivery': 'Not established by private preparation; requires separate HTTPS receipt'})
    release = {**{key: old_release[key] for key in ('schemaVersion', 'title', 'projectUrl', 'edition',
                 'originalRomSha256', 'privacy')},
               'releaseId': 'goldeneye-usa-1.0.2-' + content_hash[:12], 'version': VERSION, 'date': DATE,
               'writtenAtTimezone': ZONE, 'previousReleaseId': old_release['releaseId'],
               'previousReleaseManifestSha256': BASE_RELEASE_SHA, 'notes': ARTICLE,
               'contentSha256': content_hash, 'contentHashRule': old_release['contentHashRule'],
               'verification': verification, 'runtimeIdentity': old_release['runtimeIdentity'],
               'preservedStorageIdentities': old_release['preservedStorageIdentities'], 'files': content}
    write_json(target / 'release.json', release)
    archive = target / 'downloads/goldeneye-usa-website-1.0.2.zip'
    need(not archive.exists(), 'New ZIP already exists; freeze never overwrites.')
    zip_expected = {}
    root_index = '<!doctype html><meta charset="utf-8"><title>GoldenEye USA 1.0.2</title><meta http-equiv="refresh" content="0;url=/n64/goldeneye-usa/"><a href="/n64/goldeneye-usa/">Open GoldenEye USA</a>'
    with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
        for item in records(target):
            if not item['path'].startswith('downloads/'):
                name = PROJECT + '/' + item['path']
                z.write(target / item['path'], name)
                zip_expected[name] = (item['bytes'], item['sha256'])
        for item in assets:
            name = 'assets/' + item['path']
            z.write(output / 'shared-assets' / item['path'], name)
            zip_expected[name] = (item['bytes'], item['sha256'])
        z.writestr('index.html', root_index)
        zip_expected['index.html'] = (len(root_index.encode('utf-8')), raw_sha(root_index.encode('utf-8')))
        z.write(target / 'source/README.txt', 'README.txt')
        zip_expected['README.txt'] = ((target / 'source/README.txt').stat().st_size, digest(target / 'source/README.txt'))
    # Re-open and read actual decompressed bytes, rather than treating successful
    # archive construction as evidence that every member matches the manifest.
    with zipfile.ZipFile(archive, 'r') as z:
        need(len(z.namelist()) == len(set(z.namelist())) and set(z.namelist()) == set(zip_expected),
             'Offline ZIP entry inventory differs or contains duplicate members.')
        for item in z.infolist():
            h = hashlib.sha256()
            with z.open(item) as stream:
                for chunk in iter(lambda: stream.read(1024 * 1024), b''):
                    h.update(chunk)
            need((item.file_size, h.hexdigest()) == zip_expected[item.filename],
                 'Offline ZIP member mismatch: ' + item.filename)
    archive_record = {'schemaVersion': 1, 'version': VERSION, 'date': DATE,
                      'archive': archive.name, 'bytes': archive.stat().st_size, 'sha256': digest(archive),
                      'releaseId': release['releaseId'], 'contentSha256': content_hash,
                      'releaseJsonSha256': digest(target / 'release.json'),
                      'excludes': 'downloads/ to avoid recursive old/new ZIP envelopes',
                      'sharedDependencies': assets, 'verification': verification}
    write_json(target / 'downloads/goldeneye-usa-website-1.0.2.json', archive_record)
    final = check_candidate(target, old)
    oldmap, newmap = {x['path']: x for x in old}, {x['path']: x for x in final}
    added = sorted(set(newmap) - set(oldmap))
    changed = sorted(p for p in oldmap if oldmap[p] != newmap[p])
    manifest = {'schemaVersion': 1, 'project': 'GoldenEye USA', 'version': VERSION,
                'canonicalPrefixes': ['/' + PROJECT + '/'], 'root': str(output / 'site'),
                'releaseId': release['releaseId'], 'contentSha256': content_hash,
                'files': [{**x, 'path': PROJECT + '/' + x['path']} for x in final],
                'filesCount': len(final), 'bytes': sum(x['bytes'] for x in final),
                'sharedDiscoveryIncluded': False, 'publicDeliveryVerified': False}
    complete_path = output / 'upload-manifest-1.0.2.json'
    write_json(complete_path, manifest)
    delta = {'schemaVersion': 1, 'project': 'GoldenEye USA', 'version': VERSION,
             'root': str(output / 'site'), 'canonicalPrefixes': ['/' + PROJECT + '/'],
             'previousManifestSha256': BASE_MANIFEST_SHA,
             'completeManifestSha256': digest(complete_path), 'releaseId': release['releaseId'],
             'added': [PROJECT + '/' + p for p in added], 'changed': [PROJECT + '/' + p for p in changed],
             'removed': [], 'files': [{**newmap[p], 'path': PROJECT + '/' + p} for p in sorted(added + changed)],
             'unchangedCount': len(oldmap) - len(changed), 'sharedDiscoveryIncluded': False}
    write_json(output / 'delta-manifest-1.0.2.json', delta)
    _, _, after = baseline()
    need(after == old, 'Frozen baseline changed during preparation.')
    proof = {'schemaVersion': 1, 'kind': 'goldeneye-modern-controls-preparation-v1',
             'preparedAtUtc': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
             'baselineManifestSha256': BASE_MANIFEST_SHA, 'sourceTreeUnchanged': True,
             'acceptanceReceiptSha256': public['receiptSha256'],
             'releaseId': release['releaseId'], 'contentSha256': content_hash,
             'completeManifestSha256': digest(complete_path),
             'deltaManifestSha256': digest(output / 'delta-manifest-1.0.2.json'),
             'ordinaryCopiesUsed': True, 'hardlinksRequested': False, 'removed': [],
             'addedPaths': added, 'changedExistingPaths': changed, 'archive': archive_record,
             'offlineArchiveEntriesVerified': len(zip_expected), 'offlineArchiveEntriesMatch': True,
             'publicWritesPerformed': False, 'browserOrNativeUiUsed': False, 'userSavesRead': False}
    write_json(output / 'preparation-receipt.json', proof)
    print(json.dumps({'result': 'private-candidate-frozen', 'releaseId': release['releaseId'],
                      'files': len(final), 'deltaFiles': len(delta['files']), 'published': False}))


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('action', choices=('init', 'freeze'))
    parser.add_argument('--candidate', default='candidate-1.0.2')
    parser.add_argument('--receipt', type=Path)
    args = parser.parse_args()
    output = candidate(args.candidate)
    if args.action == 'init':
        need(args.receipt is None, 'init does not consume an acceptance receipt.')
        init(output)
    else:
        need(args.receipt is not None, 'freeze requires --receipt.')
        freeze(output, args.receipt)


if __name__ == '__main__':
    try:
        main()
    except Exception as error:
        print('REFUSED/INCOMPLETE: ' + str(error), file=sys.stderr)
        sys.exit(1)
