"""Filesystem watcher (Phase 2b §4).

Watches user-configured paths for ``created``/``modified`` events and emits
proactive triggers using the same capability model as inbound webhooks:

    * ``notify_only`` — enqueue a redacted summary + macOS notification.
    * ``full_turn``    — enqueue + arm the runner for an active turn.

Rules live in ``[file_watcher]`` of ``mugi_config.toml``. The file watcher is
default-off and requires the master ``file_watcher.enabled`` flag.

A rule looks like::

    {
        "name": "downloads-images",
        "path": "~/Downloads",
        "glob": "*.png",
        "recursive": false,
        "debounce_sec": 5,
        "capability": "notify_only",   # or "full_turn"
    }

The watcher prefers ``watchdog`` (FSEvents on macOS) but falls back to a
polling scan thread when ``watchdog`` is not importable. All paths are
sandboxed under ``$HOME``; symlink escapes are rejected. ``activeHours`` is
respected for ``full_turn`` rules only — ``notify_only`` rules always fire.
"""

from __future__ import annotations

import fnmatch
import logging
import os
import threading
import time
from dataclasses import dataclass, field
from typing import Any

_log = logging.getLogger(__name__)

try:  # Preferred event-driven backend.
    from watchdog.events import FileSystemEventHandler  # type: ignore
    from watchdog.observers import Observer  # type: ignore
    _HAS_WATCHDOG = True
except Exception:  # pragma: no cover - polling fallback path
    FileSystemEventHandler = object  # type: ignore
    Observer = None  # type: ignore
    _HAS_WATCHDOG = False


MAX_RULES = 16
MAX_PATHS_PER_RULE = 1
MAX_GLOB_LENGTH = 120
MIN_DEBOUNCE_SEC = 1
MAX_DEBOUNCE_SEC = 3600
DEFAULT_DEBOUNCE_SEC = 5
_POLL_INTERVAL_SECONDS = 4.0


# ---------------------------------------------------------------------------
# Rule schema + validation
# ---------------------------------------------------------------------------

@dataclass
class WatcherRule:
    name: str
    path: str
    glob: str = "*"
    recursive: bool = False
    debounce_sec: int = DEFAULT_DEBOUNCE_SEC
    capability: str = "notify_only"

    @property
    def abs_path(self) -> str:
        return os.path.realpath(os.path.expanduser(self.path))


def _home() -> str:
    return os.path.realpath(os.path.expanduser("~"))


def _is_under_home(abs_path: str) -> bool:
    home = _home()
    try:
        return os.path.commonpath([home, abs_path]) == home
    except ValueError:
        return False


def validate_rule(raw: dict[str, Any]) -> tuple[WatcherRule | None, str]:
    """Coerce a config rule dict into a :class:`WatcherRule`. Returns (rule, err)."""
    if not isinstance(raw, dict):
        return None, "rule must be a table"

    name = str(raw.get("name") or "").strip()[:64]
    path = str(raw.get("path") or "").strip()
    glob = str(raw.get("glob") or "*").strip() or "*"
    recursive = bool(raw.get("recursive", False))
    capability = str(raw.get("capability") or "notify_only").strip().lower()
    try:
        debounce = int(raw.get("debounce_sec") or DEFAULT_DEBOUNCE_SEC)
    except (TypeError, ValueError):
        return None, "debounce_sec must be an integer"

    if not name:
        return None, "rule.name is required"
    if not path:
        return None, "rule.path is required"
    if len(glob) > MAX_GLOB_LENGTH:
        return None, f"rule.glob exceeds {MAX_GLOB_LENGTH} chars"
    if glob.count("**") > 2 or glob.count("*") > 6:
        return None, "rule.glob is too complex"
    if capability not in ("notify_only", "full_turn"):
        return None, "rule.capability must be 'notify_only' or 'full_turn'"
    if debounce < MIN_DEBOUNCE_SEC or debounce > MAX_DEBOUNCE_SEC:
        return None, f"debounce_sec must be {MIN_DEBOUNCE_SEC}..{MAX_DEBOUNCE_SEC}"

    abs_path = os.path.realpath(os.path.expanduser(path))
    if not _is_under_home(abs_path):
        return None, "rule.path must resolve inside $HOME"

    return WatcherRule(
        name=name, path=path, glob=glob, recursive=recursive,
        debounce_sec=debounce, capability=capability,
    ), ""


def load_rules_from_config() -> list[WatcherRule]:
    try:
        from core.config import get_file_watcher_config
        cfg = get_file_watcher_config() or {}
    except Exception:
        cfg = {}
    raw_rules = cfg.get("rules") or []
    if not isinstance(raw_rules, list):
        return []
    rules: list[WatcherRule] = []
    for raw in raw_rules[:MAX_RULES]:
        rule, err = validate_rule(raw)
        if rule is None:
            _log.warning("file_watcher rejected rule: %s", err)
            continue
        rules.append(rule)
    return rules


def is_enabled() -> bool:
    try:
        from core.config import get_file_watcher_config
        return bool((get_file_watcher_config() or {}).get("enabled", False))
    except Exception:
        return False


# ---------------------------------------------------------------------------
# Event handling
# ---------------------------------------------------------------------------

@dataclass
class _DebouncedDispatcher:
    """Coalesces bursts of events per (rule, path) within debounce_sec."""

    _last_fired: dict[tuple[str, str], float] = field(default_factory=dict)
    _lock: threading.Lock = field(default_factory=threading.Lock)

    def should_fire(self, rule: WatcherRule, changed_path: str) -> bool:
        key = (rule.name, changed_path)
        now = time.time()
        with self._lock:
            prev = self._last_fired.get(key, 0.0)
            if now - prev < rule.debounce_sec:
                return False
            self._last_fired[key] = now
            return True


def _matches(rule: WatcherRule, changed_path: str) -> bool:
    base = os.path.basename(changed_path)
    if not fnmatch.fnmatch(base, rule.glob):
        return False
    real = os.path.realpath(changed_path)
    if not _is_under_home(real):
        return False
    # Changed path must live inside the watched tree.
    try:
        watch_root = rule.abs_path
        return os.path.commonpath([watch_root, real]) == watch_root
    except ValueError:
        return False


def dispatch_event(rule: WatcherRule, changed_path: str, event_type: str = "modified") -> None:
    """Emit one proactive trigger for a debounced file event.

    ``notify_only`` rules enqueue a redacted summary + macOS notification and
    never wake the runner. ``full_turn`` rules additionally arm the runner.
    """
    try:
        from persistence import PROACTIVE_QUEUE_PATH
        from utils.proactive_queue import append_proactive_queue
    except Exception:
        _log.debug("file_watcher: proactive queue unavailable", exc_info=True)
        return

    try:
        from utils.redaction import redact_text
    except Exception:
        redact_text = lambda s: s  # type: ignore

    summary = redact_text(f"[file:{rule.name}] {event_type} {changed_path}")[:400]
    append_proactive_queue(
        PROACTIVE_QUEUE_PATH,
        summary,
        source=f"file_watcher:{rule.name}"[:50],
        priority="normal",
        dedupe_key=f"{rule.name}:{changed_path}:{int(time.time() // max(rule.debounce_sec, 1))}",
        kind="file_event",
    )

    if rule.capability == "notify_only":
        try:
            from utils.notifications import send_mac_notification
            send_mac_notification(
                title=f"Mugi: {rule.name}",
                subtitle=event_type,
                body=os.path.basename(changed_path)[:200],
            )
        except Exception:
            _log.debug("file_watcher notification failed", exc_info=True)
    else:  # full_turn
        try:
            from utils.active_hours import arm_force_active
            arm_force_active()
        except Exception:
            _log.debug("file_watcher arm_force_active failed", exc_info=True)
        try:
            from utils.webhooks import ping_runner_wake
            ping_runner_wake()
        except Exception:
            _log.debug("file_watcher ping_runner_wake failed", exc_info=True)

    try:
        from utils import task_ledger as _tl
        _tl.record_run(
            _tl.KIND_FILE_EVENT,
            f"file_{rule.name}",
            status=_tl.STATUS_OK,
            meta={
                "path": changed_path,
                "event": event_type,
                "capability": rule.capability,
            },
        )
    except Exception:
        _log.debug("file_watcher ledger record failed", exc_info=True)


# ---------------------------------------------------------------------------
# Observer manager
# ---------------------------------------------------------------------------

class _Handler(FileSystemEventHandler):
    def __init__(self, rule: WatcherRule, debouncer: _DebouncedDispatcher) -> None:
        super().__init__()
        self.rule = rule
        self.debouncer = debouncer

    def _emit(self, event_type: str, path: str) -> None:
        if not path or not _matches(self.rule, path):
            return
        if not self.debouncer.should_fire(self.rule, path):
            return
        try:
            dispatch_event(self.rule, path, event_type)
        except Exception:
            _log.debug("file_watcher dispatch failed", exc_info=True)

    def on_created(self, event):  # type: ignore[override]
        if not getattr(event, "is_directory", False):
            self._emit("created", getattr(event, "src_path", ""))

    def on_modified(self, event):  # type: ignore[override]
        if not getattr(event, "is_directory", False):
            self._emit("modified", getattr(event, "src_path", ""))

    def on_moved(self, event):  # type: ignore[override]
        if not getattr(event, "is_directory", False):
            self._emit("moved", getattr(event, "dest_path", ""))


class FileWatcherManager:
    """Starts/stops one observer per configured rule.

    Uses ``watchdog`` when available; otherwise spawns a simple polling thread
    that scans each watch root and fires on new/changed files.
    """

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._observer = None
        self._poll_thread: threading.Thread | None = None
        self._stop_event = threading.Event()
        self._rules: list[WatcherRule] = []
        self._debouncer = _DebouncedDispatcher()
        self._poll_state: dict[str, dict[str, float]] = {}

    def is_running(self) -> bool:
        if self._observer is not None:
            try:
                return self._observer.is_alive()
            except Exception:
                return False
        return bool(self._poll_thread and self._poll_thread.is_alive())

    def start(self, rules: list[WatcherRule] | None = None) -> int:
        with self._lock:
            if self.is_running():
                return len(self._rules)
            self._rules = list(rules if rules is not None else load_rules_from_config())
            if not self._rules:
                _log.info("file_watcher: no rules configured; not starting")
                return 0
            if _HAS_WATCHDOG and Observer is not None:
                self._observer = Observer()
                for rule in self._rules:
                    root = rule.abs_path
                    if not os.path.isdir(root):
                        _log.warning("file_watcher: path missing, skipping rule %s (%s)", rule.name, root)
                        continue
                    self._observer.schedule(
                        _Handler(rule, self._debouncer), root, recursive=rule.recursive,
                    )
                self._observer.start()
                _log.info("file_watcher started with watchdog observer (%d rules)", len(self._rules))
            else:
                self._stop_event.clear()
                self._poll_thread = threading.Thread(
                    target=self._poll_loop, daemon=True, name="file-watcher-poll",
                )
                self._poll_thread.start()
                _log.info("file_watcher started in polling mode (%d rules)", len(self._rules))
            return len(self._rules)

    def stop(self, timeout: float = 5.0) -> None:
        with self._lock:
            if self._observer is not None:
                try:
                    self._observer.stop()
                    self._observer.join(timeout=timeout)
                except Exception:
                    _log.debug("file_watcher observer stop failed", exc_info=True)
                self._observer = None
            if self._poll_thread is not None:
                self._stop_event.set()
                self._poll_thread.join(timeout=timeout)
                self._poll_thread = None

    def _poll_loop(self) -> None:
        while not self._stop_event.is_set():
            for rule in self._rules:
                try:
                    self._poll_once(rule)
                except Exception:
                    _log.debug("poll iteration failed for %s", rule.name, exc_info=True)
            self._stop_event.wait(timeout=_POLL_INTERVAL_SECONDS)

    def _poll_once(self, rule: WatcherRule) -> None:
        root = rule.abs_path
        if not os.path.isdir(root):
            return
        state = self._poll_state.setdefault(rule.name, {})
        for dirpath, _dirs, files in os.walk(root):
            for fname in files:
                full = os.path.join(dirpath, fname)
                try:
                    mtime = os.path.getmtime(full)
                except OSError:
                    continue
                prev = state.get(full)
                state[full] = mtime
                if prev is None:
                    if not _matches(rule, full):
                        continue
                    if self._debouncer.should_fire(rule, full):
                        dispatch_event(rule, full, "created")
                    continue
                if mtime > prev and _matches(rule, full):
                    if self._debouncer.should_fire(rule, full):
                        dispatch_event(rule, full, "modified")
            if not rule.recursive:
                break
