#!/usr/bin/env python3
"""文檔 registry 對照原型（唯讀）：以 registry 比對專案實際檔案、git 狀態與網站可達性。

用法：python3 -I audit.py {workspaceRoot} {registry.common.json} [registry.project.json] [--json out.json]
測試：python3 -I -m unittest discover -s {本目錄} -p 'test_*.py'
"""
import collections
import json
import os
import re
import subprocess
import sys

SKIP_DIRS = {'node_modules', 'build', '.docusaurus', '.gradle', '.git'}
# git 為唯一共享空間：暫時文件審閱期間也提交，結案後從目前版本移除
PERSIST_GIT = {'persistent': 'tracked', 'temporary': 'tracked', 'regenerable': 'ignored',
               'personal': 'ignored', 'scratch': 'ignored'}
# 現行站台骨架（docs-site.cjs portalPlugin）會索引的靜態資產
SITE_INDEXED = re.compile(r'^static/(reports/(layouts|previews|restorations)/|reviews/).*\.(html|pdf)$', re.I)
LARGE_BYTES = 50 * 1024 * 1024
# 索引頁／分類設定繼承所屬類型的保存與發布限制，只保留自己的呈現方式
INHERITED_KEYS = ('lifecycle', 'retention', 'distribution', 'close', 'scope', 'downstream', 'git', 'gitReason', '_git')
# 專案層擴充共用類型時唯一允許的欄位
EXTEND_KEYS = {'id', 'legacy'}


class RegistryError(Exception):
    pass


def read_json(path):
    with open(path, encoding='utf-8') as f:
        return json.load(f)
# 只被主檔引用的相依資源，不單獨計入網站可達性
DEPENDENCY_EXT = {'css', 'js', 'mjs', 'ttf', 'otf', 'woff', 'woff2', 'gitignore'}


def glob_re(glob):
    out, i = '', 0
    while i < len(glob):
        c = glob[i]
        if glob.startswith('**/', i):
            out += '(?:.*/)?'; i += 3; continue
        if glob.startswith('**', i):
            out += '.*'; i += 2; continue
        out += {'*': '[^/]*', '?': '[^/]'}.get(c, re.escape(c)); i += 1
    return re.compile('^' + out + '$')


def literal_len(glob):
    return len(re.sub(r'\*\*?|\?', '', glob))


def load_registry(paths):
    """依層載入：各層 defaults 只套用到該層自己的類型；專案層不能改寫共用類型，只能追加 legacy。"""
    layers = [read_json(p) for p in paths]
    if not layers or layers[0].get('layer') != 'common' or any(x.get('layer') != 'project' for x in layers[1:]):
        raise RegistryError('Registry must be one common layer followed by project layers.')
    common_defaults = layers[0].get('defaults', {})
    types, by_id = [], {}
    for data in layers:
        layer = data['layer']
        layer_defaults = dict(common_defaults)
        if layer == 'project':
            layer_defaults.update(data.get('defaults', {}))
        for t in data['types']:
            if t['id'] in by_id:
                raise RegistryError(f"Duplicate type id: {t['id']}")
            t['_layer'] = layer
            t['_explicit'] = set(t)
            for k, v in layer_defaults.items():
                t.setdefault(k, v)
            t.setdefault('legacy', [])
            types.append(t); by_id[t['id']] = t
        for ext in data.get('extendCommon', []):
            if layer != 'project':
                raise RegistryError('extendCommon is only allowed in project layers.')
            extra = set(ext) - EXTEND_KEYS
            if extra:
                raise RegistryError(f"extendCommon {ext.get('id')} may only add legacy paths, got: {sorted(extra)}")
            target = by_id.get(ext.get('id'))
            if not target or target['_layer'] != 'common':
                raise RegistryError(f"extendCommon target is not a common type: {ext.get('id')}")
            target['legacy'] = target['legacy'] + list(ext.get('legacy', []))
    for t in types:
        t['_git'] = t.get('git') or PERSIST_GIT[t['lifecycle']]
    return types


def exchange_root(path):
    """交換目錄根：路徑中第一個以 * 命名的目錄段（例如 .../reviews/*/）。"""
    m = re.match(r'^(.*?/\*/)', path)
    return m.group(1) if m else None


def lint(types):
    """推導規則 R1–R11：檢查 registry 本身的矛盾組合。"""
    issues = []
    by_id = {t['id']: t for t in types}
    for t in types:
        e = lambda r, m: issues.append((t['id'], r, m))
        if t['lifecycle'] == 'temporary':
            if not t.get('close'): e('R1', 'temporary 必須宣告 close（觸發與負責者）')
            if t['retention'] != 'remove-at-close': e('R1', 'temporary 的 retention 必須是 remove-at-close')
            if t['distribution'] == 'site': e('R1', 'temporary 不可隨標準站台部署')
        if t['lifecycle'] == 'regenerable' or t['authority'] == 'derived':
            if not t.get('derivedFrom'): e('R2', 'regenerable／derived 必須宣告 derivedFrom')
            if t['writer']['mode'] != 'generator': e('R2', 'derived 的 writer 必須是 generator')
        if t['lifecycle'] == 'personal' and (t['scope'] != 'personal' or t['presentation'] != 'hidden'):
            e('R3', 'personal 必須 scope=personal、presentation=hidden')
        if t['role'] == 'evidence':
            if t['change'] not in ('append-only', 'regenerate'): e('R4', 'evidence 的 change 必須是 append-only 或 regenerate')
            if 'retention' not in t['_explicit']: e('R4', 'evidence 必須明示 retention（依用途保留）')
        if t['role'] == 'exchange' and t['lifecycle'] == 'regenerable':
            e('R5', 'exchange 不可是 regenerable')
        if t['category'] == 'source' and t.get('downstream') != 'exclude':
            e('R6', '需求來源預設 downstream=exclude')
        if t['presentation'] != 'hidden' and any(not p.startswith('{docsRoot}') for p in t['paths']):
            e('R7', f"presentation={t['presentation']} 的文件必須放在 docs 模組內")
        if t.get('git') and not t.get('gitReason'):
            e('R8', 'git 覆寫推導值時必須寫 gitReason')
        if t.get('base') == 'other' and not t.get('purpose'):
            e('R9', '以 other 為基礎的專案類型必須寫 purpose')
        if t.get('inherit') not in (None, 'container'):
            e('R10', "inherit 只允許 'container'")
        if t.get('inherit') and not t['priority']:
            e('R10', 'inherit 類型必須設 priority，才能先於所屬類型命中')
        if t['lifecycle'] == 'temporary':
            for path in t['paths']:
                root = exchange_root(path)
                if root and not any(x['lifecycle'] == 'temporary' and root + '**' in x['paths'] for x in types):
                    e('R11', f'temporary 類型缺少涵蓋整個交換目錄 {root}** 的容器類型')
    return issues


def compile_types(types, docs_module, modules):
    compiled = []
    for t in types:
        for kind in ('paths', 'legacy'):
            for g in t[kind]:
                subs = [g.replace('{docsRoot}', docs_module).replace('{workspace}/', '')]
                if '{module}' in g:
                    subs = [g.replace('{module}', m).replace('{workspace}/', '') for m in modules if m != docs_module]
                for s in subs:
                    compiled.append((t, kind == 'legacy', glob_re(s), literal_len(s) + 1000 * t['priority']))
    return compiled


def classify(f, compiled):
    """回傳 (類型, 生效特性, 是否舊位置, 所屬類型)。索引類型繼承所屬類型的保存與發布限制。"""
    hits = sorted(((score, t, leg) for t, leg, rx, score in compiled if rx.match(f)), key=lambda h: -h[0])
    if not hits:
        return None, None, False, None, hits
    _, t, leg = hits[0]
    effective = {k: t.get(k) for k in INHERITED_KEYS}
    container = None
    if t.get('inherit') == 'container':
        container = next((h[1] for h in hits[1:] if not h[1].get('inherit')), None)
        if container:
            effective = {k: container.get(k) for k in INHERITED_KEYS}
    return t, effective, leg, container, hits


def walk(ws, rel):
    base = os.path.join(ws, rel)
    if os.path.isfile(base):
        yield rel; return
    for dp, dns, fns in os.walk(base):
        dns[:] = [d for d in dns if d not in SKIP_DIRS]
        for f in fns:
            if f not in ('.DS_Store', '.gitkeep'):
                yield os.path.relpath(os.path.join(dp, f), ws)


def git_states(ws, files):
    tracked = set(subprocess.run(['git', '-C', ws, 'ls-files', '-z'], capture_output=True, text=True,
                                 check=True).stdout.split('\0'))
    untracked = [f for f in files if f not in tracked]
    ignored = set()
    if untracked:
        r = subprocess.run(['git', '-C', ws, 'check-ignore', '--stdin', '-z'], input='\0'.join(untracked),
                           capture_output=True, text=True)
        ignored = set(x for x in r.stdout.split('\0') if x)
    return {f: 'tracked' if f in tracked else 'ignored' if f in ignored else 'unprotected' for f in files}


def group_dir(path, depth=4):
    return '/'.join(path.split('/')[:-1][:depth]) or '.'


def audit(ws, registry_paths):
    project = read_json(os.path.join(ws, 'project.json'))
    modules = list(project['modules'])
    docs_module = next(m for m, v in project['modules'].items() if v.get('type') == 'docs')
    types = load_registry(registry_paths)
    compiled = compile_types(types, docs_module, modules)

    files = list(walk(ws, docs_module))
    for rel in ('.claude/peer-review', '.claude/work', '.claude/docs', '.claude/handoff.md', 'output'):
        if os.path.exists(os.path.join(ws, rel)):
            files += list(walk(ws, rel))
    for m in modules:
        if m == docs_module:
            continue
        for rel in (f'{m}/docs', f'{m}/FEATURE_CHANGELOG.md'):
            if os.path.exists(os.path.join(ws, rel)):
                files += list(walk(ws, rel))
    git = git_states(ws, files)

    stats = collections.defaultdict(lambda: {'files': 0, 'bytes': 0, 'git': collections.Counter(),
                                             'gitMismatch': collections.Counter(), 'legacy': 0,
                                             'unrenderable': 0, 'unreachable': 0, 'inPageTree': 0,
                                             'inherited': collections.Counter()})
    unregistered, ambiguous = collections.Counter(), collections.Counter()
    unregistered_bytes = collections.Counter()
    for f in files:
        t, eff, leg, container, hits = classify(f, compiled)
        size = os.path.getsize(os.path.join(ws, f))
        if not t:
            unregistered[group_dir(f)] += 1; unregistered_bytes[group_dir(f)] += size
            continue
        if len(hits) > 1 and hits[0][0] == hits[1][0] and hits[0][1]['id'] != hits[1][1]['id']:
            ambiguous[f'{hits[0][1]["id"]} / {hits[1][1]["id"]}: {group_dir(f)}'] += 1
        s = stats[t['id']]
        s['files'] += 1; s['bytes'] += size; s['git'][git[f]] += 1; s['legacy'] += leg
        if container:
            s['inherited'][container['id']] += 1
        if git[f] != eff['_git'] and not (eff['_git'] == 'tracked' and git[f] == 'unprotected'):
            s['gitMismatch'][git[f]] += 1
        in_docs = f.startswith(f'{docs_module}/docs/')
        site_rel = f[len(docs_module) + 1:] if f.startswith(docs_module + '/') else None
        ext = f.rsplit('.', 1)[-1].lower()
        if t['presentation'] == 'page' and t['authority'] == 'source' and ext not in ('md', 'mdx') and not f.endswith('_category_.json'):
            s['unrenderable'] += 1
        if t['presentation'] in ('asset-index', 'confirm-entry') and ext not in DEPENDENCY_EXT:
            reachable = (site_rel and (SITE_INDEXED.match(site_rel) or '/javadoc/' in site_rel)) or t.get('displayAs')
            if not reachable:
                s['unreachable'] += 1
        if t['role'] == 'evidence' and in_docs:
            s['inPageTree'] += 1

    # 暫時文件孤兒：證據或展示副本找不到對應簡報
    briefs = {os.path.splitext(os.path.basename(f))[0] for f in files
              if re.match(r'^\.claude/peer-review/[^/]+\.md$', f)}
    def brief_of(name):
        return re.sub(r'-(evidence|reviewer)(-r\d+)?(-[a-z]+)?$', '', name)
    orphans = collections.Counter()
    for f in files:
        m = re.match(r'^\.claude/peer-review/([^/]+)/', f) or re.match(rf'^{re.escape(docs_module)}/static/reviews/([^/]+)/', f)
        if m and brief_of(m.group(1)) not in briefs:
            orphans[m.group(0)] += os.path.getsize(os.path.join(ws, f))

    return {'workspace': ws, 'docsModule': docs_module, 'files': len(files), 'types': types,
            'lint': lint(types), 'stats': stats, 'unregistered': unregistered,
            'unregisteredBytes': unregistered_bytes, 'ambiguous': ambiguous, 'orphans': orphans}


def mb(n):
    return f'{n / 1048576:.1f} MB' if n >= 1048576 else f'{n / 1024:.0f} KB'


def render(r):
    by_id = {t['id']: t for t in r['types']}
    out = [f"### {os.path.basename(os.path.dirname(r['workspace'])) or r['workspace']}（docs 模組：`{r['docsModule']}`，掃描 {r['files']:,} 檔）", '']
    out += ['| 類型 | 層 | 狀態 | 檔數 | 大小 | git 實際 | 應為 | 不符 | 其他 |', '|---|---|---|---:|---:|---|---|---|---|']
    for tid, s in sorted(r['stats'].items(), key=lambda kv: list(by_id).index(kv[0])):
        t = by_id[tid]
        other = []
        if s['legacy']: other.append(f"舊位置 {s['legacy']}")
        if s['inherited']: other.append('繼承 ' + '、'.join(f'{k} {v}' for k, v in s['inherited'].most_common()))
        if s['unrenderable']: other.append(f"page 無法渲染 {s['unrenderable']}")
        if s['unreachable']: other.append(f"網站不可達 {s['unreachable']}")
        if s['inPageTree']: other.append(f"證據在正文樹 {s['inPageTree']}")
        g = ' '.join(f'{k}:{v}' for k, v in s['git'].most_common())
        mm = ' '.join(f'{k}:{v}' for k, v in s['gitMismatch'].items())
        out.append(f"| `{tid}` | {t['_layer'][0]} | {t['status']} | {s['files']:,} | {mb(s['bytes'])} | {g} | {t['_git']} | {mm or '—'} | {'；'.join(other) or '—'} |")
    out.append('')
    if r['unregistered']:
        out += ['**未登記（registry 無對應類型）**', '', '| 目錄 | 檔數 | 大小 |', '|---|---:|---:|']
        for d, c in r['unregistered'].most_common():
            out.append(f'| `{d}` | {c} | {mb(r["unregisteredBytes"][d])} |')
        out.append('')
    if r['ambiguous']:
        out += ['**多重比對（同分）**', ''] + [f'- {k}（{v}）' for k, v in r['ambiguous'].items()] + ['']
    if r['orphans']:
        out += [f"**暫時文件孤兒（找不到對應簡報）**：{len(r['orphans'])} 個目錄，共 {mb(sum(r['orphans'].values()))}", '']
        out += [f'- `{k}` {mb(v)}' for k, v in sorted(r['orphans'].items())] + ['']
    return '\n'.join(out)


if __name__ == '__main__':
    args = sys.argv[1:]
    out_json = None
    if '--json' in args:
        i = args.index('--json'); out_json = args[i + 1]; del args[i:i + 2]
    try:
        result = audit(os.path.realpath(args[0]), args[1:])
    except RegistryError as error:
        sys.exit(f'Registry error: {error}')
    print(render(result))
    if out_json:
        with open(out_json, 'w', encoding='utf-8') as f:
            json.dump({k: v for k, v in result.items() if k != 'types'}, f,
                      ensure_ascii=False, indent=1, default=lambda o: dict(o))
