#!/usr/bin/env python3
"""双声道呼入录音：声学筛查与应答机会对齐。结果为候选，不定责。"""
import argparse
import hashlib
import json
import math
from pathlib import Path
import subprocess
import tempfile
import wave

import numpy as np


def finite(value, name, low=0, high=float("inf")):
    if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not low <= value <= high:
        raise ValueError(f"{name} 必须是 {low} 至 {high} 之间的有限数值")
    return float(value)


def load_audio(path):
    """PCM16 WAV 直接读取；其他格式先检查声道，再由 FFmpeg 保留双轨解码。"""
    def read_wav(file):
        with wave.open(str(file), "rb") as f:
            if f.getnchannels() != 2:
                raise ValueError("必须提供真实双声道录音；禁止将单声道复制成双声道")
            if f.getsampwidth() != 2:
                raise wave.Error("需要 PCM16 解码")
            rate, count = f.getframerate(), f.getnframes()
            if rate < 8000 or count == 0 or count / rate > 3600:
                raise ValueError("示例支持 8 kHz 以上、0 至 3600 秒的录音；长通话请按事件分段")
            data = f.readframes(count)
            if len(data) != count * 4:
                raise ValueError("WAV 数据截断，不能将缺失录音解释为静音")
            return np.frombuffer(data, dtype="<i2").reshape(-1, 2).astype(np.float32) / 32768, rate
    try:
        return read_wav(path)
    except (wave.Error, EOFError):
        probe = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=channels", "-of", "json", str(path)], capture_output=True, text=True, check=True)
        streams = json.loads(probe.stdout).get("streams", [])
        if not streams or streams[0].get("channels") != 2:
            raise ValueError("输入文件首个音轨不是双声道")
        with tempfile.TemporaryDirectory(prefix="silence-decode-") as tmp:
            decoded = Path(tmp) / "decoded.wav"
            subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-i", str(path), "-map", "0:a:0", "-ar", "16000", "-c:a", "pcm_s16le", str(decoded)], capture_output=True, check=True)
            return read_wav(decoded)


def runs(mask, frame_s, duration):
    edges = np.diff(np.r_[False, np.asarray(mask, dtype=bool), False].astype(np.int8))
    return [(round(int(a) * frame_s, 6), round(min(int(b) * frame_s, duration), 6))
            for a, b in zip(np.where(edges == 1)[0], np.where(edges == -1)[0])]


def merge_spans(spans, gap):
    out = []
    for start, end in sorted(spans):
        if out and start - out[-1][1] <= gap + 1e-8:
            out[-1] = (out[-1][0], max(out[-1][1], end))
        else:
            out.append((start, end))
    return out


def complement(spans, start, end):
    cursor = start
    result = []
    for a, b in spans:
        if b <= start or a >= end:
            continue
        a, b = max(a, start), min(b, end)
        if a > cursor:
            result.append((cursor, a))
        cursor = max(cursor, b)
    if cursor < end:
        result.append((cursor, end))
    return result


def acoustic(data, rate, threshold_db=-45, min_active_s=0.16, join_gap_s=0.12):
    frame = max(1, round(rate * 0.02))
    duration = len(data) / rate
    # 最后一帧按实际样本数计算，不能补零拉低尾部音量。
    db, dc = [], []
    for start in range(0, len(data), frame):
        part = data[start:start + frame]
        mean = part.mean(axis=0)
        rms = np.sqrt(np.mean((part - mean) ** 2, axis=0))
        db.append(20 * np.log10(np.maximum(rms, 1e-6)))
        dc.append(mean)
    levels = np.array(db)
    activity = []
    for channel in (0, 1):
        spans = [s for s in runs(levels[:, channel] >= threshold_db, frame / rate, duration)
                 if s[1] - s[0] + 1e-8 >= min_active_s]
        activity.append(merge_spans(spans, join_gap_s))
    return activity, {"median_dbfs": np.median(levels, axis=0).round(2).tolist(),
                      "max_dc_offset": float(np.max(np.abs(dc))),
                      "clipping_fraction": float(np.mean(np.abs(data) >= 0.999)),
                      "duplicate_channel_max_difference": float(np.max(np.abs(data[:, 0] - data[:, 1])))}, levels


def analyze(data, rate, metadata=None, *, ai_start_s=None, threshold_db=-45, timeout_s=5,
            system_channel=1, min_active_s=0.16):
    metadata = dict(metadata or {})
    if system_channel not in (0, 1):
        raise ValueError("system_channel 只能为 0（左）或 1（右）")
    finite(threshold_db, "threshold_db", -100, -1)
    finite(timeout_s, "timeout_s", 0.1, 120)
    finite(min_active_s, "min_active_s", 0.02, 2)
    duration = len(data) / rate
    if duration <= 0 or data.ndim != 2 or data.shape[1] != 2 or not np.isfinite(data).all():
        raise ValueError("无有效双声道样本")
    start = metadata.get("ai_start_s", ai_start_s)
    if start is not None:
        start = finite(start, "ai_start_s", 0, duration)
    end = finite(metadata.get("ai_end_s", duration), "ai_end_s", start or 0, duration)
    if "system_channel" in metadata and metadata["system_channel"] != system_channel:
        raise ValueError("事件文件与命令行的系统声道不一致，请明确指定 --system-channel")
    if not isinstance(metadata.get("channel_verified", False), bool):
        raise ValueError("channel_verified 必须为 true 或 false")
    activity, health, _ = acoustic(data, rate, threshold_db, min_active_s)
    warnings = []
    verified = metadata.get("channel_verified", False)
    if not verified:
        warnings.append("角色映射未验证；只报告指定声道的声学现象")
    duplicate = health["duplicate_channel_max_difference"] < 1e-5
    if duplicate:
        warnings.append("两声道相同或均静音，不能据此确认独立用户/系统轨")
    if health["max_dc_offset"] > 0.05:
        warnings.append("存在明显直流偏移；已去除帧均值后测量")
    if health["clipping_fraction"] > 0.01:
        warnings.append("削波样本超过 1%，需要复核录音质量")
    if start is None:
        warnings.append("缺少 AI 接管时间；整通静音仅作定位，不能判 AI 漏答")
    exclusions = metadata.get("exclusions", [])
    interruptions = metadata.get("user_speech", [])
    for label, spans in (("exclusions", exclusions), ("user_speech", interruptions)):
        for span in spans:
            a = finite(span["start_s"], label + ".start_s", 0, duration)
            finite(span["end_s"], label + ".end_s", a, duration)
    raw_silence = complement(activity[system_channel], start or 0, end)
    silent = [{"start_s": round(a, 3), "end_s": round(b, 3), "duration_s": round(b - a, 3)}
              for a, b in raw_silence if b - a + 1e-8 >= timeout_s]
    opportunities = metadata.get("opportunities", [])
    ids = set()
    for op in opportunities:
        if not isinstance(op.get("id"), str) or not op["id"] or op["id"] in ids:
            raise ValueError("opportunity id 必须为非空且唯一的字符串")
        ids.add(op["id"])
        finite(op["at_s"], "opportunity.at_s", 0, duration)
        if "window_end_s" in op:
            finite(op["window_end_s"], "window_end_s", op["at_s"], duration)
        if not isinstance(op.get("expects_response", True), bool):
            raise ValueError("expects_response 必须为布尔值")
    opportunities = sorted(opportunities, key=lambda op: op["at_s"])
    results = []
    for index, op in enumerate(opportunities):
        at = float(op["at_s"])
        stop = min(end, op.get("window_end_s", end))
        if index + 1 < len(opportunities):
            stop = min(stop, opportunities[index + 1]["at_s"])
        for span in exclusions + interruptions:
            if span["start_s"] > at:
                stop = min(stop, span["start_s"])
        result = {"id": op["id"], "at_s": at, "observed_until_s": max(at, stop),
                  "right_activity_at_s": None, "latency_s": None,
                  "threshold_reached": False, "fault_confirmed": False}
        excluded = (not op.get("expects_response", True) or
                    any(s["start_s"] <= at < s["end_s"] for s in exclusions + interruptions))
        if start is None or not verified or duplicate:
            status = "INSUFFICIENT_EVIDENCE"
        elif excluded or at < start or at >= end:
            status = "EXCLUDED"
        else:
            onsets = [max(a, at) for a, b in activity[system_channel] if b > at and a < stop]
            onset = min(onsets) if onsets else None
            latency = onset - at if onset is not None else None
            result.update(right_activity_at_s=round(onset, 3) if onset is not None else None,
                          latency_s=round(latency, 3) if latency is not None else None,
                          threshold_reached=(onset if onset is not None else stop) - at + 1e-8 >= timeout_s)
            if onset is not None:
                status = "LATE_RIGHT_ACTIVITY" if latency + 1e-8 >= timeout_s else "RIGHT_ACTIVITY_OBSERVED"
            elif stop - at + 1e-8 >= timeout_s:
                status = "NO_AUDIBLE_OUTPUT_CANDIDATE"
            else:
                status = "CENSORED"
        result["status"] = status
        results.append(result)
    if not opportunities:
        warnings.append("没有应答事件；用户声学结束不等于语义说完，未自动生成漏答结论")
    return {"schema_version": "1.0", "duration_s": round(duration, 3), "sample_rate": rate,
            "system_channel": system_channel, "ai_start_s": start, "ai_end_s": end,
            "config": {"threshold_dbfs": threshold_db, "timeout_s": timeout_s, "frame_ms": 20,
                       "min_active_s": min_active_s, "join_gap_s": 0.12},
            "health": health, "warnings": warnings, "system_activity": activity[system_channel],
            "user_activity": activity[1 - system_channel], "acoustic_silences": silent,
            "opportunities": results,
            "candidate_count": sum(r["status"] in ("NO_AUDIBLE_OUTPUT_CANDIDATE", "LATE_RIGHT_ACTIVITY") for r in results),
            "interpretation": "有能量不等于有语音；录音无声不等于模型未生成；候选需语音/语义与日志复核。"}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("audio", type=Path)
    parser.add_argument("--events", type=Path)
    parser.add_argument("--ai-start", type=float)
    parser.add_argument("--system-channel", type=int, choices=[0, 1], default=1)
    parser.add_argument("--threshold-db", type=float, default=-45)
    parser.add_argument("--timeout", type=float, default=5)
    parser.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()
    try:
        protected = [args.audio.resolve()] + ([args.events.resolve()] if args.events else [])
        if args.out.resolve() in protected:
            raise ValueError("输出不能覆盖原始录音或事件文件")
        audio, rate = load_audio(args.audio)
        metadata = json.loads(args.events.read_text()) if args.events else {}
        report = analyze(audio, rate, metadata, ai_start_s=args.ai_start, threshold_db=args.threshold_db,
                         timeout_s=args.timeout, system_channel=args.system_channel)
        digest = hashlib.sha256()
        with args.audio.open("rb") as f:
            for chunk in iter(lambda: f.read(1024 * 1024), b""): digest.update(chunk)
        report["input_sha256"] = digest.hexdigest()
        args.out.parent.mkdir(parents=True, exist_ok=True)
        args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2))
        print(json.dumps({"candidates": report["candidate_count"], "opportunities": len(report["opportunities"]),
                          "warnings": report["warnings"], "output": str(args.out)}, ensure_ascii=False))
    except (ValueError, KeyError, TypeError, OSError, subprocess.CalledProcessError) as error:
        parser.exit(2, f"检测失败：{error}\n")


if __name__ == "__main__":
    main()
