# -*- coding: utf-8 -*-
"""R3 批量转卡：把 AI 读不到的格式（docx/xlsx/pptx/xmind/txt/doc/xls/zip）抽成 MD 文本卡。
输出：知识库-copilot/_文本层/<原相对路径>.md   （原件原地不动，可回滚）
台账：知识库-copilot/_文本层/_转换台账.json
用法：python3 脚本/kb_convert.py [--limit N]
"""
import os, sys, json, zipfile, subprocess, tempfile, datetime, re

ROOT = os.path.expanduser('~/Desktop/Hermes输出-工作类')
KB = os.path.join(ROOT, '知识库-copilot')
OUTDIR = os.path.join(KB, '_文本层')
LEDGER = os.path.join(OUTDIR, '_转换台账.json')
READ_NOW = {'.md', '.pdf'}
MAXCHARS = 60000          # 单文件正文上限（防止超大表格撑爆上下文）
LINE_OF_TOP = {'公用': '公用', '诊断原料': 'da', '生命科学': 'ls'}


def rel(p):
    return os.path.relpath(p, KB)


def line_of(relpath):
    top = relpath.split(os.sep)[0]
    return LINE_OF_TOP.get(top, '公用')


# ---------- 各格式抽取 ----------
def ext_docx(p):
    import docx
    d = docx.Document(p)
    out = []
    for para in d.paragraphs:
        t = (para.text or '').strip()
        if t:
            style = (para.style.name or '') if para.style is not None else ''
            if style.startswith('Heading') or style.startswith('标题'):
                lvl = re.sub(r'\D', '', style) or '2'
                out.append('#' * min(int(lvl or 2), 6) + ' ' + t)
            else:
                out.append(t)
    for ti, tb in enumerate(d.tables):
        rows = []
        for row in tb.rows:
            cells = [(c.text or '').strip().replace('\n', ' ') for c in row.cells]
            if any(cells):
                rows.append('| ' + ' | '.join(cells) + ' |')
        if rows:
            out.append('\n#### 表 %d\n' % (ti + 1))
            out.append('| ' + ' | '.join(['—'] * len(tb.columns)) + ' |')
            out.append('|' + '---|' * len(tb.columns))
            out.extend(rows)
    return '\n\n'.join(out)


def ext_xlsx(p):
    import openpyxl
    wb = openpyxl.load_workbook(p, data_only=True, read_only=True)
    out = []
    for ws in wb.worksheets:
        out.append('## 工作表：%s（%d 行 × %d 列）' % (ws.title, ws.max_row or 0, ws.max_column or 0))
        n = 0
        for row in ws.iter_rows(values_only=True):
            vals = ['' if v is None else str(v).strip().replace('\n', ' ') for v in row]
            vals = [v for v in vals]
            if not any(vals):
                continue
            n += 1
            out.append('| ' + ' | '.join(vals) + ' |')
            if n >= 400:
                out.append('…（超过 400 行，已截断，完整见原件）')
                break
    return '\n'.join(out)


def ext_pptx(p):
    from pptx import Presentation
    pr = Presentation(p)
    out = []
    for i, slide in enumerate(pr.slides, 1):
        out.append('## 第 %d 页' % i)
        for sh in slide.shapes:
            if sh.has_text_frame:
                t = (sh.text_frame.text or '').strip()
                if t:
                    out.append(t)
            if getattr(sh, 'has_table', False) and sh.has_table:
                for row in sh.table.rows:
                    cells = [(c.text or '').strip().replace('\n', ' ') for c in row.cells]
                    if any(cells):
                        out.append('| ' + ' | '.join(cells) + ' |')
        try:
            note = slide.notes_slide.notes_text_frame.text.strip()
            if note:
                out.append('> 备注：' + note)
        except Exception:
            pass
    return '\n\n'.join(out)


def ext_xmind(p):
    """XMind 是 zip：新版 content.json，旧版 content.xml。"""
    out = []
    with zipfile.ZipFile(p) as z:
        names = z.namelist()
        if 'content.json' in names:
            data = json.loads(z.read('content.json').decode('utf-8', 'ignore'))

            def walk_json(node, depth, acc):
                title = (node.get('title') or '').strip()
                if title:
                    acc.append('%s%s %s' % ('#' * min(depth + 1, 6), '', title) if depth == 0
                               else '  ' * depth + '- ' + title)
                for ch in (node.get('children') or {}).get('attached') or []:
                    walk_json(ch, depth + 1, acc)
                for ch in (node.get('children') or {}).get('detached') or []:
                    walk_json(ch, depth + 1, acc)

            for sh in (data if isinstance(data, list) else [data]):
                acc = []
                walk_json(sh.get('rootTopic') or {}, 0, acc)
                if acc:
                    out.append('\n'.join(acc))
        elif 'content.xml' in names:
            xml = z.read('content.xml').decode('utf-8', 'ignore')
            for m in re.finditer(r'<topic[^>]*>|<title>(.*?)</title>', xml, re.S):
                pass
            titles = re.findall(r'<title[^>]*>(.*?)</title>', xml, re.S)
            for t in titles:
                t = re.sub(r'<[^>]+>', '', t).strip()
                if t:
                    out.append('- ' + t)
        else:
            out.append('（未能解析 XMind 内容，包内文件：%s）' % ', '.join(names[:10]))
    return '\n'.join(out)


OCR_BIN = '/tmp/ocr_img'
_jpg_cache = {}


def _ensure_ocr_bin():
    if os.path.exists(OCR_BIN):
        return True
    src = os.path.expanduser('~/.hermes/skills/macos/macos-vision-image-processing/scripts/ocr_img.swift')
    if not os.path.exists(src):
        return False
    r = subprocess.run(['swiftc', '-O', src, '-o', OCR_BIN], capture_output=True, text=True)
    return os.path.exists(OCR_BIN)


def ext_jpg(p):
    if p in _jpg_cache:
        return _jpg_cache[p]
    if not _ensure_ocr_bin():
        return ''
    r = subprocess.run([OCR_BIN, p], capture_output=True, text=True)
    txt = r.stdout or ''
    # 输出形如 [y=0.xx x=0.xx] 文本 → 只留文本，按 y 降序（上→下）
    lines = []
    for ln in txt.splitlines():
        m = re.match(r'\s*\[y=([\d.]+)\s+x=([\d.]+)\]\s*(.*)', ln)
        if m:
            lines.append((float(m.group(1)), float(m.group(2)), m.group(3).strip()))
        elif ln.strip():
            lines.append((0.0, 0.0, ln.strip()))
    lines.sort(key=lambda t: (-t[0], t[1]))
    out = '\n'.join(t[2] for t in lines if t[2])
    _jpg_cache[p] = out
    return out


def ext_textutil(p, to='txt'):
    with tempfile.TemporaryDirectory() as td:
        r = subprocess.run(['textutil', '-convert', to, '-output', os.path.join(td, 'o.txt'), p],
                           capture_output=True, text=True)
        f = os.path.join(td, 'o.txt')
        if os.path.exists(f):
            return open(f, encoding='utf-8', errors='ignore').read()
        return ''


def ext_txt(p):
    return open(p, encoding='utf-8', errors='ignore').read()


def ext_zip(p):
    with zipfile.ZipFile(p) as z:
        names = z.namelist()
    return '压缩包内文件清单（共 %d 个）：\n' % len(names) + '\n'.join('- ' + n for n in names[:80])


def ext_xls(p):
    try:
        import pandas as pd
        sheets = pd.read_excel(p, sheet_name=None)
        out = []
        for name, df in sheets.items():
            out.append('## 工作表：%s（%d 行 × %d 列）' % (name, len(df), len(df.columns)))
            out.append(_df_md(df, 400))
        return '\n\n'.join(out)
    except Exception:
        return ext_textutil(p)


def _df_md(df, limit):
    df = df.head(limit).fillna('')
    cols = ['—'] * len(df.columns)
    lines = ['| ' + ' | '.join(str(c).replace('\n', ' ').replace('|', '/') for c in cols) + ' |',
             '|' + '---|' * len(df.columns)]
    for _, row in df.iterrows():
        lines.append('| ' + ' | '.join(str(v).replace('\n', ' ').replace('|', '/')[:200] for v in row) + ' |')
    if len(df) >= limit:
        lines.append('…（超过 %d 行，已截断，完整见原件）' % limit)
    return '\n'.join(lines)


IMG_EXT = {'.jpg', '.jpeg', '.png'}
HANDLERS = {'.docx': ext_docx, '.xlsx': ext_xlsx, '.pptx': ext_pptx, '.xmind': ext_xmind,
            '.txt': ext_txt, '.zip': ext_zip, '.doc': ext_textutil, '.xls': ext_xls,
            '.jpg': ext_jpg, '.jpeg': ext_jpg, '.png': ext_jpg}


def img_family(p):
    b = os.path.splitext(os.path.basename(p))[0]
    return re.sub(r'(_1|_thumb|_hd|_thumb_1|_hd_1)+$', '', b)


def main():
    limit = None
    if '--limit' in sys.argv:
        limit = int(sys.argv[sys.argv.index('--limit') + 1])
    os.makedirs(OUTDIR, exist_ok=True)
    todo = []
    for r, ds, fs in os.walk(KB):
        ds[:] = [d for d in ds if not d.startswith('.') and d != '_文本层']
        for f in fs:
            if f.startswith('.') or f == '.DS_Store':
                continue
            p = os.path.join(r, f)
            if os.path.islink(p):
                continue
            e = os.path.splitext(f)[1].lower()
            if e in READ_NOW:
                continue
            todo.append((p, e))
    todo.sort()
    if limit:
        todo = todo[:limit]
    # 图片族归并：同图多尺寸只 OCR 最大一张，其余写成指针卡
    fams = {}
    for p, e in todo:
        if e in IMG_EXT:
            fams.setdefault(img_family(p), []).append(p)
    canon = {}
    for k, ps in fams.items():
        canon[k] = max(ps, key=lambda x: os.path.getsize(x))

    ledger = {'run': datetime.datetime.now().isoformat(timespec='seconds'),
              'total': len(todo), 'ok': 0, 'fail': 0, 'skip': 0, 'items': []}
    for p, e in todo:
        rp = rel(p)
        outpath = os.path.join(OUTDIR, os.path.splitext(rp)[0] + '.md')
        h = HANDLERS.get(e)
        if h is None:
            ledger['skip'] += 1
            ledger['items'].append({'file': rp, 'status': 'skip', 'reason': '需 OCR/无解析器'})
            continue
        stub = ''
        if e in IMG_EXT and canon.get(img_family(p)) != p:
            c = os.path.relpath(canon[img_family(p)], KB)
            stub = '同图副本：本文件与 `%s` 是同一张图的不同尺寸（`_thumb`/`_hd`/`_1`），正文见该图的转卡。' % c
        try:
            body = stub or (h(p) or '')
            body = re.sub(r'\n{3,}', '\n\n', body).strip()
        except Exception as ex:
            ledger['fail'] += 1
            ledger['items'].append({'file': rp, 'status': 'fail', 'reason': '%s: %s' % (type(ex).__name__, ex)[:200]})
            continue
        if not body:
            ledger['fail'] += 1
            ledger['items'].append({'file': rp, 'status': 'fail', 'reason': '抽出空内容'})
            continue
        truncated = len(body) > MAXCHARS
        body_use = body[:MAXCHARS]
        mt = datetime.datetime.fromtimestamp(os.path.getmtime(p)).strftime('%Y-%m-%d')
        head = ['---', 'title: %s' % os.path.splitext(os.path.basename(p))[0],
                'origin: %s' % rp, 'ext: %s' % e, 'line: %s' % line_of(rp),
                'converted: %s' % datetime.date.today().isoformat(),
                'source_mtime: %s' % mt, 'auto: true', 'confidence: raw',
                'words: %d' % len(body), 'truncated: %s' % ('true' if truncated else 'false'),
                '---', '', '# %s' % os.path.splitext(os.path.basename(p))[0], '',
                '> 自动转卡（原件：`%s`）｜本卡由机器抽取，未经人工确认；数值口径以原件为准。' % rp, '']
        if truncated:
            head.append('> ⚠️ 原文 %d 字，此处保留前 %d 字，完整内容见原件。\n' % (len(body), MAXCHARS))
        os.makedirs(os.path.dirname(outpath), exist_ok=True)
        with open(outpath, 'w', encoding='utf-8') as fh:
            fh.write('\n'.join(head) + body_use + '\n')
        ledger['ok'] += 1
        ledger['items'].append({'file': rp, 'status': 'ok', 'chars': len(body),
                                'card': os.path.relpath(outpath, KB), 'truncated': truncated})
    json.dump(ledger, open(LEDGER, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
    import collections
    byext = collections.Counter(os.path.splitext(i['file'])[1].lower()
                                for i in ledger['items'])
    print('转卡完成：成功 %d / 失败 %d / 跳过 %d（共 %d）'
          % (ledger['ok'], ledger['fail'], ledger['skip'], ledger['total']))
    print('按格式：', dict(byext))
    print('总字数：%d' % sum(i.get('chars', 0) for i in ledger['items']))
    if ledger['fail'] or ledger['skip']:
        print('未成功明细：')
        for i in ledger['items']:
            if i['status'] != 'ok':
                print('  ', i['status'], i['file'], i.get('reason', ''))


if __name__ == '__main__':
    main()
