#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
报销助手 · 服务端接口（多用户）
================================
挂到 销售工作台服务.py 的路由里使用。职责：
  1. 上传：把用户投递的票据（PDF / ZIP / 文件夹）落到**本人**工作区收件箱
  2. 排队：写任务队列（只标记，不阻塞 HTTP）
  3. 后台工人：串行执行 run_user_pipeline.py（解包→解析→校验→页面→打印包）
  4. 回读：只返回本人的台账与文件；打印包/页面下载做路径逃逸校验
  5. 通知：处理完成推飞书消息给本人

用户隔离：一切路径都从 expense_users.home_for(uid) 出发，uid 来自服务端 token 会话。
"""
import glob
import hashlib
import importlib
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))   # 报销助手/
SCRIPTS = os.path.join(ROOT, '脚本')
if SCRIPTS not in sys.path:
    sys.path.insert(0, SCRIPTS)
import expense_users as EU  # noqa: E402

JOBS = os.path.join(ROOT, '用户', '_jobs.json')
ALLOW_EXT = ('.pdf', '.zip', '.ofd', '.png', '.jpg', '.jpeg', '.xml')
MAX_UPLOAD = 60 * 1024 * 1024          # 单次请求上限 60MB
_LOCK = threading.Lock()
_RUNNING = {}                          # uid → 正在跑的进程（防并发）


# ══════════════════ 队列 ══════════════════
def load_jobs():
    if os.path.exists(JOBS):
        try:
            return json.load(open(JOBS, encoding='utf-8'))
        except Exception:
            pass
    return {}


def save_jobs(d):
    os.makedirs(os.path.dirname(JOBS), exist_ok=True)
    tmp = JOBS + '.tmp'
    json.dump(d, open(tmp, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
    os.replace(tmp, JOBS)


def enqueue(uid, files):
    with _LOCK:
        d = load_jobs()
        j = d.get(uid) or {}
        j['state'] = 'queued'
        j['queued_at'] = time.strftime('%Y-%m-%d %H:%M:%S')
        j['files'] = sorted(set((j.get('files') or []) + list(files)))
        j['result'] = None
        d[uid] = j
        save_jobs(d)
        return j


# ══════════════════ 上传 ══════════════════
def _safe_name(n):
    n = os.path.basename((n or '').replace('\\', '/')).strip()
    n = re.sub(r'[\x00-\x1f<>:"|?*]', '_', n)
    return n or 'unnamed.pdf'


def _parse_multipart(handler):
    """极简 multipart 解析（够用：发票/压缩包）"""
    ctype = handler.headers.get('Content-Type', '')
    if 'multipart/form-data' not in ctype:
        return []
    boundary = ctype.split('boundary=')[1].strip().strip('"')
    length = int(handler.headers.get('Content-Length') or 0)
    if length <= 0 or length > MAX_UPLOAD:
        raise ValueError('文件过大或为空（上限 %d MB）' % (MAX_UPLOAD // 1048576))
    raw = handler.rfile.read(length)
    out = []
    for part in raw.split(('--' + boundary).encode()):
        if not part or part.startswith(b'--'):
            continue
        part = part.lstrip(b'\r\n')
        he = part.find(b'\r\n\r\n')
        if he < 0:
            continue
        head = part[:he].decode('utf-8', 'replace')
        body = part[he + 4:]
        if body.endswith(b'\r\n'):
            body = body[:-2]
        m = re.search(r'filename="([^"]*)"', head)
        if not m or not m.group(1):
            continue
        out.append((_safe_name(m.group(1)), body))
    return out


def handle_upload(handler, user):
    uid = EU.uid_of(user.get('email', ''), user.get('name', ''))
    u = EU.get(uid)
    if not u or not u.get('allow'):
        return handler._send_json({'ok': False, 'error': '未开通报销助手（请联系刘新元）'}, 403)
    try:
        files = _parse_multipart(handler)
    except Exception as e:
        return handler._send_json({'ok': False, 'error': str(e)}, 400)
    if not files:
        return handler._send_json({'ok': False, 'error': '没有收到文件（支持 PDF / ZIP）'}, 400)

    home = EU.ensure_workspace(uid)
    inbox = os.path.join(home, '数据', '收件箱')
    doney = os.path.join(inbox, '已处理')
    seen = set()
    for d in (inbox, doney):
        for f in glob.glob(os.path.join(d, '*')):
            try:
                seen.add(hashlib.sha256(open(f, 'rb').read()).hexdigest())
            except Exception:
                pass

    saved, skipped = [], []
    for name, body in files:
        if not name.lower().endswith(ALLOW_EXT):
            skipped.append('%s（不支持的类型）' % name)
            continue
        h = hashlib.sha256(body).hexdigest()
        if h in seen:
            skipped.append('%s（与已有票据重复）' % name)
            continue
        seen.add(h)
        target = os.path.join(inbox, name)
        k = 1
        while os.path.exists(target):
            stem, ext = os.path.splitext(name)
            target = os.path.join(inbox, '%s_%d%s' % (stem, k, ext))
            k += 1
        with open(target, 'wb') as f:
            f.write(body)
        saved.append(os.path.basename(target))

    if saved:
        j = enqueue(uid, saved)
        return handler._send_json({'ok': True, 'uid': uid, 'saved': saved, 'skipped': skipped,
                                   'state': j['state'], 'msg': '已收到 %d 个文件，正在排队处理' % len(saved)})
    return handler._send_json({'ok': True, 'uid': uid, 'saved': [], 'skipped': skipped,
                               'msg': '没有新文件（%d 个被跳过）' % len(skipped)})


# ══════════════════ 状态 / 台账 ══════════════════
def _summary(uid):
    home = EU.home_for(uid)
    led_f = os.path.join(home or '', '数据', '报销台账.json')
    out = {'票据数': 0, '合计': 0, '按状态': {}, '更新': ''}
    if home and os.path.exists(led_f):
        try:
            led = json.load(open(led_f, encoding='utf-8'))
            out['票据数'] = (led.get('汇总') or {}).get('票据数', 0)
            out['附件数'] = (led.get('汇总') or {}).get('附件数', 0)
            out['合计'] = (led.get('汇总') or {}).get('合计', 0)
            out['更新'] = led.get('updated', '')
            cnt = {}
            for r in led.get('发票', []):
                if r.get('类型') == '附件':
                    continue
                st = (r.get('校验') or {}).get('状态') or '未校验'
                cnt[st] = cnt.get(st, 0) + 1
            out['按状态'] = cnt
        except Exception as e:
            out['error'] = str(e)
    return out


def _pack_files(uid):
    home = EU.home_for(uid) or ''
    pack_dir = os.path.join(home, '打印包')
    fs = sorted(glob.glob(os.path.join(pack_dir, '*.pdf')), key=os.path.getmtime, reverse=True)
    return [{'name': os.path.basename(f), 'size': os.path.getsize(f),
             'time': time.strftime('%Y-%m-%d %H:%M', time.localtime(os.path.getmtime(f)))}
            for f in fs[:8]]


def handle_status(handler, user):
    uid = EU.uid_of(user.get('email', ''), user.get('name', ''))
    u = EU.get(uid)
    if not u:
        return handler._send_json({'ok': False, 'error': '未注册'}, 403)
    j = (load_jobs().get(uid) or {})
    handler._send_json({'ok': True, 'uid': uid, 'name': u.get('name', ''),
                        'allow': bool(u.get('allow')),
                        'job': {'state': j.get('state') or 'idle', 'queued_at': j.get('queued_at', ''),
                                'finished_at': j.get('finished_at', ''), 'files': j.get('files') or [],
                                'result': j.get('result') or {}},
                        'summary': _summary(uid), 'packs': _pack_files(uid)})


def handle_list(handler, user):
    uid = EU.uid_of(user.get('email', ''), user.get('name', ''))
    u = EU.get(uid)
    if not u or not u.get('allow'):
        return handler._send_json({'ok': False, 'error': '未开通'}, 403)
    home = EU.home_for(uid)
    led_f = os.path.join(home, '数据', '报销台账.json')
    if not os.path.exists(led_f):
        return handler._send_json({'ok': True, 'uid': uid, '票据': [], '汇总': {}, 'packs': []})
    led = json.load(open(led_f, encoding='utf-8'))
    rows = []
    for r in led.get('发票', []):
        if r.get('类型') == '附件':
            continue
        v = r.get('校验') or {}
        rows.append({
            'id': r.get('id'), '开票日期': r.get('开票日期') or '', '销方': r.get('销方名称') or '',
            '金额': r.get('价税合计') or 0, '类别': (r.get('分类') or {}).get('费用类别') or '',
            '状态': v.get('状态') or '未校验', '问题': v.get('问题') or [], '提示': v.get('提示') or [],
            '票种': r.get('发票类型') or '', '来源': r.get('来源文件') or '',
            '推定': bool(r.get('_销方推定')),
        })
    rows.sort(key=lambda x: (x['开票日期'] or '', x['id'] or ''), reverse=True)
    handler._send_json({'ok': True, 'uid': uid, '票据': rows[:400],
                        '汇总': led.get('汇总') or {}, '更新': led.get('updated', ''),
                        'packs': _pack_files(uid)})


def _send_file(handler, path, download_name):
    """下载（路径必须落在用户工作区内）"""
    from urllib.parse import quote
    if not os.path.exists(path):
        return handler._send_json({'ok': False, 'error': '文件不存在'}, 404)
    size = os.path.getsize(path)
    handler.send_response(200)
    handler.send_header('Content-Type', 'application/octet-stream')
    handler.send_header('Content-Length', str(size))
    handler.send_header('Content-Disposition', "attachment; filename*=UTF-8''%s" % quote(download_name))
    handler.send_header('Access-Control-Allow-Origin', '*')
    handler.end_headers()
    with open(path, 'rb') as f:
        shutil.copyfileobj(f, handler.wfile)
    return None


def handle_download(handler, user, kind):
    """kind='pack' 打印包 PDF ｜ kind='page' 可编辑页面 HTML"""
    from urllib.parse import parse_qs
    uid = EU.uid_of(user.get('email', ''), user.get('name', ''))
    u = EU.get(uid)
    if not u or not u.get('allow'):
        return handler._send_json({'ok': False, 'error': '未开通'}, 403)
    home = os.path.abspath(EU.home_for(uid))
    qs = {k: v[0] for k, v in parse_qs(handler.path.split('?')[1]).items()} if '?' in handler.path else {}
    if kind == 'pack':
        _nm = (qs.get('name') or '').strip()
        if any(ord(c) > 127 for c in _nm):      # http.server 按 latin-1 解码，中文名需还原
            try:
                _nm = _nm.encode('latin-1', 'ignore').decode('utf-8', 'ignore') or _nm
            except Exception:
                pass
        name = _safe_name(_nm) if _nm else ''    # 空名 → 取最新，不要退化成 unnamed.pdf
        if name:
            path = os.path.abspath(os.path.join(home, '打印包', name))
        else:
            fs = sorted(glob.glob(os.path.join(home, '打印包', '*.pdf')), key=os.path.getmtime, reverse=True)
            path = fs[0] if fs else ''
        if not path or not os.path.abspath(path).startswith(home + os.sep):
            return handler._send_json({'ok': False, 'error': '非法路径'}, 400)
        return _send_file(handler, path, os.path.basename(path))
    if kind == 'page':
        path = os.path.join(home, '工具', '报销助手.html')
        if not os.path.exists(path):
            return handler._send_json({'ok': False, 'error': '还没有生成页面（先上传发票）'}, 404)
        from urllib.parse import quote
        body = open(path, 'rb').read()
        handler.send_response(200)
        handler.send_header('Content-Type', 'text/html; charset=utf-8')
        handler.send_header('Content-Length', str(len(body)))
        handler.send_header('Content-Disposition', "inline; filename*=UTF-8''%s" % quote('报销助手.html'))
        handler.end_headers()
        handler.wfile.write(body)
        return None
    return handler._send_json({'ok': False, 'error': '未知类型'}, 400)


# ══════════════════ 后台工人 ══════════════════
def _notify(uid, result):
    """处理完推飞书（本人）"""
    try:
        u = EU.get(uid) or {}
        oid = u.get('open_id')
        if not oid:
            return
        svc = importlib.import_module('销售工作台服务')
        st = result.get('按状态') or {}
        msg = ('🧾 报销助手：你的票据已整理完\n'
               '票据 %s 张 ｜ 合计 %s 元\n'
               '通过 %s ｜ 待补 %s ｜ 待核 %s ｜ 异常 %s\n'
               '打开工作台「报销」查看与打印'
               % (result.get('票据数', 0), result.get('合计', 0),
                  st.get('通过', 0), st.get('待补', 0), st.get('待核', 0), st.get('异常', 0)))
        if not result.get('ok'):
            msg = '🧾 报销助手：处理失败，请重试或联系管理员\n%s' % (result.get('error') or '')
        svc.feishu_notify(oid, msg)
    except Exception as e:
        print('[expense] 通知失败: %s' % e)


def _run_one(uid):
    home = EU.home_for(uid)
    if not home:
        return {'ok': False, 'error': '未注册'}
    env = dict(os.environ)
    env['BAOYU_EXPENSE_HOME'] = home
    env['BAOYU_EXPENSE_SHARED'] = os.path.join(ROOT, '共享')
    env['PYTHONIOENCODING'] = 'utf-8'
    cmd = ['/usr/bin/python3', os.path.join(SCRIPTS, 'run_user_pipeline.py'),
           '--uid', uid, '--pack', '--json']
    p = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=3600)
    for line in (p.stdout or '').split('\n'):
        if line.startswith('__RESULT__'):
            try:
                return json.loads(line[len('__RESULT__'):])
            except Exception:
                pass
    return {'ok': False, 'error': (p.stderr or p.stdout or '')[-300:]}


def worker_loop(interval=6):
    print('[expense] 报销任务工人已启动（每 %ds 轮询）' % interval)
    while True:
        try:
            jobs = load_jobs()
            for uid, j in list(jobs.items()):
                if (j or {}).get('state') != 'queued' or uid in _RUNNING:
                    continue
                j['state'] = 'running'
                j['started_at'] = time.strftime('%Y-%m-%d %H:%M:%S')
                save_jobs(jobs)
                _RUNNING[uid] = True
                try:
                    print('[expense] ▶ 处理 %s …' % uid)
                    res = _run_one(uid)
                    jobs = load_jobs()
                    jj = jobs.get(uid) or {}
                    jj['state'] = 'done' if res.get('ok') else 'failed'
                    jj['finished_at'] = time.strftime('%Y-%m-%d %H:%M:%S')
                    jj['result'] = res
                    jobs[uid] = jj
                    save_jobs(jobs)
                    print('[expense] ✅ %s 完成：票据 %s 张 ｜ 合计 %s'
                          % (uid, res.get('票据数'), res.get('合计')))
                    _notify(uid, res)
                except Exception as e:
                    jobs = load_jobs()
                    jj = jobs.get(uid) or {}
                    jj['state'] = 'failed'
                    jj['finished_at'] = time.strftime('%Y-%m-%d %H:%M:%S')
                    jj['result'] = {'ok': False, 'error': str(e)}
                    jobs[uid] = jj
                    save_jobs(jobs)
                    print('[expense] ❌ %s 失败：%s' % (uid, e))
                finally:
                    _RUNNING.pop(uid, None)
        except Exception as e:
            print('[expense] 工人循环异常: %s' % e)
        time.sleep(interval)


def start_worker():
    t = threading.Thread(target=worker_loop, daemon=True)
    t.start()
    return t


# ══════════ 邮箱授权码（每人自己的邮箱，互不可见）══════════

_MAIL_RUNNING = {}


def _mail_conf_path(uid):
    return os.path.join(EU.home_for(uid) or '', '数据', '邮箱配置.json')


def _mask_account(a):
    a = str(a or '')
    if not a:
        return ''
    if '@' in a:
        n, d = a.split('@', 1)
        return ('%s***@%s' % (n[:2], d)) if len(n) > 2 else ('***@%s' % d)
    return a[:2] + '***'


def _notify_uid(uid, msg):
    try:
        oid = (EU.get(uid) or {}).get('open_id')
        if not oid:
            return
        svc = importlib.import_module('销售工作台服务')
        svc.feishu_notify(oid, msg)
    except Exception as e:
        print('[expense] 通知失败: %s' % e)


def _allowed(uid, handler):
    u = EU.get(uid)
    if not u or not u.get('allow'):
        handler._send_json({'ok': False, 'error': '未开通报销助手（请联系刘新元）'}, 403)
        return False
    return True


def _read_mail_conf(uid):
    p = _mail_conf_path(uid)
    if os.path.exists(p):
        try:
            return json.load(open(p, encoding='utf-8'))
        except Exception:
            return {}
    return {}


def handle_mail_get(handler, user):
    """查自己的邮箱配置状态（授权码永不回传）"""
    uid = EU.uid_of(user.get('email', ''), user.get('name', ''))
    if not _allowed(uid, handler):
        return
    c = _read_mail_conf(uid)
    return handler._send_json({
        'ok': True,
        'configured': bool(c.get('账号') and c.get('授权码')),
        'account': _mask_account(c.get('账号')),
        'host': c.get('imap_host') or 'imap.qq.com',
        'running': bool(_MAIL_RUNNING.get(uid)),
        'scope': '仅抓你自己的邮箱，内容只有你自己能看到',
    })


def handle_mail_save(handler, user):
    """保存自己的邮箱账号+授权码（写本用户工作区，权限 600）"""
    uid = EU.uid_of(user.get('email', ''), user.get('name', ''))
    if not _allowed(uid, handler):
        return
    try:
        n = int(handler.headers.get('Content-Length') or 0)
        body = json.loads(handler.rfile.read(n) or b'{}')
    except Exception:
        return handler._send_json({'ok': False, 'error': 'bad json'}, 400)
    account = str(body.get('账号') or body.get('account') or '').strip()
    code = str(body.get('授权码') or body.get('code') or '').strip()
    if not account or not code:
        return handler._send_json({'ok': False, 'error': '邮箱账号与 IMAP 授权码都要填'}, 400)
    p = _mail_conf_path(uid)
    os.makedirs(os.path.dirname(p), exist_ok=True)
    c = _read_mail_conf(uid)
    c.setdefault('imap_host', 'imap.qq.com')
    c.setdefault('imap_port', 993)
    c.setdefault('use_ssl', True)
    c.setdefault('文件夹', ['INBOX'])
    c.setdefault('只抓未读', False)
    c.setdefault('抓取后标记已读', True)
    c.setdefault('附件后缀', ['.pdf', '.zip'])
    c.setdefault('_说明', 'QQ 邮箱：设置→账户→开启 IMAP→生成授权码。其他邮箱请让管理员改 imap_host。')
    c['账号'] = account
    c['授权码'] = code
    json.dump(c, open(p, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
    os.chmod(p, 0o600)
    return handler._send_json({'ok': True, 'account': _mask_account(account),
                               'msg': '已保存（只存在你自己的工作区，我这边看不到内容）'})


def handle_mail_fetch(handler, user):
    """立即抓取自己的邮箱（后台跑，完成后飞书通知）"""
    uid = EU.uid_of(user.get('email', ''), user.get('name', ''))
    if not _allowed(uid, handler):
        return
    c = _read_mail_conf(uid)
    if not (c.get('账号') and c.get('授权码')):
        return handler._send_json({'ok': False, 'error': '还没填邮箱授权码（先保存再抓取）'}, 400)
    if _MAIL_RUNNING.get(uid):
        return handler._send_json({'ok': True, 'started': False, 'msg': '正在抓取中，请稍候'})
    _MAIL_RUNNING[uid] = True
    here = os.path.dirname(os.path.abspath(__file__))
    root = os.path.dirname(here)

    def _work():
        try:
            env = dict(os.environ)
            env['BAOYU_EXPENSE_HOME'] = EU.home_for(uid)
            env['BAOYU_EXPENSE_SHARED'] = os.path.join(root, '共享')
            r = subprocess.run([sys.executable, os.path.join(here, 'fetch_user_mail.py'),
                                '--uid', uid, '--days', '7'],
                               env=env, capture_output=True, text=True, timeout=1500)
            out = ((r.stdout or '') + (r.stderr or '')).strip()
            m = re.search(r'新增附件\s*(\d+)', out)
            added = m.group(1) if m else '0'
            n = 0
            try:
                led = json.load(open(os.path.join(env['BAOYU_EXPENSE_HOME'], '数据', '报销台账.json'), encoding='utf-8'))
                n = len(led.get('发票') or [])
            except Exception:
                n = 0
            tail = (out.splitlines() or [''])[-1][:110]
            if r.returncode == 0:
                _notify_uid(uid, '📮 邮箱抓票完成：新增附件 %s 个 ｜ 你的台账 %s 张\n%s' % (added, n, tail))
            else:
                _notify_uid(uid, '⚠️ 邮箱抓票失败（退出码 %s）：%s' % (r.returncode, tail))
        except Exception as e:
            _notify_uid(uid, '⚠️ 邮箱抓票异常：%s' % str(e)[:150])
        finally:
            _MAIL_RUNNING[uid] = False

    threading.Thread(target=_work, daemon=True).start()
    return handler._send_json({'ok': True, 'started': True, 'msg': '已开始抓取你自己的邮箱，完成后飞书通知你'})
