from pathlib import Path
import json, hashlib, fnmatch, re, sqlite3, sys
from html.parser import HTMLParser

root=(Path(sys.argv[1]).resolve() if len(sys.argv)>1 else Path(__file__).resolve().parents[1]/'app')
checks=0

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

def ok(name, condition, detail=''):
    global checks
    checks += 1
    if not condition:
        raise AssertionError(f'{name}: {detail or "failed"}')
    print(f'PASS {name}' + (f' — {detail}' if detail else ''))

required=['.htaccess','index.html','index.php','manifest.webmanifest','sw.js','precache-manifest.json','server','storage','assets','icons']
for item in required: ok(f'root {item}', (root/item).exists())
ok('no app wrapper', not (root/'app').exists())
ok('no public documentation directory', not (root/'documentation').exists())
symlinks=[p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_symlink()]
ok('no symlinks', not symlinks, str(symlinks))
forbidden_runtime=[p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file() and (p.suffix.lower() in {'.db','.sqlite','.sqlite3','.key','.log'} or p.name=='installed.lock')]
ok('no runtime data shipped', not forbidden_runtime, str(forbidden_runtime))

json_names=['DEPLOYMENT_MANIFEST.json','manifest.webmanifest','precache-manifest.json','sbom.cdx.json']
docs={name:json.loads((root/name).read_text()) for name in json_names}
ok('JSON parsing', len(docs)==4, '4 documents')

manifest=docs['DEPLOYMENT_MANIFEST.json']; entries=manifest['files']
ok('manifest file_count', manifest['file_count']==len(entries), str(len(entries)))
expected={p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file() and p.name!='DEPLOYMENT_MANIFEST.json' and not fnmatch.fnmatch(p.name,'CHANGES_*.txt')}
ok('manifest inventory coverage', set(entries)==expected, f'{len(expected)} files')
for rel,meta in entries.items():
    p=root/rel
    ok(f'manifest {rel}', p.stat().st_size==meta['size'] and digest(p)==meta['sha256'])

sbom=docs['sbom.cdx.json']; file_components=[]
for c in sbom.get('components',[]):
    if c.get('type')!='file': continue
    props={x.get('name'):x.get('value') for x in c.get('properties',[])}
    rel=props.get('glmchat.deployment_path')
    if rel: file_components.append((rel,c))
sbom_expected={p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file() and p.name not in {'DEPLOYMENT_MANIFEST.json','sbom.cdx.json'} and not fnmatch.fnmatch(p.name,'CHANGES_*.txt')}
ok('SBOM inventory coverage', {r for r,_ in file_components}==sbom_expected, f'{len(sbom_expected)} files')
for rel,c in file_components:
    hashes={h.get('alg'):h.get('content') for h in c.get('hashes',[])}
    ok(f'SBOM {rel}', hashes.get('SHA-256')==digest(root/rel))

class RefParser(HTMLParser):
    def __init__(self): super().__init__(); self.refs=[]; self.inline_scripts=0; self.inline_handlers=[]
    def handle_starttag(self,tag,attrs):
        d=dict(attrs)
        for k in ('src','href'):
            v=d.get(k)
            if v and v.startswith('./'): self.refs.append(v[2:].split('?',1)[0].split('#',1)[0])
        if tag=='script' and not d.get('src'): self.inline_scripts+=1
        for k in d:
            if k.lower().startswith('on'): self.inline_handlers.append(k)
parser=RefParser(); parser.feed((root/'index.html').read_text())
for rel in parser.refs: ok(f'HTML reference {rel}', (root/rel).is_file())
ok('no inline scripts', parser.inline_scripts==0)
ok('no inline event handlers', not parser.inline_handlers, str(parser.inline_handlers))

webmanifest=docs['manifest.webmanifest']
ok('manifest start_url relative', webmanifest.get('start_url')=='./')
ok('manifest scope relative', webmanifest.get('scope')=='./')
ok('manifest standalone', webmanifest.get('display')=='standalone')
ok('manifest light colours', webmanifest.get('background_color')=='#f7f5fb' and webmanifest.get('theme_color')=='#6d3fd1')
try:
    from PIL import Image
    expected_dims={'icons/icon-192.png':(192,192),'icons/icon-512.png':(512,512),'icons/maskable-512.png':(512,512),'icons/apple-touch-icon-180.png':(180,180),'icons/favicon-48.png':(48,48)}
    for rel,dim in expected_dims.items():
        with Image.open(root/rel) as im: ok(f'icon {rel}', im.size==dim, str(im.size))
except ImportError:
    print('NOT_RUN icon dimensions — Pillow unavailable')

precache=docs['precache-manifest.json']; assets=precache.get('assets',[])
ok('precache version', precache.get('version')==1)
ok('precache unique', len(assets)==len(set(assets)), str(len(assets)))
for rel in assets:
    ok(f'precache safety {rel}', isinstance(rel,str) and rel.startswith('./') and '..' not in rel and '\\' not in rel)
    ok(f'precache exists {rel}', (root/rel[2:]).is_file())

sw=(root/'sw.js').read_text()
install_block=sw[sw.index("self.addEventListener('install'"):sw.index("self.addEventListener('activate'")]
ok('install block does not skip waiting', 'skipWaiting' not in install_block)
ok('API excluded before cache handling', sw.index("if (request.method !== 'GET' || isApi(url)) return") < sw.index("if (request.mode === 'navigate')"))
ok('service-worker fallback returns Response', 'cached || (await refresh) || Response.error()' in sw)
ok('controlled update message supported', "data?.type === 'SKIP_WAITING'" in sw)
ht=(root/'.htaccess').read_text()
for token in ['RewriteRule ^(?:storage|server','Content-Security-Policy','Strict-Transport-Security','Cache-Control "public, max-age=31536000, immutable"','offline-runtime\\.js']:
    ok(f'.htaccess contains {token}', token in ht)
off=(root/'assets/offline-runtime.js').read_text()
for token in ['AES-GCM','MAX_OFFLINE_CREDENTIAL_AGE_MS','derivePin','encryptLocalRecord','decryptLocalRecord']:
    ok(f'offline hardening {token}', token in off)
together=(root/'server/lib/Together.php').read_text()
ok('mock provider test-only', together.count("getenv('APP_ENV') === 'test' && getenv('GLMCHAT_MOCK_PROVIDER') === '1'")>=2)

readme=(root/'README.md').read_text()
for stale in ['../documentation/','under `app/`','contents of `app/`','Headless Chromium provides deterministic']:
    ok(f'README excludes stale text {stale}', stale not in readme)
ok('README states flat layout', 'This archive is intentionally flat' in readme)

frontend=(root/'assets/index-5aa4ab81f41a.js').read_text()
api_refs=set(re.findall(r'\./api/([A-Za-z0-9-]+)',frontend))
allowed={'status','setup','login','logout','session','health','conversations','projects','chat','generations','workflows','cost','data','prompts','settings','sync','automation-definitions','tools','templates','tts'}
ok('frontend API root coverage', api_refs<=allowed, str(sorted(api_refs-allowed)))

scan='\n'.join(p.read_text(errors='ignore') for p in root.rglob('*') if p.is_file() and p.suffix.lower() in {'.php','.js','.json','.md','.txt','.sql','.html','.css'} and not fnmatch.fnmatch(p.name,'CHANGES_*.txt'))
secret_patterns=[r'(?i)sk-[A-Za-z0-9_-]{20,}',r'(?i)api[_-]?key\s*[:=]\s*["\'][A-Za-z0-9_-]{20,}["\']',r'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----']
for pat in secret_patterns: ok(f'no secret pattern {pat}', re.search(pat,scan) is None)
for primitive in ['eval(', 'shell_exec(', 'passthru(', 'proc_open(', 'popen(']:
    ok(f'no dangerous primitive {primitive}', primitive not in scan)

migrations=sorted((root/'server/migrations').glob('*.sql'))
ok('migration count', len(migrations)==14, str(len(migrations)))
def apply(paths):
    con=sqlite3.connect(':memory:'); con.execute('PRAGMA foreign_keys=ON')
    for p in paths: con.executescript(p.read_text())
    return con
fresh=apply(migrations)
ok('fresh SQLite integrity', fresh.execute('PRAGMA integrity_check').fetchone()[0]=='ok')
ok('fresh foreign keys', fresh.execute('PRAGMA foreign_key_check').fetchall()==[])
tables={r[0] for r in fresh.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for required_table in ['users','settings','conversations','messages','projects','workflows','automation_definitions','automation_templates','sync_mutations','workflow_workers','tool_approvals']:
    ok(f'table {required_table}', required_table in tables)
ok('fresh app version', fresh.execute("SELECT value FROM settings WHERE key='app_version'").fetchone()[0]=='2.2.1')
ok('fresh offline age', fresh.execute("SELECT value FROM settings WHERE key='offline_credential_max_age_days'").fetchone()[0]=='7')
ok('fresh chat model', fresh.execute("SELECT value FROM settings WHERE key='model_chat'").fetchone()[0]=='zai-org/GLM-5.1')
ok('fresh chat context', fresh.execute("SELECT value FROM settings WHERE key='model_context_tokens'").fetchone()[0]=='202752')
ok('fresh cached-input price', fresh.execute("SELECT value FROM settings WHERE key='price_cached_input_per_million'").fetchone()[0]=='1.40')
ok('fresh vision input price', fresh.execute("SELECT value FROM settings WHERE key='price_vision_input_per_million'").fetchone()[0]=='0.10')
ok('fresh vision output price', fresh.execute("SELECT value FROM settings WHERE key='price_vision_output_per_million'").fetchone()[0]=='0.15')
ok('fresh Kokoro price', fresh.execute("SELECT value FROM settings WHERE key='price_tts_kokoro_per_million_characters'").fetchone()[0]=='4.00')
fresh.executescript(migrations[-1].read_text())
ok('latest migration repeat', fresh.execute('PRAGMA integrity_check').fetchone()[0]=='ok')
upgrade=apply(migrations[:-1])
upgrade.execute("UPDATE settings SET value='zai-org/GLM-5.2' WHERE key='model_chat'")
upgrade.execute("UPDATE settings SET value='262144' WHERE key='model_context_tokens'")
upgrade.execute("UPDATE settings SET value='0.26' WHERE key='price_cached_input_per_million'")
upgrade.execute("UPDATE settings SET value='10.00' WHERE key='price_tts_kokoro_per_million_characters'")
upgrade.execute("INSERT INTO users(id,password_hash) VALUES(1,'retained')")
upgrade.executescript(migrations[-1].read_text())
ok('13-to-14 upgrade integrity', upgrade.execute('PRAGMA integrity_check').fetchone()[0]=='ok')
ok('13-to-14 foreign keys', upgrade.execute('PRAGMA foreign_key_check').fetchall()==[])
ok('13-to-14 user retained', upgrade.execute('SELECT password_hash FROM users WHERE id=1').fetchone()[0]=='retained')
ok('13-to-14 model aligned', upgrade.execute("SELECT value FROM settings WHERE key='model_chat'").fetchone()[0]=='zai-org/GLM-5.1')
ok('13-to-14 context aligned', upgrade.execute("SELECT value FROM settings WHERE key='model_context_tokens'").fetchone()[0]=='202752')
ok('13-to-14 cached price aligned', upgrade.execute("SELECT value FROM settings WHERE key='price_cached_input_per_million'").fetchone()[0]=='1.40')
ok('13-to-14 Kokoro price aligned', upgrade.execute("SELECT value FROM settings WHERE key='price_tts_kokoro_per_million_characters'").fetchone()[0]=='4.00')
print(f'SUMMARY {checks} structural checks passed')
