#!/usr/bin/env python3
"""Create a separate ad-hoc-signed Mosaic Custom; never mutate the input app/data."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import plistlib
import shutil
import subprocess
import tempfile


def run(*args):
    return subprocess.run(args, check=True, capture_output=True, text=True).stdout.strip()


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def eligible(path):
    s = path.as_posix()
    return (s in ('QtCore', 'QtNetwork') or
            s.startswith('PySide6/Qt/lib/') or s.startswith('PySide6/Qt/plugins/') or
            (s.startswith('PySide6/') and path.name.endswith(('.so', '.dylib'))) or
            (s.startswith('shiboken6/') and path.name.endswith(('.so', '.dylib'))))


def make(source, replacements, destination):
    source, replacements, destination = source.resolve(), replacements.resolve(), destination.absolute()
    if not source.is_dir() or not (source/'Contents/Info.plist').is_file():
        raise ValueError('Choose an existing Mosaic app.')
    info = plistlib.loads((source/'Contents/Info.plist').read_bytes())
    if info.get('CFBundleIdentifier') != 'com.mosaicmedialibrary.desktop':
        raise ValueError('Input must be the official direct Mosaic app, not a custom copy.')
    if info.get('MosaicDistributionChannel') == 'app-store':
        raise ValueError('Use the direct-download app.')
    if destination.name != 'Mosaic Custom.app' or destination.is_symlink():
        raise ValueError('Output must be a separate Mosaic Custom.app, not a symlink.')
    if destination.resolve() == source or source in destination.resolve().parents:
        raise ValueError('Output must be outside the official app.')
    if destination.exists():
        raise ValueError('Output already exists. It was not changed. Choose another parent folder.')
    files = sorted(p for p in replacements.rglob('*') if p.is_file() or p.is_symlink())
    if not files or any(p.is_symlink() or not eligible(p.relative_to(replacements)) for p in files):
        raise ValueError('Supply only supported Qt/PySide/Shiboken library files; no symlinks.')
    # Validate ALL inputs before staging. A replacement must cover the original architecture.
    for p in files:
        rel = p.relative_to(replacements)
        targets = [source/'Contents/Resources'/helper/'_internal'/rel for helper in ('Engine', 'Reader')]
        targets = [t for t in targets if t.exists()]
        if not targets:
            raise ValueError('No shipped library matches: '+str(rel))
        arch = set(run('/usr/bin/lipo', '-archs', str(p)).split())
        for t in targets:
            if not set(run('/usr/bin/lipo', '-archs', str(t)).split()) <= arch:
                raise ValueError('Replacement architecture mismatch: '+str(rel))
    destination.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix='.mosaic-custom-', dir=destination.parent) as temp:
        stage = Path(temp)/destination.name
        run('/usr/bin/ditto', '--noqtn', str(source), str(stage))
        replaced = []
        for p in files:
            rel = p.relative_to(replacements)
            for helper in ('Engine', 'Reader'):
                target = stage/'Contents/Resources'/helper/'_internal'/rel
                if not target.exists():
                    continue
                target = target.resolve()
                if stage not in target.parents:
                    raise ValueError('Library symlink escapes the staged copy.')
                original_id = run('/usr/bin/otool', '-D', str(target)).splitlines()
                shutil.copyfile(p, target)
                if len(original_id) > 1:
                    run('/usr/bin/install_name_tool', '-id', original_id[1], str(target))
                # PyInstaller flattens Qt framework install names in the official runtime.
                linked = run('/usr/bin/otool', '-L', str(target)).splitlines()[1:]
                for line in linked:
                    dep = line.strip().split(' (')[0]
                    for module in ('QtCore', 'QtNetwork'):
                        if module+'.framework/' in dep:
                            run('/usr/bin/install_name_tool', '-change', dep, '@rpath/'+module, str(target))
                replaced.append({'path':str(target.relative_to(stage)), 'inputSha256':digest(p)})
        info['CFBundleIdentifier'] = 'com.mosaicmedialibrary.custom'
        info['CFBundleName'] = info['CFBundleDisplayName'] = 'Mosaic Custom'
        info['MosaicCustomBuild'] = True
        (stage/'Contents/Info.plist').write_bytes(plistlib.dumps(info))
        # Ad-hoc signatures have no Developer ID. Hardened runtime stays on; only the
        # LOCAL CUSTOM copy permits its user-signed libraries (which have no Team ID).
        ent = Path(temp)/'custom-entitlements.plist'
        ent.write_bytes(plistlib.dumps({'com.apple.security.cs.disable-library-validation':True}))
        for p in sorted(stage.rglob('*')):
            if p.is_file() and not p.is_symlink() and 'Mach-O' in run('/usr/bin/file', '-b', str(p)):
                run('/usr/bin/codesign', '--force', '--sign', '-', '--options', 'runtime',
                    '--entitlements', str(ent), str(p))
        for p in sorted(stage.rglob('*.framework'), key=lambda p:len(p.parts), reverse=True):
            run('/usr/bin/codesign', '--force', '--sign', '-', '--options', 'runtime', str(p))
        receipt = {'format':1, 'sourceApp':str(source), 'sourceExecutableSha256':digest(source/'Contents/MacOS/MosaicNative'),
                   'replacements':replaced, 'signature':'ad-hoc; no certificate',
                   'data':'separate Mosaic Custom application support folder; no automatic official-library access'}
        (stage/'Contents/Resources/custom-replacement.json').write_text(json.dumps(receipt,indent=2)+'\n')
        run('/usr/bin/codesign', '--force', '--sign', '-', '--options', 'runtime', '--entitlements', str(ent), str(stage))
        run('/usr/bin/codesign', '--verify', '--deep', '--strict', str(stage))
        # Atomic publish; reruns fail without replacing an existing result.
        if destination.exists():
            raise ValueError('Output appeared during signing; nothing was overwritten.')
        stage.rename(destination)
    return destination


if __name__ == '__main__':
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument('--app',required=True,type=Path)
    ap.add_argument('--libraries',required=True,type=Path,help='Files relative to Engine/_internal, e.g. PySide6/Qt/lib/QtCore.framework/Versions/A/QtCore')
    ap.add_argument('--output',type=Path,default=Path.home()/'Applications/Mosaic Custom.app')
    a=ap.parse_args()
    try: print(make(a.app,a.libraries,a.output))
    except (ValueError,subprocess.CalledProcessError) as e: ap.exit(1,str(e)+'\n')
