"""
tests/test_brain_graph_watcher.py — Tests for M10c: BrainGraphWatcher

Covers:
- auto_register(): new file detection, hash-dedup skip, changed file refresh
- watch() / _poll_once(): mtime-based change detection, debounce
- check_stale(): deleted files, hash mismatch, URL skip
- refresh_graph(): manual trigger
- Config field defaults on CortexConfig
- _collect_files() / _compute_file_hash() / _matches_patterns() helpers
"""
from __future__ import annotations

import asyncio
import hashlib
import os
import time
from pathlib import Path
from typing import Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
import pytest_asyncio

from cortex.brain_graph.watcher import (
    BrainGraphWatcher,
    _collect_files,
    _compute_file_hash,
    _matches_patterns,
    _source_type_for,
)
from cortex.config import CortexConfig
from cortex.types import BrainGraphIndex


# ---------------------------------------------------------------------------
# Helpers / Factories
# ---------------------------------------------------------------------------

def _make_graph(
    graph_id: str,
    source_uri: str,
    source_hash: Optional[str] = None,
    status: str = "active",
    owner_id: str = "default",
) -> BrainGraphIndex:
    return BrainGraphIndex(
        id=graph_id,
        owner_id=owner_id,
        name="test-graph",
        source_type="file",
        source_uri=source_uri,
        source_hash=source_hash,
        status=status,
    )


def _make_storage(graphs: List[BrainGraphIndex]) -> MagicMock:
    storage = MagicMock()
    storage.list_brain_graphs = AsyncMock(return_value=graphs)
    storage.update_brain_graph = AsyncMock(return_value=None)
    return storage


def _make_builder(build_result: Optional[BrainGraphIndex] = None,
                  refresh_result: Optional[BrainGraphIndex] = None) -> MagicMock:
    builder = MagicMock()
    builder.build = AsyncMock(return_value=build_result or _make_graph("new-id", "x"))
    builder.refresh = AsyncMock(return_value=refresh_result or _make_graph("ref-id", "x"))
    return builder


def _default_config(**overrides) -> CortexConfig:
    cfg = CortexConfig()
    for k, v in overrides.items():
        setattr(cfg, k, v)
    return cfg


# ---------------------------------------------------------------------------
# Helper unit tests
# ---------------------------------------------------------------------------

class TestHelpers:
    def test_source_type_markdown(self, tmp_path):
        assert _source_type_for(tmp_path / "README.md") == "file"

    def test_source_type_python(self, tmp_path):
        assert _source_type_for(tmp_path / "module.py") == "file"

    def test_source_type_text(self, tmp_path):
        assert _source_type_for(tmp_path / "notes.txt") == "file"

    def test_source_type_unknown(self, tmp_path):
        assert _source_type_for(tmp_path / "binary.bin") == "file"  # fallback — all files are "file"

    def test_compute_hash_consistent(self, tmp_path):
        f = tmp_path / "doc.md"
        f.write_text("hello world")
        h1 = _compute_file_hash(f)
        h2 = _compute_file_hash(f)
        assert h1 == h2
        assert len(h1) == 64  # SHA-256 hex

    def test_compute_hash_changes_with_content(self, tmp_path):
        f = tmp_path / "doc.md"
        f.write_text("hello")
        h1 = _compute_file_hash(f)
        f.write_text("world")
        h2 = _compute_file_hash(f)
        assert h1 != h2

    def test_compute_hash_missing_file(self, tmp_path):
        result = _compute_file_hash(tmp_path / "nonexistent.md")
        assert result is None

    def test_matches_patterns_name_glob(self, tmp_path):
        assert _matches_patterns(tmp_path / "README.md", ["*.md"])
        assert not _matches_patterns(tmp_path / "README.md", ["*.py"])

    def test_matches_patterns_no_patterns(self, tmp_path):
        assert not _matches_patterns(tmp_path / "README.md", [])

    def test_collect_files_basic(self, tmp_path):
        (tmp_path / "a.md").write_text("# A")
        (tmp_path / "b.txt").write_text("hello")
        (tmp_path / "c.pyc").write_bytes(b"\x00")
        files = _collect_files(
            [str(tmp_path)],
            ["*.md", "*.txt"],
            ["*.pyc"],
        )
        names = {f.name for f in files}
        assert "a.md" in names
        assert "b.txt" in names
        assert "c.pyc" not in names

    def test_collect_files_recursive(self, tmp_path):
        sub = tmp_path / "sub"
        sub.mkdir()
        (sub / "nested.md").write_text("# nested")
        files = _collect_files([str(tmp_path)], ["*.md"], [])
        names = {f.name for f in files}
        assert "nested.md" in names

    def test_collect_files_missing_dir(self):
        files = _collect_files(["/nonexistent/path"], ["*.md"], [])
        assert files == []

    def test_collect_files_exclude_dir(self, tmp_path):
        cache = tmp_path / "__pycache__"
        cache.mkdir()
        (cache / "mod.pyc").write_bytes(b"\x00")
        files = _collect_files([str(tmp_path)], [], ["__pycache__/*", "*.pyc"])
        names = {f.name for f in files}
        assert "mod.pyc" not in names


# ---------------------------------------------------------------------------
# CortexConfig fields
# ---------------------------------------------------------------------------

class TestCortexConfigBrainGraphFields:
    def test_default_watch_dirs(self):
        cfg = CortexConfig()
        assert cfg.brain_graph_watch_dirs == ["docs"]

    def test_default_include_patterns(self):
        cfg = CortexConfig()
        assert "*.md" in cfg.brain_graph_include_patterns
        assert "*.txt" in cfg.brain_graph_include_patterns
        assert "*.py" in cfg.brain_graph_include_patterns

    def test_default_exclude_patterns(self):
        cfg = CortexConfig()
        assert "*.pyc" in cfg.brain_graph_exclude_patterns

    def test_default_debounce(self):
        cfg = CortexConfig()
        assert cfg.brain_graph_debounce_seconds == 5.0

    def test_custom_values(self):
        cfg = CortexConfig(
            brain_graph_watch_dirs=["notes", "wiki"],
            brain_graph_include_patterns=["*.rst"],
            brain_graph_debounce_seconds=10.0,
        )
        assert cfg.brain_graph_watch_dirs == ["notes", "wiki"]
        assert cfg.brain_graph_include_patterns == ["*.rst"]
        assert cfg.brain_graph_debounce_seconds == 10.0


# ---------------------------------------------------------------------------
# BrainGraphWatcher — property delegation
# ---------------------------------------------------------------------------

class TestWatcherConfig:
    def test_watch_dirs_from_config(self):
        cfg = _default_config(brain_graph_watch_dirs=["notes"])
        w = BrainGraphWatcher(MagicMock(), MagicMock(), cfg)
        assert w.watch_dirs == ["notes"]

    def test_debounce_from_config(self):
        cfg = _default_config(brain_graph_debounce_seconds=10.0)
        w = BrainGraphWatcher(MagicMock(), MagicMock(), cfg)
        assert w.debounce_seconds == 10.0

    def test_defaults_without_attrs(self):
        # Plain CortexConfig should expose defaults through watcher
        cfg = CortexConfig()
        w = BrainGraphWatcher(MagicMock(), MagicMock(), cfg)
        assert w.watch_dirs == ["docs"]
        assert "*.md" in w.include_patterns
        assert w.debounce_seconds == 5.0


# ---------------------------------------------------------------------------
# auto_register()
# ---------------------------------------------------------------------------

class TestAutoRegister:
    @pytest.mark.asyncio
    async def test_registers_new_file(self, tmp_path):
        (tmp_path / "new.md").write_text("# Hello")
        storage = _make_storage([])  # nothing indexed yet
        builder = _make_builder(build_result=_make_graph("g1", str(tmp_path / "new.md")))
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        new_ids = await watcher.auto_register("default")
        assert "g1" in new_ids
        builder.build.assert_called_once()

    @pytest.mark.asyncio
    async def test_skips_already_indexed_same_hash(self, tmp_path):
        f = tmp_path / "doc.md"
        f.write_text("stable content")
        file_hash = _compute_file_hash(f)
        graph = _make_graph("existing", str(f.resolve()), source_hash=file_hash)
        storage = _make_storage([graph])
        builder = _make_builder()
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        new_ids = await watcher.auto_register("default")
        assert new_ids == []
        builder.build.assert_not_called()
        builder.refresh.assert_not_called()

    @pytest.mark.asyncio
    async def test_refreshes_changed_file(self, tmp_path):
        f = tmp_path / "doc.md"
        f.write_text("new content")
        current_hash = _compute_file_hash(f)
        old_hash = "deadbeefdeadbeef" * 4  # wrong hash
        graph = _make_graph("g-old", str(f.resolve()), source_hash=old_hash)
        storage = _make_storage([graph])
        builder = _make_builder(refresh_result=_make_graph("g-old", str(f.resolve())))
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        new_ids = await watcher.auto_register("default")
        builder.refresh.assert_called_once_with("g-old")
        builder.build.assert_not_called()

    @pytest.mark.asyncio
    async def test_handles_build_exception(self, tmp_path):
        (tmp_path / "err.md").write_text("content")
        storage = _make_storage([])
        builder = _make_builder()
        builder.build = AsyncMock(side_effect=RuntimeError("LLM timeout"))
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        new_ids = await watcher.auto_register("default")
        assert new_ids == []  # error swallowed, not raised

    @pytest.mark.asyncio
    async def test_excludes_patterns(self, tmp_path):
        (tmp_path / "include.md").write_text("yes")
        (tmp_path / "skip.pyc").write_bytes(b"\x00")
        storage = _make_storage([])
        builder = _make_builder(build_result=_make_graph("g1", "include.md"))
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=["*.pyc"],
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        await watcher.auto_register("default")
        # build called once only (for the .md file)
        assert builder.build.call_count == 1
        call_kwargs = builder.build.call_args
        assert "include.md" in str(call_kwargs)


# ---------------------------------------------------------------------------
# check_stale()
# ---------------------------------------------------------------------------

class TestCheckStale:
    @pytest.mark.asyncio
    async def test_stale_on_deleted_file(self, tmp_path):
        missing = str(tmp_path / "gone.md")  # does not exist
        graph = _make_graph("g-del", missing, source_hash="abc123")
        storage = _make_storage([graph])
        storage.update_brain_graph = AsyncMock(return_value=None)
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(_make_builder(), storage, cfg)
        stale = await watcher.check_stale("default")
        assert "g-del" in stale
        storage.update_brain_graph.assert_called_once_with("g-del", status="stale")

    @pytest.mark.asyncio
    async def test_stale_on_hash_mismatch(self, tmp_path):
        f = tmp_path / "changed.md"
        f.write_text("new content")
        current_hash = _compute_file_hash(f)
        old_hash = "old" + "0" * 61
        graph = _make_graph("g-changed", str(f.resolve()), source_hash=old_hash)
        storage = _make_storage([graph])
        storage.update_brain_graph = AsyncMock(return_value=None)
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(_make_builder(), storage, cfg)
        stale = await watcher.check_stale("default")
        assert "g-changed" in stale

    @pytest.mark.asyncio
    async def test_not_stale_on_hash_match(self, tmp_path):
        f = tmp_path / "stable.md"
        f.write_text("unchanged")
        current_hash = _compute_file_hash(f)
        graph = _make_graph("g-ok", str(f.resolve()), source_hash=current_hash)
        storage = _make_storage([graph])
        storage.update_brain_graph = AsyncMock(return_value=None)
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(_make_builder(), storage, cfg)
        stale = await watcher.check_stale("default")
        assert "g-ok" not in stale

    @pytest.mark.asyncio
    async def test_skips_non_active_graphs(self, tmp_path):
        graph = _make_graph("g-building", "/some/path.md", source_hash="abc", status="building")
        storage = _make_storage([graph])
        storage.update_brain_graph = AsyncMock(return_value=None)
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(_make_builder(), storage, cfg)
        stale = await watcher.check_stale("default")
        assert "g-building" not in stale

    @pytest.mark.asyncio
    async def test_skips_url_sources(self):
        graph = _make_graph("g-url", "https://example.com/docs", source_hash="abc")
        storage = _make_storage([graph])
        storage.update_brain_graph = AsyncMock(return_value=None)
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(_make_builder(), storage, cfg)
        stale = await watcher.check_stale("default")
        assert "g-url" not in stale

    @pytest.mark.asyncio
    async def test_skips_graphs_without_source_uri(self):
        graph = _make_graph("g-no-uri", "", source_hash="abc")
        graph.source_uri = None
        storage = _make_storage([graph])
        storage.update_brain_graph = AsyncMock(return_value=None)
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(_make_builder(), storage, cfg)
        stale = await watcher.check_stale("default")
        assert "g-no-uri" not in stale

    @pytest.mark.asyncio
    async def test_returns_empty_when_no_graphs(self):
        storage = _make_storage([])
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(_make_builder(), storage, cfg)
        stale = await watcher.check_stale("default")
        assert stale == []


# ---------------------------------------------------------------------------
# Debounce
# ---------------------------------------------------------------------------

class TestDebounce:
    def test_is_debounced_fresh(self):
        cfg = _default_config(brain_graph_debounce_seconds=5.0)
        w = BrainGraphWatcher(MagicMock(), MagicMock(), cfg)
        assert not w._is_debounced("/some/path.md")

    def test_set_then_is_debounced(self):
        cfg = _default_config(brain_graph_debounce_seconds=60.0)
        w = BrainGraphWatcher(MagicMock(), MagicMock(), cfg)
        w._set_cooldown("/some/path.md")
        assert w._is_debounced("/some/path.md")

    def test_debounce_expires(self):
        cfg = _default_config(brain_graph_debounce_seconds=0.01)
        w = BrainGraphWatcher(MagicMock(), MagicMock(), cfg)
        w._set_cooldown("/some/path.md")
        time.sleep(0.05)
        assert not w._is_debounced("/some/path.md")


# ---------------------------------------------------------------------------
# _poll_once() — watcher core
# ---------------------------------------------------------------------------

class TestPollOnce:
    @pytest.mark.asyncio
    async def test_detects_new_file(self, tmp_path):
        """A file with no mtime history triggers build()."""
        f = tmp_path / "new.md"
        f.write_text("# new doc")
        storage = _make_storage([])
        builder = _make_builder(build_result=_make_graph("g1", str(f.resolve())))
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
            brain_graph_debounce_seconds=0.0,
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        # Seed prev_mtime = 0 so mtime > prev triggers
        watcher._mtimes[str(f.resolve())] = 0.0
        await watcher._poll_once("default")
        builder.build.assert_called_once()

    @pytest.mark.asyncio
    async def test_debounces_rapid_changes(self, tmp_path):
        """Second poll within debounce window does NOT trigger rebuild."""
        f = tmp_path / "rapid.md"
        f.write_text("content")
        storage = _make_storage([])
        builder = _make_builder(build_result=_make_graph("g1", str(f.resolve())))
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
            brain_graph_debounce_seconds=60.0,
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        watcher._mtimes[str(f.resolve())] = 0.0
        # First poll — triggers build
        await watcher._poll_once("default")
        # Second poll — same file in cooldown
        watcher._mtimes[str(f.resolve())] = 0.0  # reset to trigger mtime check again
        await watcher._poll_once("default")
        assert builder.build.call_count == 1  # only called once

    @pytest.mark.asyncio
    async def test_refreshes_changed_indexed_file(self, tmp_path):
        """File change triggers refresh() for already-indexed graph."""
        f = tmp_path / "indexed.md"
        f.write_text("updated content")
        current_hash = _compute_file_hash(f)
        old_hash = "stale" + "0" * 59
        graph = _make_graph("g-indexed", str(f.resolve()), source_hash=old_hash)
        storage = _make_storage([graph])
        builder = _make_builder(refresh_result=graph)
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
            brain_graph_debounce_seconds=0.0,
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        watcher._mtimes[str(f.resolve())] = 0.0
        await watcher._poll_once("default")
        builder.refresh.assert_called_once_with("g-indexed")

    @pytest.mark.asyncio
    async def test_no_trigger_on_same_hash(self, tmp_path):
        """No rebuild when mtime changes but hash is the same (e.g. touch)."""
        f = tmp_path / "touched.md"
        f.write_text("same content")
        current_hash = _compute_file_hash(f)
        graph = _make_graph("g-touch", str(f.resolve()), source_hash=current_hash)
        storage = _make_storage([graph])
        builder = _make_builder()
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
            brain_graph_debounce_seconds=0.0,
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        watcher._mtimes[str(f.resolve())] = 0.0
        await watcher._poll_once("default")
        builder.build.assert_not_called()
        builder.refresh.assert_not_called()


# ---------------------------------------------------------------------------
# watch() — start/stop lifecycle
# ---------------------------------------------------------------------------

class TestWatchLifecycle:
    @pytest.mark.asyncio
    async def test_watch_stops_on_stop(self, tmp_path):
        """watch() exits cleanly when stop() is called."""
        storage = _make_storage([])
        builder = _make_builder()
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
            brain_graph_debounce_seconds=5.0,
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        watcher._poll_interval = 0.01

        task = asyncio.create_task(watcher.watch("default"))
        await asyncio.sleep(0.05)
        await watcher.stop()
        await asyncio.wait_for(task, timeout=1.0)
        assert not watcher._running

    @pytest.mark.asyncio
    async def test_watch_seeds_initial_mtimes(self, tmp_path):
        """Files present at startup are seeded in _mtimes without triggering builds."""
        (tmp_path / "existing.md").write_text("# existing")
        storage = _make_storage([])
        builder = _make_builder()
        cfg = _default_config(
            brain_graph_watch_dirs=[str(tmp_path)],
            brain_graph_include_patterns=["*.md"],
            brain_graph_exclude_patterns=[],
        )
        watcher = BrainGraphWatcher(builder, storage, cfg)
        watcher._poll_interval = 0.01

        task = asyncio.create_task(watcher.watch("default"))
        await asyncio.sleep(0.05)
        await watcher.stop()
        await asyncio.wait_for(task, timeout=1.0)

        # Seeded — no builds triggered for pre-existing files
        builder.build.assert_not_called()


# ---------------------------------------------------------------------------
# refresh_graph() — manual hook
# ---------------------------------------------------------------------------

class TestRefreshGraph:
    @pytest.mark.asyncio
    async def test_refresh_graph_calls_builder(self):
        expected = _make_graph("g-refreshed", "/docs/plan.md")
        builder = _make_builder(refresh_result=expected)
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(builder, _make_storage([]), cfg)
        result = await watcher.refresh_graph("g-refreshed")
        builder.refresh.assert_called_once_with("g-refreshed")
        assert result.id == "g-refreshed"

    @pytest.mark.asyncio
    async def test_refresh_graph_propagates_exception(self):
        builder = _make_builder()
        builder.refresh = AsyncMock(side_effect=ValueError("Graph not found"))
        cfg = CortexConfig()
        watcher = BrainGraphWatcher(builder, _make_storage([]), cfg)
        with pytest.raises(ValueError, match="Graph not found"):
            await watcher.refresh_graph("nonexistent")
