#!/usr/bin/env python3
"""
watcher.py — Phase 1 Lv2 Jarvis 常駐デーモン ファイル監視モジュール

役割：recording/raw/ を監視して新規CHUNK完成を検知し、transcriber.py をディスパッチする。
録音：recorder.py  /  文字起こし：transcriber.py  /  監視・発火：本モジュール（watcher）

2026-05-04 transcribe.sh 26並列爆死事故 根本原因:
  watch_chunks が CHUNK_*.wav で全wav拾い、process_chunk 出力 *_L.wav も再帰的に食った。
  本モジュールはその再発防止設計で作られている。
"""

import argparse
import logging
import os
import re
import shutil
import signal
import subprocess
import sys
import time
from collections import deque
from pathlib import Path

# ---------------------------------------------------------------------------
# パス定数
# ---------------------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
WATCH_DIR_DEFAULT = SCRIPT_DIR / "recording" / "raw"
PROCESSED_DIR = SCRIPT_DIR / "recording" / "processed"
LOGS_DIR = SCRIPT_DIR / "logs"
LOG_FILE = LOGS_DIR / "watcher.log"
TRANSCRIBER = SCRIPT_DIR / "transcriber.py"

# ---------------------------------------------------------------------------
# 安全定数
# ---------------------------------------------------------------------------
# 録音中ファイル判定：size 不変を N 回連続確認してから完成とみなす
SIZE_STABLE_ROUNDS = 2
POLL_INTERVAL = 1.0  # 秒

# 並列上限
MAX_TRANSCRIBER_PROCS = 5

# 異常増殖検知：60秒間に検出ファイル数がこれを超えたら停止
FLOOD_WINDOW_SEC = 60
FLOOD_THRESHOLD = 20  # 30秒チャンク前提なら1分で2ファイルが正常

# ---------------------------------------------------------------------------
# ファイルパターン（核心：再発防止の要）
# _L / _R / _processed / .processing など一切含むものは拾わない
# ---------------------------------------------------------------------------
CHUNK_PATTERN = re.compile(r"^CHUNK_\d{3}\.wav$")


# ---------------------------------------------------------------------------
# ログ設定
# ---------------------------------------------------------------------------
def setup_logging() -> logging.Logger:
    LOGS_DIR.mkdir(parents=True, exist_ok=True)
    logger = logging.getLogger("watcher")
    logger.setLevel(logging.DEBUG)

    fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")

    fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
    fh.setLevel(logging.DEBUG)
    fh.setFormatter(fmt)

    ch = logging.StreamHandler(sys.stdout)
    ch.setLevel(logging.INFO)
    ch.setFormatter(fmt)

    logger.addHandler(fh)
    logger.addHandler(ch)
    return logger


# ---------------------------------------------------------------------------
# 多重起動チェック
# ---------------------------------------------------------------------------
def check_single_instance(logger: logging.Logger) -> bool:
    """
    自分以外の python が watcher.py を実行中なら False を返す。
    pgrep -f だと bash スクリプト中に "watcher.py" 文字列があるだけでヒットするので、
    ps 経由で「python が watcher.py を実行している」プロセスのみを抽出する。
    """
    try:
        result = subprocess.run(
            ["ps", "-ax", "-o", "pid,command"],
            capture_output=True, text=True
        )
        my_pid = os.getpid()
        my_pid_str = str(my_pid)
        others = []
        for line in result.stdout.splitlines()[1:]:  # ヘッダー行除外
            line = line.strip()
            if not line:
                continue
            parts = line.split(None, 1)
            if len(parts) < 2:
                continue
            pid_str, cmd = parts
            if pid_str == my_pid_str:
                continue
            # python が watcher.py を実行しているプロセスのみ
            if "watcher.py" in cmd and ("python" in cmd.lower()):
                try:
                    others.append(int(pid_str))
                except ValueError:
                    continue
        if others:
            logger.error(f"多重起動検知: 既存 watcher PID={others} → 終了")
            return False
        return True
    except Exception as e:
        logger.warning(f"多重起動チェック失敗（続行）: {e}")
        return True


# ---------------------------------------------------------------------------
# transcriber プロセス数取得
# ---------------------------------------------------------------------------
def count_transcriber_procs() -> int:
    try:
        result = subprocess.run(
            ["pgrep", "-c", "-f", "transcriber.py"],
            capture_output=True, text=True
        )
        val = result.stdout.strip()
        return int(val) if val.isdigit() else 0
    except Exception:
        return 0


# ---------------------------------------------------------------------------
# ファイル完成判定（録音中の最新ファイルを除外）
# ---------------------------------------------------------------------------
class SizeTracker:
    """ポーリングごとにファイルサイズを記録し、安定（SIZE_STABLE_ROUNDS 回連続同値）を確認する。"""

    def __init__(self):
        # filename -> deque of sizes
        self._history: dict[str, deque] = {}

    def is_stable(self, path: Path) -> bool:
        """True = size が安定してる（書き込み完了とみなす）"""
        name = path.name
        try:
            size = path.stat().st_size
        except FileNotFoundError:
            self._history.pop(name, None)
            return False

        if name not in self._history:
            self._history[name] = deque(maxlen=SIZE_STABLE_ROUNDS)

        self._history[name].append(size)

        if len(self._history[name]) < SIZE_STABLE_ROUNDS:
            return False

        sizes = self._history[name]
        return len(set(sizes)) == 1 and sizes[-1] > 0

    def forget(self, name: str):
        self._history.pop(name, None)


# ---------------------------------------------------------------------------
# 異常増殖検知
# ---------------------------------------------------------------------------
class FloodGuard:
    def __init__(self, window_sec: int = FLOOD_WINDOW_SEC, threshold: int = FLOOD_THRESHOLD):
        self._window = window_sec
        self._threshold = threshold
        self._timestamps: deque = deque()

    def record(self, logger: logging.Logger) -> bool:
        """
        新規検出を1件記録。threshold 超えたら True（異常）を返す。
        """
        now = time.time()
        self._timestamps.append(now)
        # window外を削除
        while self._timestamps and self._timestamps[0] < now - self._window:
            self._timestamps.popleft()

        count = len(self._timestamps)
        if count > self._threshold:
            logger.critical(
                f"[FloodGuard] 異常増殖検知: {self._window}秒間に {count} ファイル検出 "
                f"(閾値={self._threshold}) → 監視停止"
            )
            return True
        return False

    def simulate_flood(self, n: int):
        """dry-run テスト用：n 件を一括追加して状態確認。"""
        now = time.time()
        for _ in range(n):
            self._timestamps.append(now)


# ---------------------------------------------------------------------------
# transcriber 発火
# ---------------------------------------------------------------------------
def dispatch_transcriber(wav_path: Path, logger: logging.Logger, dry_run: bool = False):
    if dry_run:
        logger.info(f"[dry-run] dispatch: {wav_path}")
        return

    # 処理開始前に processed/ へ move（二重処理防止）
    PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
    dest = PROCESSED_DIR / wav_path.name
    try:
        shutil.move(str(wav_path), str(dest))
        logger.info(f"moved → processed/: {wav_path.name}")
    except Exception as e:
        logger.error(f"move 失敗: {e} → スキップ")
        return

    try:
        subprocess.Popen(
            [sys.executable, str(TRANSCRIBER), str(dest)],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        logger.info(f"transcriber 発火: {dest.name}")
    except Exception as e:
        logger.error(f"transcriber 起動失敗: {e}")


# ---------------------------------------------------------------------------
# メイン監視ループ
# ---------------------------------------------------------------------------
_running = True


def _stop_handler(signum, frame):
    global _running
    _running = False
    print("\n[watcher] SIGTERM/SIGINT 受信 → graceful stop")


def run_watcher(watch_dir: Path, logger: logging.Logger, dry_run: bool = False):
    global _running

    signal.signal(signal.SIGINT, _stop_handler)
    signal.signal(signal.SIGTERM, _stop_handler)

    watch_dir.mkdir(parents=True, exist_ok=True)
    PROCESSED_DIR.mkdir(parents=True, exist_ok=True)

    logger.info(f"[watcher] 起動 watch_dir={watch_dir} dry_run={dry_run}")
    logger.info(f"[watcher] パターン: {CHUNK_PATTERN.pattern}")

    size_tracker = SizeTracker()
    flood_guard = FloodGuard()
    # 起動時点で processed/ に既にある済みファイルを把握
    already_done: set[str] = {f.name for f in PROCESSED_DIR.glob("CHUNK_???.wav")}
    in_flight: set[str] = set()  # move済みで発火待ち or 発火中

    while _running:
        try:
            files = list(watch_dir.iterdir())
        except Exception as e:
            logger.error(f"watch_dir 読み取り失敗: {e}")
            time.sleep(POLL_INTERVAL)
            continue

        for f in files:
            if not f.is_file():
                continue

            # ★核心：厳格パターンマッチ
            if not CHUNK_PATTERN.match(f.name):
                continue  # _L / _R / _processed / .processing 等を完全無視

            if f.name in already_done or f.name in in_flight:
                continue

            # 録音中ファイル除外（size 安定確認）
            if not size_tracker.is_stable(f):
                continue

            # 異常増殖チェック
            if flood_guard.record(logger):
                logger.critical("[watcher] 異常増殖のため監視停止")
                _running = False
                break

            # transcriber 上限ガード
            procs = count_transcriber_procs()
            if procs > MAX_TRANSCRIBER_PROCS:
                logger.warning(f"transcriber 上限超過 ({procs}プロセス) → 待機")
                continue  # 次ポーリングで再試行

            # 発火
            in_flight.add(f.name)
            size_tracker.forget(f.name)
            dispatch_transcriber(f, logger, dry_run=dry_run)
            already_done.add(f.name)
            in_flight.discard(f.name)

        time.sleep(POLL_INTERVAL)

    logger.info("[watcher] 停止完了")


# ---------------------------------------------------------------------------
# dry-run 自己テスト
# ---------------------------------------------------------------------------
def run_dry_run(watch_dir: Path):
    print("[watcher] dry-run 開始\n")
    all_ok = True

    # --- 1. 正規表現テスト ---
    print("=== 1. 正規表現テスト ===")
    ok_cases = ["CHUNK_000.wav", "CHUNK_999.wav", "CHUNK_123.wav"]
    ng_cases = [
        "CHUNK_000_L.wav",
        "CHUNK_000_L_L.wav",
        "CHUNK_000_L_L_L_L_L.wav",
        "CHUNK.wav",
        "CHUNK_0.wav",
        "chunk_000.wav",
        "CHUNK_000.wav.processing",
        "CHUNK_0000.wav",    # 4桁はNG（3桁のみ）
        "CHUNK_00.wav",      # 2桁もNG
        " CHUNK_000.wav",    # 先頭スペース
        "CHUNK_000.WAV",     # 大文字拡張子
    ]

    for name in ok_cases:
        matched = bool(CHUNK_PATTERN.match(name))
        status = "OK" if matched else "FAIL"
        print(f"  [{status}] OK期待: {name!r}")
        if not matched:
            all_ok = False
        assert matched, f"OKケースが弾かれた: {name!r}"

    for name in ng_cases:
        matched = bool(CHUNK_PATTERN.match(name))
        status = "OK" if not matched else "FAIL（NG弾けず）"
        print(f"  [{status}] NG期待: {name!r}")
        if matched:
            all_ok = False
        assert not matched, f"NGケースが通過してしまった: {name!r}  ← 2026-05-04事故再発"

    print()

    # --- 2. 監視dir確認 ---
    print("=== 2. 監視dir確認 ===")
    watch_dir.mkdir(parents=True, exist_ok=True)
    assert watch_dir.exists() and watch_dir.is_dir(), f"watch_dir 作成失敗: {watch_dir}"
    print(f"  [OK] watch_dir: {watch_dir}")
    print()

    # --- 3. 多重起動チェック ---
    print("=== 3. 多重起動チェック ===")
    logger = logging.getLogger("watcher_dryrun")
    logger.setLevel(logging.DEBUG)
    if not logger.handlers:
        logger.addHandler(logging.StreamHandler(sys.stdout))
    result = check_single_instance(logger)
    # dry-run 時は自分しか動いていないはずなので True が正常
    assert result is True, "多重起動チェックが誤検知"
    print(f"  [OK] 多重起動チェック: {result} (起動OKを意味する)")
    print()

    # --- 4. transcriber プロセス数取得 ---
    print("=== 4. transcriber プロセス数取得 ===")
    procs = count_transcriber_procs()
    assert isinstance(procs, int) and procs >= 0, "プロセス数取得エラー"
    print(f"  [OK] 現在の transcriber プロセス数: {procs}")
    print()

    # --- 5. 異常増殖検知ロジック ---
    print("=== 5. 異常増殖検知ロジック ===")
    guard = FloodGuard(window_sec=60, threshold=FLOOD_THRESHOLD)
    guard.simulate_flood(FLOOD_THRESHOLD)  # 閾値ちょうど（超えない）
    # 1件追加して超える
    guard.simulate_flood(1)
    import io, logging as _logging
    buf = io.StringIO()
    _logger = _logging.getLogger("flood_test")
    _logger.setLevel(_logging.DEBUG)
    _logger.handlers.clear()
    _logger.addHandler(_logging.StreamHandler(buf))
    triggered = guard.record(_logger)
    assert triggered, "FloodGuard が FLOOD_THRESHOLD 超でも発火しなかった"
    print(f"  [OK] FloodGuard: {FLOOD_THRESHOLD + 1} 件で発火確認")
    # 正常件数では発火しないことも確認
    guard2 = FloodGuard(window_sec=60, threshold=FLOOD_THRESHOLD)
    guard2.simulate_flood(1)  # 正常：チャンク1件
    buf2 = io.StringIO()
    _logger2 = _logging.getLogger("flood_test2")
    _logger2.setLevel(_logging.DEBUG)
    _logger2.handlers.clear()
    _logger2.addHandler(_logging.StreamHandler(buf2))
    not_triggered = guard2.record(_logger2)
    assert not not_triggered, "FloodGuard が正常件数で誤発火した"
    print(f"  [OK] FloodGuard: 正常件数({2}件)では発火しないことを確認")
    print()

    # --- 結果 ---
    if all_ok:
        print("[watcher] dry-run: 全項目OK")
        sys.exit(0)
    else:
        print("[watcher] dry-run: FAIL あり")
        sys.exit(1)


# ---------------------------------------------------------------------------
# エントリポイント
# ---------------------------------------------------------------------------
def main():
    parser = argparse.ArgumentParser(description="watcher.py — CHUNK監視ディスパッチャ")
    parser.add_argument("--dry-run", action="store_true", help="監視ループに入らずテストのみ実行")
    parser.add_argument("--watch-dir", type=str, default=None, help="監視対象ディレクトリ（デフォルト: recording/raw/）")
    args = parser.parse_args()

    watch_dir = Path(args.watch_dir).resolve() if args.watch_dir else WATCH_DIR_DEFAULT

    if args.dry_run:
        run_dry_run(watch_dir)
        return  # run_dry_run 内で sys.exit するが念のため

    logger = setup_logging()

    if not check_single_instance(logger):
        sys.exit(1)

    run_watcher(watch_dir, logger)


if __name__ == "__main__":
    main()


# ---------------------------------------------------------------------------
# 自己増殖防止サマリ
# ---------------------------------------------------------------------------
# 1. raw/ のみ監視：split/ processed/ は絶対に監視しない
# 2. 正規表現厳格：^CHUNK_\d{3}\.wav$ のみ受理。_L _R _processed .processing 等は全拒否
# 3. 上限ガード：transcriber 同時5プロセス超で発火待機
# 4. 異常増殖検知：60秒に20ファイル超でログ警告＋停止
# 5. 多重起動拒否：自分以外の watcher.py が動いていれば即 exit
