"""VaultEditWatcher — detect manual user edits to vault files.

Implements Rule #13 (AGENT AWARENESS OF MANUAL USER ACTIONS) for the vault-
edit channel. When the user edits SOUL.md, CONTRACT.md, or a wiki article
directly in Obsidian (bypassing our API), this watcher notices and emits a
structured event to ActivityLogger so the agent's world model stays current.

Distinguishes manual edits from API writes by checking
VaultWriter.was_recently_written(): every API write stamps the path+mtime,
and the watcher ignores changes that match a recent stamp.

Watched paths:
    system/identity/SOUL.md           → user_override (high)
    system/identity/CONTRACT.md       → user_override (high)
    system/identity/USER.md           → user_override (high)
    system/identity/PROFILE.md        → user          (medium)
    system/identity/TRUST.md          → user_override (high)
    system/identity/RELATIONSHIP.md   → user_override (high)
    knowledge/*/wiki/**/*.md          → user          (medium)
    journal/user/*.md                 → user          (medium)
    projects/**/PROJECT.md            → user          (medium)
    projects/**/tasks/*.md            → user          (low)
    goals/*.md                        → user          (medium)
    system/skills/**/SKILL.md         → user_override (high)
    system/agents/*.md                → user_override (high)
    memory/summaries/*.md             → user_override (medium)

Polls every 30 seconds. Persists mtimes to `.vault-edit-watcher-state.json`
so restarts don't spam events. First boot records baseline without emitting.

API-originated writes are suppressed via VaultWriter._recent_writes stamping —
only out-of-band edits (Obsidian, text editors, direct CLI) fire events.
"""

from __future__ import annotations

import asyncio
import json
import logging
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from agntspace.activity.logger import ActivityLogger
    from agntspace.vault.paths import VaultPaths

log = logging.getLogger(__name__)

_POLL_INTERVAL = 30.0  # seconds
_STATE_FILENAME = ".vault-edit-watcher-state.json"

# Identity files → (category, action, summary, importance)
_IDENTITY_FILES: dict[str, tuple[str, str, str, str]] = {
    "SOUL.md":     ("user_override", "soul_manually_edited",     "User hand-edited SOUL.md",     "high"),
    "CONTRACT.md": ("user_override", "contract_manually_edited", "User hand-edited CONTRACT.md", "high"),
    "USER.md":     ("user_override", "user_manually_edited",     "User hand-edited USER.md",     "high"),
    "PROFILE.md":  ("user",          "profile_manually_edited",  "User hand-edited PROFILE.md",  "medium"),
    "TRUST.md":    ("user_override", "trust_manually_edited",    "User hand-edited TRUST.md",    "high"),
    "RELATIONSHIP.md": ("user_override", "relationship_manually_edited",
                        "User hand-edited RELATIONSHIP.md", "high"),
}


class VaultEditWatcher:
    """Polls identity + wiki files for manual edits and emits ActivityLogger events."""

    def __init__(
        self,
        vault_dir: Path,
        *,
        paths: VaultPaths | None = None,
        activity: ActivityLogger | None = None,
        interval: float = _POLL_INTERVAL,
        journal_edit_callback: Callable[[Path], None] | None = None,
    ) -> None:
        self._vault_dir = Path(vault_dir)
        self._paths = paths
        self._activity = activity
        self._interval = interval
        # Optional hook fired AFTER the activity event when a journal edit
        # is detected. Used to push-trigger light-dream capture so prose
        # the user writes in the journal shows up in the graph quickly,
        # rather than waiting for the 6h cron tick. Synchronous call —
        # the callback must be non-blocking (e.g. schedule an asyncio task).
        self._journal_edit_callback = journal_edit_callback
        self._task: asyncio.Task | None = None
        self._stop = asyncio.Event()

        # Persisted state: {absolute_path: mtime}
        self._state_file = self._vault_dir / _STATE_FILENAME
        self._known_mtimes: dict[str, float] = {}
        self._first_scan = True
        self._load_state()

    # ── Lifecycle ──

    def start(self) -> None:
        if self._task and not self._task.done():
            return
        self._stop.clear()
        self._task = asyncio.create_task(self._run(), name="vault-edit-watcher")
        log.info("VaultEditWatcher started (interval=%.0fs)", self._interval)

    async def stop(self) -> None:
        self._stop.set()
        if self._task:
            try:
                await asyncio.wait_for(self._task, timeout=5.0)
            except (TimeoutError, asyncio.CancelledError):
                pass
        self._save_state()

    async def _run(self) -> None:
        while not self._stop.is_set():
            try:
                self.scan()
            except Exception:
                log.exception("VaultEditWatcher scan failed")
            try:
                await asyncio.wait_for(self._stop.wait(), timeout=self._interval)
            except TimeoutError:
                pass

    # ── State persistence ──

    def _load_state(self) -> None:
        if not self._state_file.is_file():
            return
        try:
            data = json.loads(self._state_file.read_text(encoding="utf-8"))
            mtimes = data.get("mtimes", {})
            if isinstance(mtimes, dict):
                self._known_mtimes = {str(k): float(v) for k, v in mtimes.items()}
                self._first_scan = False
        except Exception:
            log.debug("Failed to load vault-edit-watcher state", exc_info=True)

    def _save_state(self) -> None:
        try:
            self._state_file.write_text(
                json.dumps({"mtimes": self._known_mtimes}, indent=2),
                encoding="utf-8",
            )
        except OSError:
            log.debug("Failed to save vault-edit-watcher state", exc_info=True)

    # ── Scanning ──

    def _resolve(self, logical: str, fallback: str) -> Path:
        """Resolve a logical path via VaultPaths, or fall back to a physical subpath."""
        if self._paths:
            try:
                return self._paths.resolve(logical)
            except Exception:
                pass
        return self._vault_dir / fallback

    def _target_files(self) -> list[tuple[Path, tuple[str, str, str, str]]]:
        """Enumerate all watched files with their classification tuple."""
        targets: list[tuple[Path, tuple[str, str, str, str]]] = []

        # ── Identity files ──
        identity_dir = self._resolve("system/identity", "agntspace/system/identity")
        for filename, classification in _IDENTITY_FILES.items():
            path = identity_dir / filename
            if path.is_file():
                targets.append((path, classification))

        # ── Wiki articles across all KBs ──
        knowledge_dir = self._resolve("knowledge", "agntspace-knowledge")
        if knowledge_dir.is_dir():
            wiki_class = ("user", "wiki_manually_edited",
                          "User hand-edited a wiki article", "medium")
            for kb in knowledge_dir.iterdir():
                if not kb.is_dir():
                    continue
                wiki_root = kb / "wiki"
                if not wiki_root.is_dir():
                    continue
                for md in wiki_root.rglob("*.md"):
                    if self._is_excluded_file(md):
                        continue
                    targets.append((md, wiki_class))

        # ── User journal (agent journal is written by services; API stamps) ──
        journal_user = self._resolve("journal", "agntspace-journal") / "user"
        if journal_user.is_dir():
            journal_class = ("user", "journal_manually_edited",
                             "User hand-edited a journal entry", "medium")
            for md in journal_user.glob("*.md"):
                if self._is_excluded_file(md):
                    continue
                targets.append((md, journal_class))

        # ── Projects (PROJECT.md + tasks) ──
        projects_dir = self._resolve("projects", "agntspace-projects")
        if projects_dir.is_dir():
            project_class = ("user", "project_manually_edited",
                             "User hand-edited a PROJECT.md", "medium")
            task_class = ("user", "task_manually_edited",
                          "User hand-edited a task", "low")
            for slug_dir in projects_dir.iterdir():
                if not slug_dir.is_dir():
                    continue
                project_md = slug_dir / "PROJECT.md"
                if project_md.is_file():
                    targets.append((project_md, project_class))
                tasks_dir = slug_dir / "tasks"
                if tasks_dir.is_dir():
                    for md in tasks_dir.glob("*.md"):
                        if self._is_excluded_file(md):
                            continue
                        targets.append((md, task_class))

        # ── Goals ──
        goals_dir = self._resolve("goals", "agntspace-goals")
        if goals_dir.is_dir():
            goal_class = ("user", "goal_manually_edited",
                          "User hand-edited a goal", "medium")
            for md in goals_dir.glob("*.md"):
                if self._is_excluded_file(md):
                    continue
                targets.append((md, goal_class))

        # ── Skills (SKILL.md definitions — core behavior) ──
        skills_dir = self._resolve("system/skills", "agntspace/system/skills")
        if skills_dir.is_dir():
            skill_class = ("user_override", "skill_manually_edited",
                           "User hand-edited a SKILL.md", "high")
            for md in skills_dir.rglob("SKILL.md"):
                targets.append((md, skill_class))

        # ── Agent definitions ──
        agents_dir = self._resolve("system/agents", "agntspace/system/agents")
        if agents_dir.is_dir():
            agent_class = ("user_override", "agent_manually_edited",
                           "User hand-edited an agent definition", "high")
            for md in agents_dir.rglob("*.md"):
                if self._is_excluded_file(md):
                    continue
                targets.append((md, agent_class))

        # ── Memory summaries (temporal layers) ──
        memory_dir = self._resolve("memory", "agntspace/memory")
        if memory_dir.is_dir():
            summaries_dir = memory_dir / "summaries"
            if summaries_dir.is_dir():
                memory_class = ("user_override", "memory_summary_manually_edited",
                                "User hand-edited a memory summary", "medium")
                for md in summaries_dir.glob("*.md"):
                    if self._is_excluded_file(md):
                        continue
                    targets.append((md, memory_class))

        return targets

    @staticmethod
    def _is_excluded_file(path: Path) -> bool:
        """Skip .history/, .archives/, hidden files, and leading-underscore files."""
        parts = path.parts
        if any(p in (".history", ".archives", "_archives", ".quarantine") for p in parts):
            return True
        name = path.name
        if name.startswith((".", "_")):
            return True
        return False

    # Backward-compat alias
    _is_excluded_wiki = _is_excluded_file

    def scan(self) -> int:
        """One scan pass. Returns the number of manual edits detected."""
        from agntspace.vault.writer import VaultWriter

        detected = 0
        current_paths: set[str] = set()

        for path, classification in self._target_files():
            try:
                mtime = path.stat().st_mtime
            except OSError:
                continue

            abs_key = str(path.resolve())
            current_paths.add(abs_key)
            prev = self._known_mtimes.get(abs_key)
            self._known_mtimes[abs_key] = mtime

            if self._first_scan:
                continue  # baseline only — no events on first boot

            if prev is None:
                # New file (created outside API) — treat as manual edit
                self._emit(path, classification, created=True)
                detected += 1
                continue

            if mtime <= prev:
                continue  # unchanged or older (clock skew)

            # File changed — is it an API write?
            if VaultWriter.was_recently_written(abs_key, mtime):
                continue

            self._emit(path, classification, created=False)
            detected += 1

        # Drop stale entries (files deleted)
        for stale in list(self._known_mtimes.keys()):
            if stale not in current_paths:
                self._known_mtimes.pop(stale, None)

        if self._first_scan:
            self._first_scan = False

        self._save_state()
        return detected

    def _emit(
        self,
        path: Path,
        classification: tuple[str, str, str, str],
        *,
        created: bool,
    ) -> None:
        """Send one event to ActivityLogger."""
        if not self._activity:
            return
        category, action, summary, importance = classification

        # Compute a friendly logical path for details
        try:
            if self._paths:
                logical = self._paths.to_logical(path)
            else:
                logical = str(path.relative_to(self._vault_dir))
        except Exception:
            logical = path.name

        details = {
            "path": logical,
            "created": created,
            "detected_via": "vault_edit_watcher",
        }

        try:
            self._activity.log(
                category=category,
                action=action,
                summary=summary,
                details=details,
                importance=importance,
            )
            self._activity.record_user_activity("vault_edit")
        except Exception:
            log.debug("Failed to emit vault edit event for %s", path, exc_info=True)

        # Push-trigger light-dream capture on journal edits so the graph
        # sees user prose within seconds, not hours. The callback owns
        # its own debouncing — we fire on every detected edit.
        if action == "journal_manually_edited" and self._journal_edit_callback:
            try:
                self._journal_edit_callback(path)
            except Exception:
                log.debug("journal_edit_callback failed for %s", path, exc_info=True)
