"""
cortex/brain_graph/watcher.py — Brain Graph Auto-Registration + File Watcher

M10c: Automation layer that keeps brain graph indexes in sync with source files.

Key components:
- ``BrainGraphWatcher``: async file watcher; monitors configured dirs, triggers
  incremental rebuilds on changes, debounces rapid saves.
- ``auto_register``: scan configured directories and register new/changed files
  that are not yet in brain_graph_index.
- ``check_stale``: dream-cycle hook — find graphs whose source files have been
  deleted or changed since last build.

Config keys (from CortexConfig):
    brain_graph_watch_dirs      List[str]  Directories to monitor (default ["docs"])
    brain_graph_include_patterns List[str] Glob patterns to include (default ["*.md","*.txt","*.py"])
    brain_graph_exclude_patterns List[str] Glob patterns to exclude (default ["*.pyc","__pycache__/*"])
    brain_graph_debounce_seconds float     Cooldown per file in seconds (default 5.0)

Usage::

    builder = BrainGraphBuilder(storage, config, llm)
    watcher = BrainGraphWatcher(builder, storage, config)

    # One-off startup scan
    await watcher.auto_register(owner_id="default")

    # Continuous watch (run as background task)
    asyncio.create_task(watcher.watch(owner_id="default"))

    # Dream-cycle hook
    stale_ids = await watcher.check_stale(owner_id="default")
"""
from __future__ import annotations

import asyncio
import fnmatch
import hashlib
import logging
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Set

if TYPE_CHECKING:
    from cortex.brain_graph.builder import BrainGraphBuilder
    from cortex.storage.adapter import StorageAdapter
    from cortex.config import CortexConfig
    from cortex.types import BrainGraphIndex

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_SOURCE_TYPE_MAP: Dict[str, str] = {
    ".md": "file",
    ".markdown": "file",
    ".txt": "file",
    ".py": "file",
    ".js": "file",
    ".ts": "file",
    ".json": "file",
    ".yaml": "file",
    ".yml": "file",
    ".toml": "file",
    ".rst": "file",
}


def _source_type_for(path: Path) -> str:
    """Infer a source_type string from the file extension.

    Returns 'file' for all individual file paths — matches the accepted
    values in BrainGraphBuilder._read_source() ('file', 'directory', 'url').
    """
    return _SOURCE_TYPE_MAP.get(path.suffix.lower(), "file")


def _compute_file_hash(path: Path) -> Optional[str]:
    """Return the SHA-256 hex digest of *path*, or None if unreadable."""
    try:
        h = hashlib.sha256()
        with open(path, "rb") as fh:
            for chunk in iter(lambda: fh.read(65536), b""):
                h.update(chunk)
        return h.hexdigest()
    except OSError:
        return None


def _matches_patterns(path: Path, patterns: List[str]) -> bool:
    """Return True if *path* name matches any of the glob *patterns*."""
    name = path.name
    rel = str(path)
    for pat in patterns:
        if fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel, pat):
            return True
    return False


def _collect_files(
    watch_dirs: List[str],
    include_patterns: List[str],
    exclude_patterns: List[str],
) -> List[Path]:
    """
    Walk *watch_dirs* recursively and return all files that match
    *include_patterns* and do not match *exclude_patterns*.
    """
    results: List[Path] = []
    for raw_dir in watch_dirs:
        base = Path(raw_dir)
        if not base.is_dir():
            logger.debug("watch_dir %s does not exist — skipping", base)
            continue
        for root, dirs, files in os.walk(base):
            root_path = Path(root)
            # Prune excluded directories early
            dirs[:] = [
                d for d in dirs
                if not _matches_patterns(root_path / d, exclude_patterns)
            ]
            for fname in files:
                fpath = root_path / fname
                if _matches_patterns(fpath, exclude_patterns):
                    continue
                if include_patterns and not _matches_patterns(fpath, include_patterns):
                    continue
                results.append(fpath)
    return results


# ---------------------------------------------------------------------------
# BrainGraphWatcher
# ---------------------------------------------------------------------------

class BrainGraphWatcher:
    """
    Async file watcher + auto-registration engine for brain graph indexes.

    All public methods are ``async``.  The ``watch()`` coroutine is designed
    to run as a long-lived ``asyncio`` background task; it never blocks the
    event loop (uses ``asyncio.sleep`` between polls).

    Args:
        builder:  A configured ``BrainGraphBuilder`` instance.
        storage:  The active ``StorageAdapter``.
        config:   ``CortexConfig`` instance (reads brain_graph_* fields).
    """

    def __init__(
        self,
        builder: "BrainGraphBuilder",
        storage: "StorageAdapter",
        config: "CortexConfig",
    ) -> None:
        self._builder = builder
        self._storage = storage
        self._config = config

        # Per-file debounce: path → earliest time we may trigger again
        self._cooldown: Dict[str, float] = {}
        # Track last-seen mtimes for cheap change detection
        self._mtimes: Dict[str, float] = {}
        # Whether the background watch loop is running
        self._running: bool = False
        # Poll interval for the watch loop (seconds)
        self._poll_interval: float = 1.0

    # ------------------------------------------------------------------
    # Config helpers
    # ------------------------------------------------------------------

    @property
    def watch_dirs(self) -> List[str]:
        return getattr(self._config, "brain_graph_watch_dirs", ["docs"])

    @property
    def include_patterns(self) -> List[str]:
        return getattr(
            self._config,
            "brain_graph_include_patterns",
            ["*.md", "*.txt", "*.py"],
        )

    @property
    def exclude_patterns(self) -> List[str]:
        return getattr(
            self._config,
            "brain_graph_exclude_patterns",
            ["*.pyc", "__pycache__/*", "*.egg-info/*", ".git/*"],
        )

    @property
    def debounce_seconds(self) -> float:
        return float(getattr(self._config, "brain_graph_debounce_seconds", 5.0))

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    async def _list_indexed_uris(self, owner_id: str) -> Dict[str, "BrainGraphIndex"]:
        """Return a mapping of source_uri → BrainGraphIndex for *owner_id*."""
        try:
            graphs = await self._storage.list_brain_graphs(owner_id)
        except AttributeError:
            # Fallback: try BrainGraphCRUD if storage method not available
            from cortex.brain_graph.builder import BrainGraphCRUD
            crud = BrainGraphCRUD(self._storage)
            graphs = await crud.list_graphs(owner_id)
        return {g.source_uri: g for g in graphs if g.source_uri}

    def _is_debounced(self, path: str) -> bool:
        """Return True if *path* is still within its debounce window."""
        deadline = self._cooldown.get(path)
        if deadline is None:
            return False
        return time.monotonic() < deadline

    def _set_cooldown(self, path: str) -> None:
        """Record that *path* was just processed; block it for debounce_seconds."""
        self._cooldown[path] = time.monotonic() + self.debounce_seconds

    # ------------------------------------------------------------------
    # Auto-registration
    # ------------------------------------------------------------------

    async def auto_register(self, owner_id: str = "default") -> List[str]:
        """
        Scan configured ``watch_dirs`` and register any file not yet in the
        brain graph index.  Already-indexed files are skipped (by URI + hash).

        Returns:
            List of graph IDs that were newly created during this scan.
        """
        files = _collect_files(
            self.watch_dirs,
            self.include_patterns,
            self.exclude_patterns,
        )
        indexed = await self._list_indexed_uris(owner_id)
        new_ids: List[str] = []

        for fpath in files:
            uri = str(fpath.resolve())
            current_hash = _compute_file_hash(fpath)
            if current_hash is None:
                logger.warning("Cannot read %s — skipping registration", uri)
                continue

            existing = indexed.get(uri)
            if existing is not None:
                # Already indexed — check if hash matches
                if existing.source_hash == current_hash:
                    logger.debug("Already indexed (hash match): %s", uri)
                    continue
                # Hash changed → refresh
                logger.info("Hash changed for %s — refreshing graph %s", uri, existing.id)
                try:
                    refreshed = await self._builder.refresh(existing.id)
                    new_ids.append(refreshed.id)
                except Exception as exc:
                    logger.error("refresh() failed for %s: %s", existing.id, exc)
            else:
                # New file — build graph
                source_type = _source_type_for(fpath)
                name = fpath.stem
                logger.info("Registering new file %s (type=%s)", uri, source_type)
                try:
                    graph = await self._builder.build(
                        source=uri,
                        source_type=source_type,
                        name=name,
                        owner_id=owner_id,
                    )
                    new_ids.append(graph.id)
                except Exception as exc:
                    logger.error("build() failed for %s: %s", uri, exc)

        logger.info(
            "auto_register complete: %d files scanned, %d graphs created/refreshed",
            len(files),
            len(new_ids),
        )
        return new_ids

    # ------------------------------------------------------------------
    # File watcher (continuous)
    # ------------------------------------------------------------------

    async def watch(self, owner_id: str = "default") -> None:
        """
        Continuously monitor ``watch_dirs`` for file changes.

        Polls every ``_poll_interval`` seconds (default 1s) using
        ``asyncio.sleep`` — never blocks the event loop.

        On change (mtime advance + hash change):
        - Apply debounce (skip if within cooldown window)
        - For new files: call ``BrainGraphBuilder.build()``
        - For changed files: call ``BrainGraphBuilder.refresh()``

        This coroutine runs until ``stop()`` is called or the task is
        cancelled.  Designed for use with ``asyncio.create_task()``.
        """
        self._running = True
        logger.info(
            "BrainGraphWatcher starting (dirs=%s, patterns=%s, debounce=%.1fs)",
            self.watch_dirs,
            self.include_patterns,
            self.debounce_seconds,
        )

        # Seed initial mtimes without triggering builds
        for fpath in _collect_files(
            self.watch_dirs, self.include_patterns, self.exclude_patterns
        ):
            try:
                self._mtimes[str(fpath.resolve())] = fpath.stat().st_mtime
            except OSError:
                pass

        while self._running:
            await asyncio.sleep(self._poll_interval)
            await self._poll_once(owner_id)

    async def stop(self) -> None:
        """Signal the watch loop to exit after the current iteration."""
        self._running = False

    async def _poll_once(self, owner_id: str) -> None:
        """Single poll iteration — detect changes and trigger rebuilds."""
        files = _collect_files(
            self.watch_dirs, self.include_patterns, self.exclude_patterns
        )
        indexed = await self._list_indexed_uris(owner_id)
        seen_uris: Set[str] = set()

        for fpath in files:
            uri = str(fpath.resolve())
            seen_uris.add(uri)

            try:
                mtime = fpath.stat().st_mtime
            except OSError:
                continue

            prev_mtime = self._mtimes.get(uri)
            self._mtimes[uri] = mtime

            if prev_mtime is not None and mtime <= prev_mtime:
                continue  # No mtime change — skip hash check

            if self._is_debounced(uri):
                logger.debug("Debouncing %s", uri)
                continue

            current_hash = _compute_file_hash(fpath)
            if current_hash is None:
                continue

            existing = indexed.get(uri)
            if existing is not None:
                if existing.source_hash == current_hash:
                    continue  # Same content despite mtime change (e.g., touch)
                logger.info("Change detected in %s — refreshing graph %s", uri, existing.id)
                self._set_cooldown(uri)
                try:
                    await self._builder.refresh(existing.id)
                except Exception as exc:
                    logger.error("refresh() failed for graph %s: %s", existing.id, exc)
            else:
                # New file
                source_type = _source_type_for(fpath)
                name = fpath.stem
                logger.info("New file detected %s (type=%s) — building graph", uri, source_type)
                self._set_cooldown(uri)
                try:
                    await self._builder.build(
                        source=uri,
                        source_type=source_type,
                        name=name,
                        owner_id=owner_id,
                    )
                except Exception as exc:
                    logger.error("build() failed for %s: %s", uri, exc)

    # ------------------------------------------------------------------
    # Dream cycle hook
    # ------------------------------------------------------------------

    async def check_stale(self, owner_id: str = "default") -> List[str]:
        """
        Dream-cycle hook — scan all active brain graph indexes and detect stale ones.

        A graph is *stale* if:
        - Its ``source_uri`` points to a file that no longer exists, OR
        - The current file hash differs from the stored ``source_hash``.

        For deleted/moved files: marks graph status='stale' in storage
        (does NOT auto-delete).

        Returns:
            List of graph IDs that are stale and should be queued for rebuild
            by the dream cycle.
        """
        try:
            graphs = await self._storage.list_brain_graphs(owner_id)
        except AttributeError:
            from cortex.brain_graph.builder import BrainGraphCRUD
            crud = BrainGraphCRUD(self._storage)
            graphs = await crud.list_graphs(owner_id)

        stale_ids: List[str] = []

        for graph in graphs:
            if graph.status not in ("active",):
                continue
            if not graph.source_uri:
                continue

            uri = graph.source_uri
            fpath = Path(uri)

            # Skip non-file URIs (URLs, etc.)
            if uri.startswith("http://") or uri.startswith("https://"):
                continue

            if not fpath.exists():
                logger.info(
                    "Source file deleted/moved for graph %s (%s) — marking stale",
                    graph.id,
                    uri,
                )
                await self._mark_stale(graph.id)
                stale_ids.append(graph.id)
                continue

            current_hash = _compute_file_hash(fpath)
            if current_hash is None:
                logger.warning("Cannot read source %s for graph %s", uri, graph.id)
                continue

            if graph.source_hash and current_hash != graph.source_hash:
                logger.info(
                    "Hash mismatch for graph %s (%s) — marking stale",
                    graph.id,
                    uri,
                )
                stale_ids.append(graph.id)

        logger.info(
            "check_stale complete for owner=%s: %d/%d graphs stale",
            owner_id,
            len(stale_ids),
            len(graphs),
        )
        return stale_ids

    async def _mark_stale(self, graph_id: str) -> None:
        """Update graph status to 'stale' in storage."""
        try:
            await self._storage.update_brain_graph(graph_id, status="stale")
        except AttributeError:
            try:
                from cortex.brain_graph.builder import BrainGraphCRUD
                crud = BrainGraphCRUD(self._storage)
                await crud.update_graph(graph_id, status="stale")
            except Exception as exc:
                logger.warning("Could not mark graph %s as stale: %s", graph_id, exc)

    # ------------------------------------------------------------------
    # Manual refresh
    # ------------------------------------------------------------------

    async def refresh_graph(self, graph_id: str) -> "BrainGraphIndex":
        """
        Manually trigger a refresh for a specific graph by ID.

        This is the CLI/API hook for on-demand rebuilds.  Unlike the
        automatic watcher, this bypasses debounce and hash checks.

        Returns:
            The updated ``BrainGraphIndex`` after rebuild.
        """
        logger.info("Manual refresh requested for graph %s", graph_id)
        return await self._builder.refresh(graph_id)
