#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""空间管理：把「我输出的、90 天未更新、且未被线上引用」的交付物压缩归档。

规则（用户 2026-10-21 定）：
  · 边界：只处理 agent 产出的交付物目录（销售分析 / 营销活动 / 经营分析 / AI生成文件 / _tmp / 对外物料 / 季度汇报）；
    用户提供的原始素材（数据/、工具/、脚本/、归档/、产品与市场/产品、客户管理等）一律不动。
  · 判据：文件 mtime 超过 90 天（= 我最后一次改它超过 3 个月）；且文件名未出现在 工具/*.html、数据/*.json 里（防线上引用）。
  · 动作：gzip 到 归档/_空间管理/<YYYY-MM>/<原相对路径>.gz，写 manifest，校验后删除原件。
  · 可逆：脚本 space_restore.py 按 manifest 还原。

用法：
  python3 space_manage_archive.py --dry-run     # 只打印清单
  python3 space_manage_archive.py --apply       # 执行归档
"""
import argparse
import gzip
import hashlib
import json
import os
import shutil
import time

W = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
ARCH_ROOT = os.path.join(W, '归档', '_空间管理')
MANIFEST = os.path.join(ARCH_ROOT, '_manifest.json')
INCLUDE = ['销售分析', '营销活动', '经营分析', 'AI生成文件', '_tmp', '对外物料']
# 当前在用的产销归档目录、汇报材料原件，不动
EXCLUDE_PARTS = ['2026订单分析', '汇报材料', '备货预测_2026-10-21', '货号下降判定台_2026-10-21', '货号销售额与套装归集_2026-10-21']
KEEP_NAMES = {'README.md', 'index.html', '.DS_Store'}
REGISTRY = os.path.join(ARCH_ROOT, '_known_outputs.txt')   # 显式登记「确认是我产出的」文件（相对路径逐行）
DAYS = 90


def load_manifest():
    if os.path.exists(MANIFEST):
        try:
            return json.load(open(MANIFEST, encoding='utf-8'))
        except Exception:
            pass
    return {'updated': '', 'items': []}


def refs_from_live():
    """线上/运行时会用到的文件名集合（工具页面、数据 JSON、脚本里写死的产物名）。"""
    refs = set()
    for d in ('工具', '数据', '脚本'):
        root = os.path.join(W, d)
        if not os.path.isdir(root):
            continue
        for dp, dn, fn in os.walk(root):
            if '/_备份' in dp or '/_bak' in dp:
                continue
            for f in fn:
                refs.add(f)
            # 只取文件名集合，用于“被引用”判断
    return refs


def load_registry():
    if not os.path.exists(REGISTRY):
        return set()
    return {ln.strip() for ln in open(REGISTRY, encoding='utf-8') if ln.strip() and not ln.startswith('#')}


def looks_mine(p):
    """只认带「我的产出」特征的文本类文件；xlsx/pptx/pdf/图片必须有登记，避免误伤用户素材。"""
    low = p.lower()
    try:
        if low.endswith(('.html', '.htm')):
            head = open(p, encoding='utf-8', errors='ignore').read(300000)
            return ('bior-brand' in head) or ('守护生命健康' in head)
        if low.endswith(('.md', '.txt')):
            head = open(p, encoding='utf-8', errors='ignore').read(20000)
            return any(k in head for k in ('口径', '生成', '结论', '数据源'))
        if low.endswith('.json'):
            head = open(p, encoding='utf-8', errors='ignore').read(4000)
            return ('"generated"' in head) or ('"updated"' in head) or ('"meta"' in head)
    except Exception:
        return False
    return False


def scan(refs):
    cut = time.time() - DAYS * 86400
    reg = load_registry()
    out = []
    for d in INCLUDE:
        root = os.path.join(W, d)
        if not os.path.isdir(root):
            continue
        for dp, dn, fn in os.walk(root):
            rel_dir = os.path.relpath(dp, W)
            if any(x in rel_dir + '/' for x in EXCLUDE_PARTS):
                continue
            for f in fn:
                if f in KEEP_NAMES or f.startswith('.'):
                    continue
                p = os.path.join(dp, f)
                try:
                    st = os.stat(p)
                except Exception:
                    continue
                if st.st_mtime >= cut:
                    continue
                if f in refs:
                    continue          # 被工具/数据/脚本引用 → 不归档
                rel = os.path.relpath(p, W)
                if rel not in reg and not looks_mine(p):
                    continue          # 未登记且无产出特征 → 视为用户素材，不动
                out.append((st.st_mtime, st.st_size, rel))
    out.sort()
    return out


def sha256(p):
    h = hashlib.sha256()
    with open(p, 'rb') as f:
        for chunk in iter(lambda: f.read(1 << 20), b''):
            h.update(chunk)
    return h.hexdigest()


def apply(rows):
    man = load_manifest()
    done = 0
    saved = 0
    for mt, size, rel in rows:
        src = os.path.join(W, rel)
        month = time.strftime('%Y-%m', time.localtime(mt))
        dst = os.path.join(ARCH_ROOT, month, rel + '.gz')
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        try:
            with open(src, 'rb') as fi, gzip.open(dst, 'wb', compresslevel=6) as fo:
                shutil.copyfileobj(fi, fo)
            if not os.path.exists(dst) or os.path.getsize(dst) == 0:
                print('!! 压缩失败', rel)
                continue
            h = sha256(src)
            man['items'].append({'orig': rel, 'archive': os.path.relpath(dst, W), 'bytes': size,
                                 'sha256': h, 'mtime': time.strftime('%Y-%m-%d %H:%M', time.localtime(mt)),
                                 'archived_at': time.strftime('%Y-%m-%d %H:%M')})
            os.remove(src)
            done += 1
            saved += size - os.path.getsize(dst)
        except Exception as e:
            print('!! 出错', rel, e)
    man['updated'] = time.strftime('%Y-%m-%d %H:%M')
    json.dump(man, open(MANIFEST, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
    return done, saved


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--apply', action='store_true')
    ap.add_argument('--dry-run', action='store_true')
    args = ap.parse_args()
    refs = refs_from_live()
    rows = scan(refs)
    total = sum(r[1] for r in rows)
    print('候选 %d 个｜%.1f MB｜阈值 %d 天' % (len(rows), total / 1e6, DAYS))
    for mt, size, rel in rows[:40]:
        print('  %s %8.1fKB %s' % (time.strftime('%Y-%m-%d', time.localtime(mt)), size / 1024, rel))
    if len(rows) > 40:
        print('  ... 其余 %d 个' % (len(rows) - 40))
    if args.apply and rows:
        done, saved = apply(rows)
        print('✅ 归档 %d 个，释放 %.1f MB（清单 %s）' % (done, saved / 1e6, MANIFEST))
    elif not args.dry_run:
        print('（未执行；加 --apply 执行）')


if __name__ == '__main__':
    main()
