#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""输出文件命名/归档 dry-run（只读，不移动/不改名）。
规则：<对象>_<类型>_<YYYYMMDD>_V<NN>.<ext>
"""
import os, re, csv, json, subprocess, tempfile
from pathlib import Path
from datetime import datetime
from collections import Counter, defaultdict

ROOT = Path('/Users/liuxinyuan/Desktop/Hermes输出-工作类')
OUTDIRS = ['对外物料','客户管理','销售分析','产品与市场','经营分析','培训与上手','营销活动','资料','AI生成文件']
DATE = datetime.now().strftime('%Y%m%d')
RUN = ROOT / '归档' / f'_dryrun_{DATE}'
RUN.mkdir(parents=True, exist_ok=True)

CAND_EXT = {'.html','.pdf','.pptx','.docx','.xlsx','.md','.csv','.json','.png','.jpg','.jpeg','.webp'}
BAD_WORDS = re.compile(r'最终版?|定稿|副本|拷贝|copy|最新|修改版?|final|\(\d+\)|（\d+）', re.I)
DATE_RE = re.compile(r'(20\d{6})')
VER_RE = re.compile(r'(?i)(?:^|[_\-\s])V(\d{1,2})(?:$|[_\-\s])')
DATE_TOKEN_RE = re.compile(r'(?<!\d)(?:20\d{2}[-_.年]?(?:0[1-9]|1[0-2])[-_.月]?(?:0[1-9]|[12]\d|3[01])日?|20\d{6})(?!\d)')

def is_source(p: Path) -> bool:
    rel = p.relative_to(ROOT).as_posix()
    suf = p.suffix.lower()
    if suf in {'.py','.js','.sh','.svg'}:
        return True
    if p.name.lower() in {'readme.md','skill.md','agents.md','package.json','tsconfig.json','config.json','manifest.json'}:
        return True
    if re.search(r'(?:^|[_-])(?:bak|backup)(?:[_-]|$)', p.stem, re.I):
        return True
    if re.match(r'^(销售订单[_-]?|2024全年订单|2025全年订单|2026年\d+[-_月].*订单)', p.name):
        return True
    if rel.startswith('销售分析/源数据/') and suf in {'.xlsx','.xls','.csv'}:
        return True
    if rel.startswith('销售分析/2026订单分析/') and suf in {'.xlsx','.xls','.csv'}:
        return True
    if suf == '.json' and ('data' in p.stem.lower() or 'config' in p.stem.lower()):
        return True
    if rel.startswith('资料/'):
        return True
    if rel.startswith('产品与市场/') and suf == '.pdf':
        return True
    if rel.startswith('培训与上手/') and suf in {'.png','.jpg','.jpeg','.webp'}:
        return True
    if rel.startswith('对外物料/') and suf in {'.png','.jpg','.jpeg','.webp'} and '_预览' not in p.stem:
        return True
    if re.search(r'/(images?|assets?|qr|media)/', rel, re.I):
        return True
    return False

def extract_date(stem: str) -> str:
    hits = []
    for m in re.finditer(r'(?<!\d)(?:20\d{2}[-_.年]?(0[1-9]|1[0-2])[-_.月]?(0[1-9]|[12]\d|3[01])日?|(20\d{6}))(?!\d)', stem):
        if m.group(3):
            hits.append(m.group(3))
        else:
            hits.append('20' + m.group(0)[2:4] if False else re.sub(r'\D','',m.group(0))[:8])
    return hits[-1] if hits else ''

def parse_name(p: Path):
    stem = p.stem
    date = extract_date(stem)
    vm = VER_RE.search(stem)
    ver = f'{int(vm.group(1)):02d}' if vm else ''
    bad = bool(BAD_WORDS.search(stem))
    if date and ver and not bad:
        status = 'compliant'
    elif date and not ver:
        status = 'needs_version'
    elif ver and not date:
        status = 'needs_date'
    else:
        status = 'needs_both'
    if bad:
        status += '+bad_word'
    return stem, date, ver, status

def clean_stem(stem: str) -> str:
    s = re.sub(r'^\d{8}[-_]\d{4,6}[_-]?', '', stem)  # leading upload timestamp
    s = DATE_TOKEN_RE.sub('', s)
    s = VER_RE.sub('', s)
    s = BAD_WORDS.sub('', s)
    s = re.sub(r'[_\-\s]{2,}', '_', s).strip('_- ')
    return s or stem

def proposed(p: Path, date: str, ver: str):
    stem = p.stem
    preview = '_预览' in stem
    base = stem.replace('_预览', '')
    base = clean_stem(base)
    v = ver or '01'
    d = date or datetime.fromtimestamp(p.stat().st_mtime).strftime('%Y%m%d')
    suffix = '_预览' if preview else ''
    return f'{base}_{d}_V{v}{suffix}{p.suffix.lower()}'

cand, skipped = [], []
for d in OUTDIRS:
    top = ROOT / d
    if not top.is_dir():
        continue
    for base, dirs, files in os.walk(top):
        dirs[:] = [x for x in dirs if x not in {'.git','__pycache__','node_modules','.taste-skill'}]
        for f in files:
            p = Path(base) / f
            if p.suffix.lower() not in CAND_EXT:
                continue
            if is_source(p):
                skipped.append(p)
                continue
            stem, date, ver, status = parse_name(p)
            st = p.stat()
            cand.append({
                'path': p.relative_to(ROOT).as_posix(),
                'dir': d,
                'ext': p.suffix.lower(),
                'size_kb': round(st.st_size / 1024, 1),
                'mtime': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M'),
                'status': status,
                'date': date,
                'date_inferred': '' if date else datetime.fromtimestamp(st.st_mtime).strftime('%Y%m%d'),
                'version': ver,
                'proposed': proposed(p, date, ver),
                'referenced': False,
                'reference_hits': '',
                'collision': False,
                'archive_suggestion': '',
            })

# reference scan via ripgrep fixed strings
names = sorted({Path(x['path']).name for x in cand if Path(x['path']).name})
ref_files = set()
if names:
    with tempfile.NamedTemporaryFile('w', encoding='utf-8', delete=False) as tf:
        tf.write('\n'.join(names)); pat = tf.name
    cmd = ['rg','-F','-l','--no-messages','-f',pat,
           '--glob','*.html','--glob','*.htm','--glob','*.js','--glob','*.mjs',
           '--glob','*.py','--glob','*.sh','--glob','*.json','--glob','*.md',
           '--glob','*.css','--glob','*.yml','--glob','*.yaml',
           str(ROOT)]
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
        for line in r.stdout.splitlines():
            fp = Path(line.strip())
            if not fp.is_absolute(): fp = ROOT / fp
            if RUN in fp.parents or '归档' in fp.parts or '数据' in fp.parts:
                continue
            ref_files.add(fp)
    finally:
        os.unlink(pat)

# mark references by exact basename
ref_map = defaultdict(list)
for fp in ref_files:
    try:
        txt = fp.read_text(encoding='utf-8', errors='ignore')
    except Exception:
        continue
    for n in names:
        if n in txt:
            ref_map[n].append(fp.relative_to(ROOT).as_posix())
for x in cand:
    n = Path(x['path']).name
    hits = [h for h in ref_map.get(n, []) if h != x['path']]
    if hits:
        x['referenced'] = True
        x['reference_hits'] = ';'.join(hits[:5]) + (' ...' if len(hits) > 5 else '')

# collisions
by_prop = defaultdict(list)
for x in cand:
    by_prop[(str(Path(x['path']).parent), x['proposed'])].append(x['path'])
for paths in by_prop.values():
    if len(paths) > 1:
        for p in paths:
            for x in cand:
                if x['path'] == p: x['collision'] = True

# archive suggestion (advisory only)
for x in cand:
    p = ROOT / x['path']
    d = x['date'] or x['date_inferred'] or datetime.fromtimestamp(p.stat().st_mtime).strftime('%Y%m%d')
    base = clean_stem(p.stem.replace('_预览',''))
    if x['referenced']:
        x['archive_suggestion'] = '暂缓（被引用）'
    elif x['collision']:
        x['archive_suggestion'] = '待人工判定（重名）'
    elif x['status'] == 'compliant':
        x['archive_suggestion'] = '当前版本保留'
    else:
        x['archive_suggestion'] = f'归档/{d[:4]}/{d[4:6]}/{x["dir"]}/{base}/'

# summary
status_counts = Counter(x['status'] for x in cand)
dir_counts = Counter(x['dir'] for x in cand)
ref_count = sum(1 for x in cand if x['referenced'])
collision_count = sum(1 for x in cand if x['collision'])
actionable = [x for x in cand if x['status'] != 'compliant' and not x['referenced'] and not x['collision']]

report = {
    'run': DATE,
    'scope_dirs': OUTDIRS,
    'candidate_files': len(cand),
    'source_files_skipped': len(skipped),
    'status': dict(status_counts),
    'by_dir': dict(dir_counts),
    'referenced': ref_count,
    'collisions': collision_count,
    'actionable': len(actionable),
    'reference_files_scanned': len(ref_files),
}
(RUN / '命名归档体检汇总.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')

cols = ['path','dir','ext','size_kb','mtime','status','date','date_inferred','version','proposed','referenced','reference_hits','collision','archive_suggestion']
with (RUN / '命名归档体检明细.csv').open('w', encoding='utf-8-sig', newline='') as f:
    w = csv.DictWriter(f, fieldnames=cols); w.writeheader()
    for x in sorted(cand, key=lambda y: y['path']):
        w.writerow({k: x[k] for k in cols})

def md_table(rows, headers):
    out = ['| ' + ' | '.join(headers) + ' |', '|' + '|'.join(['---']*len(headers)) + '|']
    for r in rows:
        out.append('| ' + ' | '.join(str(r.get(h,'')) for h in headers) + ' |')
    return '\n'.join(out)

samples = sorted([x for x in cand if x['status'] != 'compliant'], key=lambda x: (-x['size_kb'], x['path']))[:40]
ref_samples = [x for x in cand if x['referenced']][:30]
md = []
md.append(f'# 输出文件命名与归档 Dry-Run — {DATE}\n')
md.append('> 只读审计，未移动、未改名、未部署。规则：`<对象>_<类型>_<YYYYMMDD>_V<NN>.<ext>`\n')
md.append('## 范围与总量\n')
md.append(f'- 扫描目录：{", ".join(OUTDIRS)}')
md.append(f'- 交付物候选：**{len(cand)}** 个')
md.append(f'- 素材/源文件跳过：**{len(skipped)}** 个（产品目录、原始 PDF、图片、代码、SVG 等）')
md.append(f'- 被 HTML/脚本/JSON/MD 引用：**{ref_count}** 个')
md.append(f'- 建议改名但存在重名冲突：**{collision_count}** 个')
md.append(f'- 可安全进入下一轮改名/归档的候选：**{len(actionable)}** 个\n')
md.append('## 命名状态\n')
md.append(md_table([{'状态':k,'数量':v} for k,v in status_counts.most_common()], ['状态','数量']))
md.append('\n状态含义：`compliant` 已符合；`needs_version` 有日期缺版本；`needs_date` 有版本缺日期；`needs_both` 两者都缺；`+bad_word` 含“最终/副本/最新”等词。\n')
md.append('## 按目录\n')
md.append(md_table([{'目录':k,'候选数':v} for k,v in dir_counts.most_common()], ['目录','候选数']))
md.append('\n## 优先处理样例（按体积/异常度取前 40）\n')
md.append(md_table(samples, ['path','status','proposed','referenced','collision','archive_suggestion']))
md.append('\n## 被引用、不能直接改名（前 30）\n')
md.append(md_table(ref_samples, ['path','reference_hits','proposed']))
md.append('\n## 建议执行顺序\n')
md.append('1. 先处理 `needs_version` 且未被引用的交付物：只补 `_V01`，不重排目录。')
md.append('2. 再处理 `needs_both`：先按 mtime 推断交付日，给 `_V01`，并在清单里标“日期为推断值”。')
md.append('3. 被引用文件保留原名；要改名必须先同步改 HTML/脚本/CloudBase 路径。')
md.append('4. 旧版不删除，移动到 `归档/<YYYY>/<MM>/<项目>/`，并写 `_版本清单.json`。')
md.append('5. 重名冲突先人工判定是“同版重复”还是“不同变体”，不要自动加 `_2`。\n')
md.append('## 附件\n')
md.append(f'- 明细：`{RUN.relative_to(ROOT)}/命名归档体检明细.csv`')
md.append(f'- 汇总：`{RUN.relative_to(ROOT)}/命名归档体检汇总.json`')
(RUN / '命名归档体检报告.md').write_text('\n'.join(md), encoding='utf-8')

print('DRY-RUN complete; no files moved/renamed')
print(json.dumps(report, ensure_ascii=False, indent=2))
print('report:', RUN / '命名归档体检报告.md')
