"""watchdog 文件监听 — 自动增量重索引

监听文件 create/modify/delete/rename 事件，500ms debounce 后触发索引。
"""

from __future__ import annotations

import logging
import os
import threading
import time
from pathlib import Path

from watchdog.events import FileSystemEventHandler, FileSystemEvent
from watchdog.observers import Observer

from .indexer import INDEXABLE_EXTENSIONS, SKIP_DIRS

logger = logging.getLogger(__name__)

DEBOUNCE_SECONDS = 0.5


class _DebouncedHandler(FileSystemEventHandler):
    """去抖文件事件处理器"""

    def __init__(self, reindex_callback, delete_callback, base_dir: Path):
        super().__init__()
        self._reindex = reindex_callback
        self._delete = delete_callback
        self._base_dir = base_dir
        self._pending: dict[str, tuple[str, float]] = {}  # path → (action, ts)
        self._lock = threading.Lock()
        self._timer: threading.Timer | None = None

    def _should_handle(self, path: str) -> bool:
        p = Path(path)
        if p.suffix.lower() not in INDEXABLE_EXTENSIONS:
            return False
        try:
            rel = p.relative_to(self._base_dir)
            if any(part in SKIP_DIRS for part in rel.parts[:-1]):
                return False
        except ValueError:
            pass
        return True

    def on_created(self, event: FileSystemEvent) -> None:
        if not event.is_directory and self._should_handle(event.src_path):
            self._schedule(event.src_path, "reindex")

    def on_modified(self, event: FileSystemEvent) -> None:
        if not event.is_directory and self._should_handle(event.src_path):
            self._schedule(event.src_path, "reindex")

    def on_deleted(self, event: FileSystemEvent) -> None:
        if not event.is_directory and self._should_handle(event.src_path):
            self._schedule(event.src_path, "delete")

    def on_moved(self, event: FileSystemEvent) -> None:
        if event.is_directory:
            return
        if self._should_handle(event.src_path):
            self._schedule(event.src_path, "delete")
        if hasattr(event, "dest_path") and self._should_handle(event.dest_path):
            self._schedule(event.dest_path, "reindex")

    def _schedule(self, path: str, action: str) -> None:
        with self._lock:
            self._pending[path] = (action, time.monotonic())
            if self._timer is not None:
                self._timer.cancel()
            self._timer = threading.Timer(DEBOUNCE_SECONDS, self._flush)
            self._timer.daemon = True
            self._timer.start()

    # 单次 flush 最多处理的文件数，防止 git checkout 等大批量事件阻塞
    _MAX_FLUSH_BATCH = 50

    def _flush(self) -> None:
        with self._lock:
            batch = dict(self._pending)
            self._pending.clear()
            self._timer = None

        items = list(batch.items())
        total = len(items)

        if total > self._MAX_FLUSH_BATCH:
            n_batches = (total + self._MAX_FLUSH_BATCH - 1) // self._MAX_FLUSH_BATCH
            logger.warning(
                "检测到大批量文件变更（%d 个），将分 %d 批处理，预计耗时较长，请耐心等待",
                total, n_batches,
            )

        # 分批处理，每批之间让出 CPU 防止阻塞
        for i in range(0, total, self._MAX_FLUSH_BATCH):
            chunk = items[i : i + self._MAX_FLUSH_BATCH]
            if i > 0:
                logger.info("处理第 %d/%d 批...", i // self._MAX_FLUSH_BATCH + 1,
                            (total + self._MAX_FLUSH_BATCH - 1) // self._MAX_FLUSH_BATCH)
            for path, (action, _ts) in chunk:
                try:
                    if action == "delete":
                        try:
                            rel_path = str(Path(path).relative_to(self._base_dir))
                        except ValueError:
                            logger.debug("文件不在监听目录内: %s", path)
                            rel_path = path
                        self._delete(rel_path)
                        logger.info("已删除索引: %s", rel_path)
                    elif action == "reindex":
                        self._reindex(path)
                        logger.info("已重索引: %s", path)
                except Exception as e:
                    logger.warning("处理文件事件失败 %s: %s", path, e)


class FileWatcher:
    """文件监听器，后台线程运行，带崩溃自动重启"""

    _HEALTH_CHECK_INTERVAL = 30  # 健康检查间隔（秒）
    _MAX_RESTART_BACKOFF = 300   # 最大重启退避（秒）

    def __init__(
        self,
        paths: list[str | Path],
        reindex_callback,
        delete_callback,
        base_dir: Path | None = None,
        db=None,
    ):
        self._paths = [Path(p).resolve() for p in paths]
        self._base_dir = base_dir or (self._paths[0] if self._paths else Path.cwd())
        self._reindex_callback = reindex_callback
        self._delete_callback = delete_callback
        self._db = db  # 用于孤儿文档检测（可选）
        self._handler = _DebouncedHandler(
            reindex_callback=reindex_callback,
            delete_callback=delete_callback,
            base_dir=self._base_dir,
        )
        self._observer = Observer()
        self._stopped = False
        self._restart_count = 0
        self._health_timer: threading.Timer | None = None
        self._last_alive_time: float = time.time()  # 上次确认存活的时间戳

    def _schedule_watches(self, observer: Observer) -> None:
        """给 observer 注册监听路径"""
        for path in self._paths:
            try:
                if path.is_dir():
                    observer.schedule(self._handler, str(path), recursive=True)
                elif path.is_file():
                    observer.schedule(self._handler, str(path.parent), recursive=False)
            except OSError as e:
                logger.warning("无法监听 %s: %s", path, e)

    def start(self) -> None:
        """启动文件监听（后台线程）+ 健康检查"""
        self._stopped = False
        self._schedule_watches(self._observer)
        self._observer.daemon = True
        self._observer.start()
        logger.info("文件监听已启动: %s", ", ".join(str(p) for p in self._paths))
        self._start_health_check()

    def _start_health_check(self) -> None:
        """启动定期健康检查"""
        if self._stopped:
            return
        self._health_timer = threading.Timer(
            self._HEALTH_CHECK_INTERVAL, self._check_health,
        )
        self._health_timer.daemon = True
        self._health_timer.start()

    def _check_health(self) -> None:
        """检查 Observer 是否存活，崩溃则自动重启"""
        if self._stopped:
            return
        if self._observer.is_alive():
            self._last_alive_time = time.time()
            self._start_health_check()
            return
        else:
            backoff = min(
                2 ** min(self._restart_count, 10),  # 限制指数上限防溢出
                self._MAX_RESTART_BACKOFF,
            )
            self._restart_count += 1
            logger.warning(
                "文件监听已崩溃，%.0fs 后尝试第 %d 次重启...",
                backoff, self._restart_count,
            )
            # 可中断的 sleep：每秒检查一次 _stopped 标志
            for _ in range(int(backoff)):
                if self._stopped:
                    return
                time.sleep(1)
            if self._stopped:
                return
            crash_time = self._last_alive_time
            try:
                self._observer = Observer()
                self._schedule_watches(self._observer)
                self._observer.daemon = True
                self._observer.start()
                if self._observer.is_alive():
                    logger.info("文件监听已重启（第 %d 次）", self._restart_count)
                    self._restart_count = 0  # 确认存活后才重置
                    self._last_alive_time = time.time()
                    # 补偿扫描放后台线程，不阻塞健康检查
                    t = threading.Thread(
                        target=self._compensate_missed_events,
                        args=(crash_time,),
                        daemon=True,
                    )
                    t.start()
                else:
                    logger.error("Observer.start() 未报错但未运行")
            except Exception as e:
                logger.error("文件监听重启失败: %s", e)
        self._start_health_check()

    def _compensate_missed_events(self, since_timestamp: float) -> None:
        """扫描崩溃期间变更的文件并补偿索引

        遍历监听目录，找出 mtime > since_timestamp 的可索引文件，
        触发 reindex 回调补偿崩溃期间丢失的事件。
        """
        compensated = 0
        for path in self._paths:
            if not path.is_dir():
                continue
            try:
                for f in path.rglob("*"):
                    if self._stopped:
                        return
                    if not f.is_file():
                        continue
                    if f.suffix.lower() not in INDEXABLE_EXTENSIONS:
                        continue
                    try:
                        rel = f.relative_to(path)
                        if any(part in SKIP_DIRS for part in rel.parts[:-1]):
                            continue
                    except ValueError:
                        continue
                    try:
                        if f.stat().st_mtime > since_timestamp:
                            self._reindex_callback(str(f))
                            compensated += 1
                    except OSError:
                        continue
            except OSError as e:
                logger.warning("补偿扫描目录 %s 失败: %s", path, e)

        # Phase 2: 检测孤儿文档（DB 有记录但文件已删除）
        deleted = 0
        if self._db is not None:
            try:
                all_paths = self._db.list_document_paths()
                for doc_path in all_paths:
                    if self._stopped:
                        return
                    abs_path = self._base_dir / doc_path
                    if not abs_path.exists():
                        try:
                            self._delete_callback(doc_path)
                            deleted += 1
                            logger.info("补偿删除孤儿文档: %s", doc_path)
                        except Exception as e:
                            logger.warning("补偿删除失败 %s: %s", doc_path, e)
            except Exception as e:
                logger.warning("孤儿文档检测失败: %s", e)

        if compensated > 0 or deleted > 0:
            logger.info(
                "补偿扫描完成: 重索引 %d 个文件, 删除 %d 个孤儿文档",
                compensated, deleted,
            )

    def stop(self) -> None:
        """停止监听"""
        self._stopped = True
        if self._health_timer:
            self._health_timer.cancel()
            self._health_timer = None
        # 取消 pending debounce flush，防止 stop 后回调仍触发
        with self._handler._lock:
            if self._handler._timer is not None:
                self._handler._timer.cancel()
                self._handler._timer = None
            self._handler._pending.clear()
        self._observer.stop()
        try:
            self._observer.join(timeout=5)
        except RuntimeError:
            pass  # observer 未启动时 join 会抛异常
        logger.info("文件监听已停止")

    @property
    def is_alive(self) -> bool:
        return self._observer.is_alive()


# ── Corpus 过期检测 ──

def check_corpus_stale(db_path: str, threshold_seconds: int = 3600) -> dict:
    """检查 corpus 索引是否过期（L0 纯 DB 查询，<5ms）

    返回 {"stale": bool, "last_indexed": str|None, "age_seconds": int|None}
    """
    import sqlite3
    from datetime import datetime, timezone

    result: dict = {"stale": True, "last_indexed": None, "age_seconds": None}
    try:
        conn = sqlite3.connect(db_path, timeout=2.0)
        try:
            row = conn.execute(
                "SELECT MAX(indexed_at) FROM documents"
            ).fetchone()
        finally:
            conn.close()

        if not row or not row[0]:
            return result  # 无文档 → stale

        last_indexed_str = row[0]
        result["last_indexed"] = last_indexed_str

        # indexed_at 是 SQLite CURRENT_TIMESTAMP（UTC）
        last_dt = datetime.fromisoformat(last_indexed_str).replace(tzinfo=timezone.utc)
        age = (datetime.now(timezone.utc) - last_dt).total_seconds()
        result["age_seconds"] = int(age)
        result["stale"] = age > threshold_seconds
    except Exception as e:
        logger.debug("check_corpus_stale 失败: %s", e)
    return result


# ── Watcher 幂等启动 ──

_WATCHER_PID_FILE = Path.home() / ".claude-index1" / ".watcher.pid"
_WATCHER_LOG_FILE = Path.home() / ".claude-index1" / "watcher.log"


def _read_watcher_pid() -> dict | None:
    """读取 watcher PID 文件"""
    try:
        if _WATCHER_PID_FILE.exists():
            import json as _json
            data = _json.loads(_WATCHER_PID_FILE.read_text())
            if isinstance(data, dict) and "pid" in data:
                return data
    except Exception:
        pass
    return None


def _write_watcher_pid(pid: int) -> None:
    """写入 watcher PID 文件"""
    import json as _json
    _WATCHER_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
    _WATCHER_PID_FILE.write_text(_json.dumps({"pid": pid}))


def _cleanup_watcher_pid() -> None:
    """清理 stale PID 文件"""
    try:
        _WATCHER_PID_FILE.unlink(missing_ok=True)
    except OSError:
        pass


def _is_watcher_process_alive(pid: int) -> bool:
    """检查进程是否存活"""
    try:
        os.kill(pid, 0)
        return True
    except (OSError, ProcessLookupError):
        return False


def ensure_watcher_running(config, *, watch_paths: list[str] | None = None) -> bool:
    """幂等启动 watcher 后台服务。

    已运行 → 跳过，返回 True
    新启动 → 拉起后台进程，返回 True
    失败 → 返回 False（不崩溃）

    Args:
        config: 配置对象
        watch_paths: 可选覆盖，不传则用 config.watch_paths（避免修改 config 对象）

    复用 ensure_web_running() / ensure_observer_running() 模式。
    """
    import shutil
    import subprocess

    try:
        # L1 事前校验：watch_paths 非空
        watch_paths = watch_paths if watch_paths is not None else getattr(config, "watch_paths", [])
        if not watch_paths:
            return False

        # L1 事前校验：至少一个路径存在
        valid_paths = [str(Path(p).expanduser().resolve()) for p in watch_paths if Path(p).expanduser().resolve().exists()]
        if not valid_paths:
            logger.warning("watch_paths 中无有效路径: %s", watch_paths)
            return False

        # 1. 检查 PID 文件
        pid_info = _read_watcher_pid()
        if pid_info:
            pid = pid_info["pid"]
            if _is_watcher_process_alive(pid):
                return True  # 已在运行
            _cleanup_watcher_pid()  # stale PID

        # L1 事前校验：index1 命令可用
        index1_bin = shutil.which("index1")
        if not index1_bin:
            logger.warning("index1 不在 PATH 中，watcher 自动启动失败")
            return False

        # 3. 启动新进程
        _WATCHER_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
        log_fh = open(_WATCHER_LOG_FILE, "a")  # noqa: SIM115
        try:
            cmd = [index1_bin, "watch"] + valid_paths
            # L1: 所有参数必须是 str（宪法#3 三层防御）
            if not all(isinstance(c, str) for c in cmd):
                logger.error("watcher 启动参数包含非字符串: %s", cmd)
                log_fh.close()
                return False

            proc = subprocess.Popen(
                cmd,
                start_new_session=True,
                stdout=subprocess.DEVNULL,
                stderr=log_fh,
            )

            # L2 事中监控：验证进程存活（宪法#3 铁律#7）
            import time as _time
            _time.sleep(0.5)
            if proc.poll() is not None:
                logger.warning(
                    "Watcher 启动后立即退出 (rc=%s)，详见 %s",
                    proc.returncode, _WATCHER_LOG_FILE,
                )
                _cleanup_watcher_pid()
                return False

            # 写 PID 文件
            _write_watcher_pid(proc.pid)
            # L3 事后日志
            logger.info("Watcher 后台启动成功 (pid=%d, paths=%s)", proc.pid, valid_paths)
            return True
        except Exception:
            log_fh.close()
            raise
    except Exception as e:
        logger.warning("ensure_watcher_running 失败: %s", e)
        return False
