#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
会议录音本地转写服务（AIPM 会议纪要插件）

作用：
  在用户【本地电脑】运行，监听 http://127.0.0.1:8765，
  使用 faster-whisper 将会议录音本地转写为文字，保护隐私、不上传音频到云端。
  AIPM 网页端（会议纪要页）会调用本服务完成“多录音转文字并合并”，
  随后再把合并后的文本上传到 AIPM 云端由管理员配置的 AI 生成纪要。

  转写为【服务端后台任务】（job）：网页提交文件后立即拿到 job_id 并断开，
  任务在服务器后台线程独立运行——即使关闭会议纪要网页，已开始的转写仍会继续，
  完成后的合并文本会自动保存 .md 到本机桌面。

接口：
  GET  /health          返回服务状态（网页用来检测插件是否已启动）
  POST /transcribe      同步转写（兼容旧调用）：接收多个录音文件，返回合并文本与桌面路径
  POST /transcribe-async 异步转写：立即返回 {"job_id": ...}，后台线程运行
  GET  /jobs/<job_id>   查询某任务进度（status/done/total/percent/combined/desktop_path）
  GET  /jobs/latest      返回最近一次任务的 id 与状态
  POST /save            将文本保存到本机桌面（网页端实时录音结束后调用）
  GET  /monitor         返回【桌面悬浮进度窗】HTML（独立窗口，关闭网页也不影响转写）

依赖（由 install.bat / install.sh 一键安装）：
  faster-whisper  flask  flask-cors
  方言识别（上海话/粤语/四川话等）额外需要：funasr  torch  torchaudio

环境变量（可选）：
  WHISPER_MODEL=base|small|medium|large-v3   默认 small（首跑会自动下载模型）
  WHISPER_LANG=zh|en|...                     默认 zh（仅普通话引擎使用）
"""
import os
import re
import time
import tempfile
import threading
import uuid
import traceback
import subprocess
import sys

# 使用国内镜像加速 Whisper 模型（首次运行会自动下载）的拉取
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
# 禁用 HuggingFace Xet/CAS 客户端：其重建环节会直连 cas-server.xethub.hf.co
# 并返回 401，导致模型加载失败。禁用后改用标准 LFS 下载（镜像可正常代理）。
os.environ["HF_HUB_DISABLE_XET"] = "1"
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"

from flask import Flask, request, jsonify, Response
from flask_cors import CORS

app = Flask(__name__)
CORS(app)  # 常规跨域（CORS）放行

# ============ 私有网络访问 (Private Network Access, PNA) 放行 ============
# 公网 HTTPS 页面（如 aipm.sh.cn）向本机 http://127.0.0.1:8765 发起请求时，
# 现代浏览器（Chrome / Edge / Firefox）会先发 OPTIONS 预检并带
#   Access-Control-Request-Private-Network: true
# 服务器必须在响应里返回下面这个头，否则请求被“本地网络隔离”策略直接拦截。
# 典型表现：/health 拿不到 -> 页面显示“本地转写服务未连接”；
#          点击「桌面悬浮球」按钮 fetch(/show-ball) 被拦 -> 毫无反应、球不弹出。
# 注：127.0.0.1 属“潜在可信”回环地址，不受普通 mixed-content 拦截，
#     只要补上这个 PNA 头即可从公网页面正常调用本机插件（无需改 HTTPS / 证书）。
@app.after_request
def _allow_private_network(resp):
    resp.headers["Access-Control-Allow-Private-Network"] = "true"
    # 兜底：若 flask-cors 未设置来源头（个别路由/异常路径），补一个通配，避免 CORS 失败
    if "Access-Control-Allow-Origin" not in resp.headers:
        resp.headers["Access-Control-Allow-Origin"] = "*"
    return resp

PLUGIN_VERSION = "2.5.0"   # 插件版本号（页面通过 /health 检测，低于此值提示更新；本版新增多文件并发转写与真实耗时/音频时长统计）
_server_start_time = time.strftime("%Y-%m-%d %H:%M:%S")

MODEL_SIZE = os.environ.get("WHISPER_MODEL", "small")  # base / small / medium / large-v3（默认 small）
LANGUAGE = os.environ.get("WHISPER_LANG", "zh")          # 默认中文
ALLOWED_MODELS = ["base", "small", "medium", "large-v3"]  # 用户可在页面下拉选择的模型范围

# ==================== 方言 / 语言支持 ====================
# 普通话走 faster-whisper；上海话 / 粤语 / 四川话 / 自动检测 走 FunASR(SenseVoice)，
# 其原生支持中文方言且 CPU 上可实时运行。页面「识别语言」下拉与此一一对应。
SUPPORTED_LANGS = {
    "mandarin":  {"label": "普通话",   "engine": "whisper", "funasr_lang": None},
    "shanghai":  {"label": "上海话",   "engine": "funasr",  "funasr_lang": "auto"},
    "cantonese": {"label": "粤语",     "engine": "funasr",  "funasr_lang": "yue"},
    "sichuan":   {"label": "四川话",   "engine": "funasr",  "funasr_lang": "auto"},
    "auto":      {"label": "自动检测", "engine": "funasr",  "funasr_lang": "auto"},
}
DEFAULT_LANG = os.environ.get("WHISPER_LANG", "mandarin")

_funasr_model = None
_funasr_lock = threading.Lock()


def get_funasr_model():
    """懒加载 FunASR SenseVoice 模型（首次调用时从 ModelScope 下载，约 230MB）。"""
    global _funasr_model
    with _funasr_lock:
        if _funasr_model is None:
            try:
                from funasr import AutoModel
            except ImportError:
                raise RuntimeError(
                    "未安装 funasr，无法识别方言。请运行 install.bat / install.sh 安装方言支持。"
                )
            _funasr_model = AutoModel(
                model="iic/SenseVoiceSmall",
                device="cpu",
                disable_update=True,
                vad_model="fsmn-vad",
                vad_kwargs={"max_single_segment_time": 30000},
            )
    return _funasr_model


def _decode_to_wav_16k(src_path):
    """用 PyAV 把任意音频解码并重采样为 16k 单声道 wav（FunASR 需要 16k）。
    ffmpeg 二进制缺失时 PyAV 仍可用，因此不依赖系统 ffmpeg。失败则回退原文件。"""
    try:
        import av
        out = src_path + ".16k.wav"
        inp = av.open(src_path)
        resampler = av.audio.resampler.Resampler(layout="mono", rate=16000)
        o = av.open(out, "w")
        ostream = o.add_stream("pcm_s16le", rate=16000, layout="mono")
        for frame in inp.decode(audio=0):
            for fr in resampler.resample(frame):
                for p in ostream.encode(fr):
                    o.mux(p)
        for p in ostream.encode(None):
            o.mux(p)
        o.close()
        inp.close()
        return out
    except Exception:
        return src_path


# SenseVoice 在文本中以内联标记声明每段语言：<|zh|> <|en|> <|yue|> <|ja|> <|ko|>
# 其余 <|...|> 为情绪/语速/事件标记（如 <|NEUTRAL|> <|SPEED|> <|BGM|>），需剥离。
_LANG_MAP = {"zh": "普通话", "en": "英语", "yue": "粤语", "ja": "日语", "ko": "韩语"}
_LANG_TOKEN = re.compile(r"<\|(zh|en|yue|ja|ko)\|>")   # 捕获语言代码
_OTHER_TOKEN = re.compile(r"<\|[^|]+\|>")               # 捕获其余所有 <|...|>


def _split_sensevoice_langs(raw):
    """把 SenseVoice 原始文本按语言标记切成「(语言标签, 文本)」列表，供混合纪要逐句标注。
    无语言标记（纯文本）时返回空列表，调用方应回退到普通后处理。"""
    if not raw:
        return []
    parts = _LANG_TOKEN.split(raw)   # [前缀, 码1, 文1, 码2, 文2, ...]
    if len(parts) < 3:
        return []
    chunks = []
    pre = _OTHER_TOKEN.sub("", parts[0]).strip()
    if pre:
        chunks.append(("普通话", pre))   # 首段语言标记前的文本默认记普通话
    i = 1
    while i + 1 < len(parts):
        code = parts[i]
        txt = _OTHER_TOKEN.sub("", parts[i + 1]).strip()
        if txt:
            chunks.append((_LANG_MAP.get(code, code), txt))
        i += 2
    return chunks


_models = {}     # size -> WhisperModel 实例（全局缓存，仅供同步分片转写 /transcribe-chunk 串行使用）
_model_lock = threading.Lock()
_transcribe_lock = threading.Lock()   # 保护全局模型实例的 transcribe()：仅 /transcribe-chunk 串行使用
_thread_local = threading.local()     # 每个后台转写 worker 线程各自持有独立模型实例（绕开非线程安全 + 支持并发）


def _log_transcribe_error(context):
    """把分片转写异常完整 traceback 落盘，便于排查（页面只会看到简短错误）。"""
    try:
        path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "transcribe_errors.log")
        with open(path, "a", encoding="utf-8") as lf:
            lf.write(time.strftime("%Y-%m-%d %H:%M:%S") + "  " + context + "\n")
            lf.write(traceback.format_exc() + "\n\n")
    except Exception:
        pass

# ==================== 任务管理 ====================
# job_id -> {status, total, done, percent, results[], combined, desktop_path, errors[], created_at}
jobs = {}
_job_order = []          # 任务创建顺序（用于 /jobs/latest）
_jobs_lock = threading.Lock()

# 生成进度（由网页在“生成word文档”时上报，本地悬浮球据此显示百分比）
gen_state = {"active": False, "status": "idle"}  # status: idle/running/done/error


def _stamp():
    t = time.localtime()
    return time.strftime("%Y-%m-%d_%H%M%S", t)


def _save_to_desktop(content, filename):
    """把内容保存到本机桌面，返回最终路径；失败返回空串。"""
    filename = re.sub(r'[\\/:*?"<>|]', "_", filename)
    if not filename.lower().endswith((".md", ".txt")):
        filename += ".md"
    desktop = os.path.join(os.path.expanduser("~"), "Desktop")
    try:
        os.makedirs(desktop, exist_ok=True)
    except Exception:
        desktop = os.path.expanduser("~")
    path = os.path.join(desktop, filename)
    try:
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)
        return path
    except Exception:
        return ""


def get_model(size=None):
    """按模型规格懒加载 Whisper 模型（首次调用时下载/载入，按 size 缓存以避免重复加载）。"""
    size = size or MODEL_SIZE
    if size not in ALLOWED_MODELS:
        size = MODEL_SIZE
    with _model_lock:
        if size not in _models:
            try:
                from faster_whisper import WhisperModel
            except ImportError:
                raise RuntimeError(
                    "未安装 faster-whisper，请先运行 install.bat / install.sh 安装依赖。"
                )
            # CPU 默认用 int8（比 float32 快数倍、内存更省）；有 GPU 可设 WHISPER_DEVICE=cuda
            device = os.environ.get("WHISPER_DEVICE", "cpu")
            compute = os.environ.get(
                "WHISPER_COMPUTE", "int8" if device == "cpu" else "float16"
            )
            try:
                _models[size] = WhisperModel(size, device=device, compute_type=compute)
            except Exception:
                # 回退到最稳妥的 CPU int8
                _models[size] = WhisperModel(size, device="cpu", compute_type="int8")
    return _models[size]


# ==================== 并发转写支持 ====================
# faster-whisper 同一模型实例非线程安全，且 CTranslate2/OpenMP 在多份模型同时加载时
# 容易发生线程过度订阅（over-subscription）反而变慢。策略：
#   1. 每个 worker 线程通过 threading.local 持有【独立】模型实例，绕开非线程安全；
#   2. 每份实例的 cpu_threads 限制为 总核数÷并发数，避免 over-subscription；
#   3. 并发数默认按 CPU 核数自动（min(核数÷2, 4)），可用 TRANSCRIBE_CONCURRENCY 覆盖。
def _resolve_concurrency():
    env = os.environ.get("TRANSCRIBE_CONCURRENCY")
    if env:
        try:
            v = int(env)
            if v > 0:
                return v
        except Exception:
            pass
    cores = os.cpu_count() or 2
    return max(1, min(cores // 2, 4))


def _get_thread_model(size, cpu_threads):
    """取当前 worker 线程专属的 Whisper 模型实例（按 size 缓存，首次加载后复用）。"""
    if not hasattr(_thread_local, "models"):
        _thread_local.models = {}
    if size not in _thread_local.models:
        from faster_whisper import WhisperModel
        device = os.environ.get("WHISPER_DEVICE", "cpu")
        compute = os.environ.get(
            "WHISPER_COMPUTE", "int8" if device == "cpu" else "float16"
        )
        try:
            _thread_local.models[size] = WhisperModel(size, device=device, compute_type=compute, cpu_threads=cpu_threads)
        except Exception:
            _thread_local.models[size] = WhisperModel(size, device="cpu", compute_type="int8", cpu_threads=cpu_threads)
    return _thread_local.models[size]


def _get_thread_funasr():
    """取当前 worker 线程专属的 FunASR 模型实例（方言串行化由调用方加锁保证）。"""
    if not hasattr(_thread_local, "funasr"):
        from funasr import AutoModel
        _thread_local.funasr = AutoModel(
            model="iic/SenseVoiceSmall",
            device="cpu",
            disable_update=True,
            vad_model="fsmn-vad",
            vad_kwargs={"max_single_segment_time": 30000},
        )
    return _thread_local.funasr


def _audio_duration_seconds(path):
    """用 PyAV 读取音频时长（秒，浮点），失败返回 0。用于统计真实音频时长。"""
    try:
        import av
        c = av.open(path)
        try:
            d = float(c.duration or 0) / 1_000_000.0
        finally:
            c.close()
        return d
    except Exception:
        return 0.0


def _update_file_percent(job_id, idx, pct):
    """更新单文件进度百分比，并同步聚合进度（取各文件均值）。"""
    with _jobs_lock:
        r = jobs[job_id]["results"]
        if idx < len(r):
            r[idx]["percent"] = max(r[idx].get("percent", 0), min(int(pct), 100))
        n = len(r)
        if n:
            agg = sum(x.get("percent", 0) for x in r) / n
            jobs[job_id]["percent"] = min(round(agg), 99)


def _set_file_result(job_id, idx, res):
    """单文件转写完成，写回结果并同步聚合进度 / done 计数。"""
    with _jobs_lock:
        r = jobs[job_id]["results"]
        if idx < len(r):
            r[idx].update(res)
            if res.get("status") == "success":
                r[idx]["percent"] = 100
        n = len(r)
        agg = sum(x.get("percent", 0) for x in r) / n if n else 0
        jobs[job_id]["percent"] = min(round(agg), 99)
        jobs[job_id]["done"] = sum(1 for x in r if x.get("status") in ("success", "error"))


def _transcribe_job(job_id, file_specs, model_size=None, lang=None):
    """后台线程：多文件【并发】转写，实时更新各文件进度，完成后自动保存桌面。

    - 用 ThreadPoolExecutor(max_workers=并发数) 并发转写所有文件；
    - 每个 worker 线程持有独立模型实例（threading.local），规避 faster-whisper 非线程安全；
    - 每份实例限制 cpu_threads = 总核数÷并发数，避免 OpenMP over-subscription；
    - 计时：elapsed=真实转写耗时(perf_counter)，duration=音频真实时长(PyAV)，
      聚合 percent 取各文件均值，结果按提交下标对齐写入 results[idx]。

    model_size: 普通话(Whisper)模型规格；lang: 语言/方言。
    """
    import concurrent.futures as cf

    model_size = model_size or MODEL_SIZE
    lang = lang or DEFAULT_LANG
    lang_cfg = SUPPORTED_LANGS.get(lang, SUPPORTED_LANGS["mandarin"])
    engine = lang_cfg["engine"]
    funasr_lang = lang_cfg.get("funasr_lang")
    total = len(file_specs)
    concurrency = _resolve_concurrency()
    # 单实例 CPU 线程数：总核数 ÷ 并发数，避免并发加载多份模型时线程过度订阅
    cpu_threads = max(1, (os.cpu_count() or 2) // concurrency)

    with _jobs_lock:
        jobs[job_id].update({
            "model_size": model_size if engine == "whisper" else lang,
            "lang": lang_cfg["label"],
            "total": total,
            "concurrency": concurrency,
            "status": "running",
            "started_at": time.perf_counter(),
            # 预分配：结果按下标对齐，避免并发完成时顺序错乱
            "results": [
                {"name": n, "status": "queued", "percent": 0, "duration": 0, "elapsed": 0, "text": ""}
                for (n, _) in file_specs
            ],
            "done": 0,
            "percent": 0,
        })

    if total == 0:
        with _jobs_lock:
            jobs[job_id]["status"] = "done"
            jobs[job_id]["percent"] = 100
        return

    # 方言(FunASR)模型全局仅一份，generate 需串行——用锁保护；Whisper 走每线程独立实例。
    funasr_model = None
    if engine == "funasr":
        try:
            funasr_model = get_funasr_model()
        except Exception as e:
            with _jobs_lock:
                jobs[job_id]["status"] = "error"
                jobs[job_id]["errors"].append("方言模型加载失败：" + str(e))
            return
    else:
        # 转写速度：beam_size=1（greedy）在 CPU 上比 beam_size=5 快 2~3 倍，精度略降但会议场景足够
        beam = int(os.environ.get("WHISPER_BEAM", "1"))

    def _worker(idx, name, data):
        """单文件转写（在 worker 线程内执行）。"""
        suffix = os.path.splitext(name)[1] or ".wav"
        tmp = None
        res = {
            "name": name, "status": "running", "percent": 0,
            "duration": 0, "elapsed": 0, "text": "", "error": "",
        }
        try:
            if not data:
                raise ValueError("空文件")
            tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
            tmp.write(data)
            tmp.close()

            t0 = time.perf_counter()
            if engine == "whisper":
                model = _get_thread_model(model_size, cpu_threads)
                segments, info = model.transcribe(
                    tmp.name,
                    language=LANGUAGE,
                    beam_size=beam,
                    vad_filter=True,
                )
                duration = float(getattr(info, "duration", 0) or 0)
                parts = []
                for seg in segments:
                    parts.append(seg.text)
                    if duration > 0:
                        frac = min(getattr(seg, "end", 0) / duration, 1.0)
                        _update_file_percent(job_id, idx, frac * 100)
                text = "".join(parts)
                res["text"] = text
                res["duration"] = int(duration)
            else:
                # FunASR(SenseVoice)：先转 16k 单声道 wav，再识别（CPU 实时、原生支持方言）
                _update_file_percent(job_id, idx, 50)
                wav = _decode_to_wav_16k(tmp.name)
                try:
                    from funasr.utils.postprocess_utils import rich_transcription_postprocess
                    with _funasr_lock:
                        res_gen = funasr_model.generate(
                            input=wav, language=funasr_lang or "auto", use_itn=True, batch_size=1
                        )
                    raw = res_gen[0].get("text", "") if res_gen else ""
                    if raw and lang == "auto":
                        # 混合纪要：自动检测时按 SenseVoice 语言标记逐句标注 [普通话]/[粤语]...
                        chunks = _split_sensevoice_langs(raw)
                        text = "\n".join("[%s] %s" % (lb, tx) for lb, tx in chunks) if chunks \
                            else (rich_transcription_postprocess(raw) if raw else "")
                    else:
                        text = rich_transcription_postprocess(raw) if raw else ""
                finally:
                    if wav != tmp.name and os.path.exists(wav):
                        try:
                            os.unlink(wav)
                        except Exception:
                            pass
                # FunASR 路径补真实音频时长（PyAV）
                res["duration"] = int(_audio_duration_seconds(tmp.name))
                res["text"] = text
            res["elapsed"] = round(time.perf_counter() - t0, 2)
            res["status"] = "success"
        except Exception as e:
            res["status"] = "error"
            res["error"] = str(e)[:300]
            with _jobs_lock:
                jobs[job_id]["errors"].append(name + "：" + str(e)[:200])
        finally:
            if tmp is not None and os.path.exists(tmp.name):
                try:
                    os.unlink(tmp.name)
                except Exception:
                    pass
        _set_file_result(job_id, idx, res)

    # 并发调度：提交全部文件，按完成结果写回（下标已对齐，顺序不会乱）
    blocks = []
    try:
        with cf.ThreadPoolExecutor(max_workers=concurrency) as ex:
            futures = [
                ex.submit(_worker, idx, name, data)
                for idx, (name, data) in enumerate(file_specs)
            ]
            for fu in cf.as_completed(futures):
                try:
                    fu.result()
                except Exception:
                    pass
    except Exception as e:
        with _jobs_lock:
            jobs[job_id]["errors"].append("并发调度异常：" + str(e)[:200])

    with _jobs_lock:
        results = jobs[job_id]["results"]
        total_audio = sum(r.get("duration", 0) for r in results)
        started = jobs[job_id].get("started_at") or time.perf_counter()
        total_elapsed = round(time.perf_counter() - started, 2)
        for r in results:
            if r.get("status") == "success":
                blocks.append("===== 录音文件：%s =====\n%s" % (r["name"], r.get("text", "")))

    tag = model_size if engine == "whisper" else lang
    combined = "\n".join(blocks)
    desktop_path = _save_to_desktop(combined, "会议转写_%s_%s.md" % (tag, _stamp()))
    with _jobs_lock:
        j = jobs[job_id]
        j["combined"] = combined
        j["desktop_path"] = desktop_path
        j["percent"] = 100
        j["total_duration"] = int(total_audio)
        j["total_elapsed"] = total_elapsed
        j["finished_at"] = time.time()
        j["status"] = "done" if combined else "error"
        if not desktop_path:
            j["errors"].append("桌面保存失败（请检查桌面目录权限）")


def _new_job():
    job_id = uuid.uuid4().hex[:12]
    with _jobs_lock:
        jobs[job_id] = {
            "job_id": job_id,
            "status": "queued",     # queued | running | done | error
            "total": 0,
            "done": 0,
            "percent": 0,
            "results": [],
            "combined": "",
            "desktop_path": "",
            "errors": [],
            "created_at": time.time(),
        }
        _job_order.append(job_id)
    return job_id


def _read_job(job_id):
    with _jobs_lock:
        j = jobs.get(job_id)
        return dict(j) if j else None


@app.route("/health")
def health():
    funasr_ok = False
    try:
        import funasr  # noqa: F401
        funasr_ok = True
    except Exception:
        funasr_ok = False
    return jsonify({
        "status": "ok",
        "plugin_version": PLUGIN_VERSION,
        "server_start_time": _server_start_time,
        "model": MODEL_SIZE,
        "language": LANGUAGE,
        "has_monitor": True,
        "async_supported": True,
        "available_models": ALLOWED_MODELS,
        "loaded_models": list(_models.keys()),
        "lang": DEFAULT_LANG,
        "available_langs": [
            {"key": k, "label": v["label"], "engine": v["engine"]}
            for k, v in SUPPORTED_LANGS.items()
        ],
        "funasr_available": funasr_ok,
        "templates": {
            "engineering": "工程监理例会",
            "general": "通用会议",
            "project": "项目进度会议",
        },
    })


@app.route("/status")
def status():
    """组件自检：网页在「已连接」时调用，精确排查缺失了哪些插件/依赖。"""
    here = os.path.dirname(os.path.abspath(__file__))

    def has_mod(name):
        try:
            __import__(name)
            return True
        except Exception:
            return False

    def has_file(rel):
        return os.path.isfile(os.path.join(here, rel))

    components = {
        "server_py":     has_file("server.py"),
        "float_ball_py": has_file("float_ball.py"),
        "venv":          os.path.isdir(os.path.join(here, "venv")),
        "faster_whisper": has_mod("faster_whisper"),
        "funasr":        has_mod("funasr"),
        "flask":         has_mod("flask"),
        "pillow":        has_mod("PIL"),
        "numpy":         has_mod("numpy"),
    }
    # 缺少悬浮球本体仅影响「桌面悬浮球」功能；缺 funasr 仅影响方言识别
    all_ok = all(components[c] for c in
                 ("server_py", "venv", "faster_whisper", "flask"))
    return jsonify({
        "plugin_version": PLUGIN_VERSION,
        "components": components,
        "model_preheated": len(_models) > 0,
        "all_ok": all_ok,
    })


@app.route("/transcribe", methods=["POST"])
def transcribe():
    """同步转写（兼容旧调用）。"""
    files = request.files.getlist("files[]")
    specs = [(f.filename or "audio", f.read()) for f in files if f]
    if not specs:
        return jsonify({"error": "未收到录音文件",
                        "files_keys": list(request.files.keys())}), 400
    job_id = _new_job()
    req_model = request.form.get("model") or None
    req_lang = request.form.get("lang") or None
    _transcribe_job(job_id, specs, req_model, req_lang)
    j = _read_job(job_id)
    return jsonify({
        "results": j["results"],
        "combined_text": j["combined"],
        "success_count": sum(1 for r in j["results"] if r["status"] == "success"),
        "file_count": j["total"],
        "desktop_path": j["desktop_path"],
        "lang": j.get("lang"),
        "job_id": job_id,
    })


@app.route("/transcribe-chunk", methods=["POST"])
def transcribe_chunk():
    """实时分片转写：浏览器 MediaRecorder 分片录音后逐片 POST 过来，
    用已加载的 faster-whisper（普通话）或 FunASR（方言）转写并返回文本。
    表单字段：audio=分片文件(webm/ogg/wav...)，lang=语言键(默认 mandarin)，model=规格(默认 small)。"""
    f = request.files.get("audio")
    if not f or not f.filename:
        return jsonify({"error": "未收到音频分片"}), 400
    lang = request.form.get("lang") or DEFAULT_LANG
    model_size = request.form.get("model") or MODEL_SIZE
    data = f.read()
    if not data:
        return jsonify({"error": "空音频分片"}), 400

    lang_cfg = SUPPORTED_LANGS.get(lang, SUPPORTED_LANGS["mandarin"])
    engine = lang_cfg["engine"]
    suffix = os.path.splitext(f.filename)[1] or ".webm"
    tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
    try:
        tmp.write(data)
        tmp.close()
        if engine == "whisper":
            try:
                model = get_model(model_size)
            except Exception as e:
                return jsonify({"error": "模型加载失败：" + str(e)}), 500
            beam = int(os.environ.get("WHISPER_BEAM", "1"))
            try:
                with _transcribe_lock:
                    segments, _ = model.transcribe(
                        tmp.name, language=LANGUAGE, beam_size=beam, vad_filter=True
                    )
                    text = "".join(s.text for s in segments)
            except Exception as e:
                _log_transcribe_error("whisper transcribe-chunk failed")
                return jsonify({"error": "转写失败：" + str(e)}), 500
        else:
            try:
                fm = get_funasr_model()
            except Exception as e:
                return jsonify({"error": "方言模型加载失败：" + str(e)}), 500
            try:
                wav = _decode_to_wav_16k(tmp.name)
                from funasr.utils.postprocess_utils import rich_transcription_postprocess
                with _funasr_lock:
                    res = fm.generate(
                        input=wav,
                        language=lang_cfg.get("funasr_lang") or "auto",
                        use_itn=True,
                        batch_size=1,
                    )
                raw = res[0].get("text", "") if res else ""
                text = rich_transcription_postprocess(raw) if raw else ""
            except Exception as e:
                _log_transcribe_error("funasr transcribe-chunk failed")
                return jsonify({"error": "方言转写失败：" + str(e)}), 500
            finally:
                if wav != tmp.name and os.path.exists(wav):
                    try:
                        os.unlink(wav)
                    except Exception:
                        pass
        return jsonify({"text": text or "", "status": "ok", "lang": lang_cfg["label"]})
    finally:
        if os.path.exists(tmp.name):
            try:
                os.unlink(tmp.name)
            except Exception:
                pass


@app.route("/transcribe-async", methods=["POST"])
def transcribe_async():
    """异步转写：立即返回 job_id，后台线程继续运行（关闭网页也不影响）。"""
    files = request.files.getlist("files[]")
    specs = [(f.filename or "audio", f.read()) for f in files if f]
    if not specs:
        return jsonify({"error": "未收到录音文件",
                        "files_keys": list(request.files.keys())}), 400
    job_id = _new_job()
    req_model = request.form.get("model") or None
    req_lang = request.form.get("lang") or None
    t = threading.Thread(target=_transcribe_job, args=(job_id, specs, req_model, req_lang), daemon=True)
    t.start()
    lang_label = SUPPORTED_LANGS.get(req_lang or DEFAULT_LANG, SUPPORTED_LANGS["mandarin"])["label"]
    return jsonify({"job_id": job_id, "total": len(specs), "model": req_model or MODEL_SIZE, "lang": lang_label})


@app.route("/jobs/<job_id>", methods=["GET"])
def get_job(job_id):
    j = _read_job(job_id)
    if not j:
        return jsonify({"error": "任务不存在"}), 404
    return jsonify(j)


@app.route("/jobs/latest", methods=["GET"])
def get_latest_job():
    with _jobs_lock:
        if not _job_order:
            return jsonify({"job_id": None})
        jid = _job_order[-1]
        return jsonify({"job_id": jid, "state": dict(jobs[jid])})


@app.route("/save", methods=["POST"])
def save():
    """将转写文本保存到【本机桌面】（网页端实时录音结束后调用）。

    请求体(JSON): {"filename": "会议转写_xxx.md", "content": "..."}
    """
    data = request.get_json(force=True, silent=True) or {}
    filename = data.get("filename") or "会议转写.md"
    content = data.get("content") or ""
    filename = re.sub(r'[\\/:*?"<>|]', "_", filename)
    if not filename.lower().endswith((".md", ".txt")):
        filename += ".md"

    desktop = os.path.join(os.path.expanduser("~"), "Desktop")
    try:
        os.makedirs(desktop, exist_ok=True)
    except Exception:
        desktop = os.path.expanduser("~")
    path = os.path.join(desktop, filename)
    try:
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)
        return jsonify({"success": True, "path": path})
    except Exception as e:
        return jsonify({"success": False, "error": str(e)}), 500


@app.route("/gen", methods=["GET"])
def get_gen():
    """悬浮球查询：网页“生成word文档”时由本地插件跟踪的 AI 整理进度。"""
    return jsonify(gen_state)


@app.route("/gen", methods=["POST"])
def post_gen():
    data = request.get_json(silent=True) or {}
    s = data.get("state")
    if s in ("running", "done", "error", "idle"):
        gen_state["active"] = (s == "running")
        gen_state["status"] = s
    return jsonify(gen_state)


@app.route("/monitor")
def monitor():
    """返回桌面悬浮进度窗 HTML（独立窗口，关闭网页不影响已开始的转写）。"""
    return Response(MONITOR_HTML, mimetype="text/html")


def _kill_stale_balls():
    """杀掉任何已存在的 float_ball.py 进程，避免旧实例占着 8766 单实例端口，
    导致新点击拉起的球 bind 失败而静默退出（表现为“点了没反应”）。"""
    try:
        out = subprocess.run(
            ["wmic", "process", "where", "name='pythonw.exe'",
             "get", "ProcessId,CommandLine", "/FORMAT:CSV"],
            capture_output=True, text=True, encoding="utf-8", errors="replace"
        ).stdout
        for line in out.splitlines():
            if "float_ball.py" not in line:
                continue
            for tok in line.split(","):
                tok = tok.strip()
                if tok.isdigit():
                    try:
                        subprocess.run(["taskkill", "/F", "/PID", tok],
                                       creationflags=subprocess.CREATE_NO_WINDOW,
                                       stdout=subprocess.DEVNULL,
                                       stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
    except Exception:
        pass


@app.route("/show-ball", methods=["POST"])
def show_ball():
    """弹出原生桌面悬浮进度球（无边框+透明背景+置顶）。

    由网页 openFloatingMonitor() 调用，用 venv 的 pythonw.exe 拉起 float_ball.py。
    先清理残留旧球，再启动新球——保证每次点击/加载都会重新弹出一颗干净的球，
    并落在当前浏览器所在的显示器右下角。
    """
    try:
        script_dir = os.path.dirname(os.path.abspath(__file__))
        pythonw = os.path.join(
            os.path.dirname(sys.executable), "pythonw.exe"
        )
        if not os.path.isfile(pythonw):
            pythonw = sys.executable
        # 先杀残留旧球，等 8766 端口释放
        _kill_stale_balls()
        time.sleep(1.0)
        # 注意：不要加 DETACHED_PROCESS —— 它会让 pythonw 子进程丢失窗口站/标准流而静默退出。
        # pythonw 本就是 GUI 子系统，本就无控制台窗口，仅 CREATE_NO_WINDOW 足矣。
        subprocess.Popen(
            [pythonw, "float_ball.py"],
            cwd=script_dir,
            creationflags=subprocess.CREATE_NO_WINDOW,
        )
        return jsonify({"ok": True, "method": "native"})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


MONITOR_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>AIPM 转写</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box;}
  html,body{
    width:140px;height:150px;overflow:hidden;
    background:transparent;
    font-family:-apple-system,"Microsoft YaHei",sans-serif;
  }
  #ball{
    position:absolute;top:10px;left:10px;width:120px;height:120px;border-radius:50%;
    background:
      radial-gradient(circle at 32% 26%, rgba(255,255,255,.95), rgba(255,255,255,0) 40%),
      radial-gradient(circle at 72% 78%, #ff8a5c, #ff5c8a 28%, #8a5cff 60%, #21d4fd 100%);
    box-shadow:0 16px 34px rgba(123,92,255,.45), inset 0 -12px 22px rgba(60,0,90,.30), inset 0 10px 18px rgba(255,255,255,.55);
    display:flex;align-items:center;justify-content:center;cursor:grab;user-select:none;
    animation:floaty 3.4s ease-in-out infinite;
  }
  #ball:active{cursor:grabbing;}
  @keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-7px)}}
  #pct{font-size:32px;font-weight:900;color:#fff;text-shadow:0 2px 7px rgba(0,0,0,.35);line-height:1;}
  #pct small{font-size:14px;font-weight:700;margin-left:1px;}
  #panel{
    position:absolute;top:130px;left:4px;width:290px;
    background:rgba(255,255,255,.88);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);
    border:1px solid rgba(200,210,230,.6);border-radius:16px;
    box-shadow:0 18px 44px rgba(31,41,80,.28);padding:12px 14px;display:none;z-index:10;
  }
  #panel.show{display:block;animation:pop .18s ease;}
  @keyframes pop{from{opacity:0;transform:translateY(-6px) scale(.97)}to{opacity:1;transform:none}}
  .phead{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;}
  .ptitle{font-weight:800;font-size:13px;color:#1e293b;}
  .px{cursor:pointer;font-size:20px;line-height:1;color:#94a3b8;padding:0 4px;}
  .px:hover{color:#ef4444;}
  .psub{font-size:12px;color:#475569;margin-bottom:4px;}
  .ptrack{height:8px;background:#e2e8f0;border-radius:6px;overflow:hidden;margin:6px 0;}
  .ptrack>i{display:block;height:100%;width:0;background:linear-gradient(90deg,#8a5cff,#21d4fd);transition:width .4s;border-radius:6px;}
  .plist{max-height:140px;overflow:auto;margin-top:5px;font-size:11.5px;color:#475569;}
  .plist div{padding:3px 0;border-bottom:1px solid #eef2f7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
  .phint{font-size:10.5px;color:#94a3b8;margin-top:6px;}
  .pdone{color:#0a7a3f;font-weight:800;}
  .perr{color:#ef4444;font-weight:800;}
</style>
</head>
<body>
<div id="ball" title="悬停查看详情 · 可拖拽"><div id="pct">0<small>%</small></div></div>
<div id="panel">
  <div class="phead"><span class="ptitle">🎙️ 转写进度</span><span class="px" id="close" title="关闭（不影响转写）">×</span></div>
  <div class="psub" id="sub">等待任务...</div>
  <div class="ptrack"><i id="barfill"></i></div>
  <div class="plist" id="list"></div>
  <div class="phint" id="hint">悬停小球查看详情 · 关闭不影响转写</div>
</div>
<script>
  // 页面加载后立即收缩窗口为小球尺寸，定位到屏幕右下角
  (function(){
    try {
      var sw = screen.availWidth || screen.width;
      var sh = screen.availHeight || screen.height;
      // 小球窗口：宽 152 × 高 162（含一点面板展开余量）
      resizeTo(152, 162);
      moveTo(sw - 172, sh - 200);
    } catch(e){}
  })();
  var jobId = new URLSearchParams(location.search).get('job');
  function basename(p){ if(!p) return ''; var bs=String.fromCharCode(92); var i=p.lastIndexOf('/'); var j=p.lastIndexOf(bs); var k=Math.max(i,j); return k<0?p:p.substring(k+1); }
  document.getElementById('close').onclick = function(){ window.close(); };
  // 拖拽（整个窗口跟随）
  (function(){
    var b=document.getElementById('ball'), dx=0, dy=0, drag=false, sx=0, sy=0;
    b.onmousedown=function(e){ drag=true; sx=screenX; sy=screenY; dx=e.clientX; dy=e.clientY; e.preventDefault(); };
    document.onmousemove=function(e){
      if(!drag) return;
      try{ moveTo(sx + e.clientX - dx, sy + e.clientY - dy); }catch(ex){}
    };
    document.onmouseup=function(){ drag=false; };
  })();
  var ball=document.getElementById('ball'), panel=document.getElementById('panel');
  ball.onmouseenter=function(){ panel.classList.add('show'); };
  ball.onmouseleave=function(){ panel.classList.remove('show'); };
  function setPct(n){ document.getElementById('pct').innerHTML = n + '<small>%</small>'; document.getElementById('barfill').style.width = n + '%'; }
  var genStart=null;
  function renderGen(g){
    if(g && g.active && g.status==='running'){
      if(!genStart) genStart=Date.now();
      var el=Math.min(92, Math.floor((Date.now()-genStart)/30000*92));
      setPct(el);
      document.getElementById('sub').innerHTML='<span style="color:#7b5cff;font-weight:700;">🤖 AI 整理中...</span>';
      document.getElementById('list').innerHTML='';
      document.getElementById('hint').textContent='正在由管理员 AI 生成会议纪要';
      return true;
    }
    if(g && g.status==='done'){ setPct(100); document.getElementById('sub').innerHTML='<span class="pdone">✅ 纪要已生成</span>'; document.getElementById('hint').textContent='可点 × 关闭悬浮球'; return true; }
    if(g && g.status==='error'){ document.getElementById('sub').innerHTML='<span class="perr">⚠️ 生成失败</span>'; return true; }
    return false;
  }
  function fmtSec(s){ s=Math.round(s||0); var m=Math.floor(s/60), ss=s%60; return (m? m+'m':'')+ss+'s'; }
  function renderTranscribe(j){
    setPct(j.percent||0);
    if(j.status==='done'){ document.getElementById('sub').innerHTML='<span class="pdone">✅ 已完成</span>'; }
    else if(j.status==='error'){ document.getElementById('sub').innerHTML='<span class="perr">⚠️ 转写出错</span>'; }
    else { document.getElementById('sub').textContent='转写中 '+(j.done||0)+' / '+(j.total||0)+(j.model_size?' · '+j.model_size:'')+(j.lang?' · '+j.lang:''); }
    var list=document.getElementById('list'); list.innerHTML='';
    (j.results||[]).forEach(function(r){
      var d=document.createElement('div');
      var icon=r.status==='success'?'✅':r.status==='error'?'❌':(r.status==='queued'?'⏳':'🔄');
      var extra='';
      if(r.status==='success'){ extra=' · 转写'+(r.elapsed||0)+'s/音频'+fmtSec(r.duration); }
      else if(r.status!=='queued'){ extra=' · '+(r.percent||0)+'%'; }
      d.textContent=icon+' '+r.name+extra;
      list.appendChild(d);
    });
    // 统计区：总文件 / 音频合计 / 转写耗时 / 实时倍速
    var ta=j.total_duration||0, te=j.total_elapsed||0;
    var speed=(ta>0 && te>0)?(ta/te).toFixed(2):'—';
    var hint='📊 共'+(j.total||0)+'文件 · 音频'+fmtSec(ta)+' · 转写'+(te?te+'s':'—')+' · 倍速'+speed+'×';
    if(j.status==='done' && j.desktop_path){ hint+=' · 已存 '+basename(j.desktop_path); }
    document.getElementById('hint').innerHTML=hint;
  }
  function poll(){
    fetch('/gen').then(function(r){return r.json();}).then(function(g){
      if(renderGen(g)) return;
      var url = jobId ? ('/jobs/'+jobId) : '/jobs/latest';
      fetch(url).then(function(r){return r.json();}).then(function(d){
        var j = d.state || (d.status ? d : null);
        if(j){ renderTranscribe(j); genStart=null; }
        else { setPct(0); document.getElementById('sub').textContent = jobId ? '读取中...' : '暂无任务...'; document.getElementById('list').innerHTML=''; }
      }).catch(function(){});
    }).catch(function(){
      var url = jobId ? ('/jobs/'+jobId) : '/jobs/latest';
      fetch(url).then(function(r){return r.json();}).then(function(d){ var j=d.state||(d.status?d:null); if(j) renderTranscribe(j); }).catch(function(){});
    });
  }
  poll(); setInterval(poll, 1000);
</script>
</body>
</html>
"""


if __name__ == "__main__":
    print("=" * 50)
    print(" AIPM 本地会议录音转写服务")
    print(" 监听地址: http://127.0.0.1:8765")
    print(" 语音模型: %s   识别语言: %s（普通话=faster-whisper；上海话/粤语/四川话=FunASR）" % (MODEL_SIZE, LANGUAGE))
    print(" 首次运行会自动下载模型（约 150MB~1.5GB，请耐心等待）")
    print(" 悬浮进度窗: http://127.0.0.1:8765/monitor")
    print("=" * 50)
    # 预热默认模型：已下载则秒级就绪；首次会自动下载（会阻塞启动直至就绪），
    # 这样网页一提交就能立刻转写，无需等待模型加载。
    try:
        print(" 正在预热模型 %s ..." % MODEL_SIZE)
        get_model()
        print(" 模型 %s 已就绪" % MODEL_SIZE)
    except Exception as e:
        print(" 模型预热失败（首次使用将在转写时自动下载）：%s" % e)
    app.run(host="127.0.0.1", port=8765, debug=False, threaded=True)
