"""Tests for HookManager lifecycle hooks.

Covers: post_step, pre_compaction, post_compaction, on_complete, failure tracking.
All hooks tested for happy path, graceful degradation, and integration.
Architecture ref: ARCHITECTURE.md Sections 4 (Component 6), 6 (Flow), 8 (Edge Cases).
"""

import logging
import os
import sys

import pytest

from netanel_core.config import NathanConfig
from netanel_core.hooks import HookManager
from netanel_core.memory_store import MemoryStore

_CHMOD_UNRELIABLE = sys.platform == "win32" or (hasattr(os, "getuid") and os.getuid() == 0)


@pytest.fixture
def config(tmp_path):
    """Config with temp directory for isolated tests."""
    return NathanConfig(
        namespace="test_hooks",
        memories_dir=tmp_path / "memories",
    )


@pytest.fixture
def store(config):
    """MemoryStore ready for testing."""
    return MemoryStore(config)


@pytest.fixture
def hooks(store):
    """HookManager ready for testing."""
    return HookManager(store)


@pytest.fixture
def create_step_logs(hooks):
    """Create numbered step log files in hooks.logs_dir.

    Usage: names = create_step_logs(count=N)
    Returns the list of filenames (not paths) written.
    """
    def _factory(count: int) -> list[str]:
        names = [f"20260101T000000_{i:04d}_step.md" for i in range(count)]
        for name in names:
            (hooks.logs_dir / name).write_text("x", encoding="utf-8")
        return names
    return _factory


# ────────────────────────────────────────────
# post_step
# ────────────────────────────────────────────


class TestPostStep:
    """post_step hook: micro-store facts after each ReAct step."""

    def test_writes_file(self, hooks):
        """post_step creates a timestamped step log file."""
        path = hooks.post_step("my task", "step output here")

        assert path is not None
        assert path.exists()
        assert "_step.md" in path.name
        content = path.read_text(encoding="utf-8")
        assert "my task" in content
        assert "step output here" in content

    def test_multiple_steps_create_separate_files(self, hooks):
        """Each post_step call creates a unique file."""
        p1 = hooks.post_step("task", "step 1 output")
        p2 = hooks.post_step("task", "step 2 output")

        assert p1 is not None
        assert p2 is not None
        assert p1 != p2
        assert p1.exists()
        assert p2.exists()

    @pytest.mark.skipif(
        _CHMOD_UNRELIABLE,
        reason="chmod-based test unreliable on Windows or as root",
    )
    def test_graceful_on_write_error(self, hooks):
        """post_step returns None on write failure, never raises."""
        logs = hooks.logs_dir
        os.chmod(logs, 0o444)
        try:
            result = hooks.post_step("task", "output")
            assert result is None
        finally:
            os.chmod(logs, 0o755)


# ────────────────────────────────────────────
# pre_compaction
# ────────────────────────────────────────────


class TestPreCompaction:
    """pre_compaction hook: full save before context compaction."""

    def test_creates_progress_summary(self, hooks, config):
        """pre_compaction writes progress_summary.md to namespace dir."""
        path = hooks.pre_compaction("context here")

        assert path.exists()
        assert path.name == "progress_summary.md"
        content = path.read_text(encoding="utf-8")
        assert "context here" in content

    def test_writes_patterns_via_memorystore(self, hooks, store):
        """Decisions are stored as pattern memories via MemoryStore."""
        hooks.pre_compaction("ctx", decisions=["use file-based approach"])

        memories = store.read_all_memories()
        assert len(memories) >= 1
        assert any("file-based approach" in m.content for m in memories)

    def test_writes_compaction_log(self, hooks):
        """pre_compaction creates a compaction log in logs dir."""
        hooks.pre_compaction("ctx", decisions=["d1"], artifacts=["a1"])

        log_files = list(hooks.logs_dir.glob("*_compaction.md"))
        assert len(log_files) == 1
        content = log_files[0].read_text(encoding="utf-8")
        assert "**Decisions:** 1" in content
        assert "**Artifacts:** 1" in content

    def test_fallback_on_error(self, hooks, config):
        """On error, falls back to raw context dump. Never raises."""
        # Create a directory where progress_summary.md should be a file
        # This causes write_text to fail with IsADirectoryError
        summary = config.namespace_dir / "progress_summary.md"
        summary.mkdir(parents=True, exist_ok=True)
        try:
            result = hooks.pre_compaction("preserve this context")

            assert "fallback" in result.name
            assert result.exists()
            content = result.read_text(encoding="utf-8")
            assert "preserve this context" in content
        finally:
            summary.rmdir()

    def test_sets_progress_summary_path(self, hooks):
        """pre_compaction stores _progress_summary_path for post_compaction."""
        assert hooks._progress_summary_path is None

        hooks.pre_compaction("context")

        assert hooks._progress_summary_path is not None
        assert hooks._progress_summary_path.exists()

    def test_bounds_decisions_and_artifacts(self, hooks, store):
        """pre_compaction enforces MAX_ITEMS on decisions and artifacts."""
        large_decisions = [f"decision {i}" for i in range(150)]
        large_artifacts = [f"artifact {i}" for i in range(150)]

        hooks.pre_compaction("ctx", decisions=large_decisions, artifacts=large_artifacts)

        memories = store.read_all_memories()
        # MAX_ITEMS=100 each → at most 200 memories stored
        assert len(memories) <= HookManager.MAX_ITEMS * 2


# ────────────────────────────────────────────
# post_compaction
# ────────────────────────────────────────────


class TestPostCompaction:
    """post_compaction hook: re-inject knowledge after compaction."""

    def test_reads_memories(self, hooks, store):
        """post_compaction returns all memories from the store."""
        store.write_memory(title="Pattern A", content="content A")

        memories, _ = hooks.post_compaction()

        assert len(memories) >= 1
        assert any(m.title == "Pattern A" for m in memories)

    def test_reads_progress_summary(self, hooks):
        """post_compaction reads progress_summary written by pre_compaction."""
        hooks.pre_compaction("context for summary")

        memories, summary = hooks.post_compaction()

        assert summary is not None
        assert "context for summary" in summary

    def test_graceful_on_no_files(self, hooks):
        """post_compaction returns empty list and None when nothing exists."""
        memories, summary = hooks.post_compaction()

        assert isinstance(memories, list)
        assert summary is None


# ────────────────────────────────────────────
# pre → post integration
# ────────────────────────────────────────────


class TestPrePostIntegration:
    """Integration: data flows from pre_compaction to post_compaction."""

    def test_data_flows_from_pre_to_post(self, hooks, store):
        """Pre saves context + decisions; post reads them back."""
        # Pre-compaction saves everything
        hooks.pre_compaction(
            "task context",
            decisions=["chose approach A"],
            artifacts=["output artifact"],
        )

        # Post-compaction reads it back
        memories, summary = hooks.post_compaction()

        assert summary is not None
        assert "task context" in summary
        # Decisions stored as memories
        assert any("approach A" in m.content for m in memories)


# ────────────────────────────────────────────
# on_complete
# ────────────────────────────────────────────


class TestOnComplete:
    """on_complete hook: final learnings when task finishes."""

    def test_writes_to_patterns(self, hooks, store):
        """on_complete stores the result as a pattern memory."""
        path = hooks.on_complete("build hooks", "all hooks implemented")

        assert path is not None
        assert path.exists()
        memories = store.read_all_memories()
        assert any("all hooks implemented" in m.content for m in memories)

    def test_cleans_up_progress_summary(self, hooks):
        """on_complete deletes progress_summary.md and resets internal state."""
        hooks.pre_compaction("context")
        summary_path = hooks._progress_summary_path
        assert summary_path.exists()

        hooks.on_complete("task", "result")

        assert not summary_path.exists()
        assert hooks._progress_summary_path is None

    @pytest.mark.skipif(
        _CHMOD_UNRELIABLE,
        reason="chmod-based test unreliable on Windows or as root",
    )
    def test_graceful_degradation(self, hooks, config):
        """on_complete returns None on failure, never raises."""
        os.chmod(config.patterns_dir, 0o444)
        try:
            result = hooks.on_complete("task", "result")
            assert result is None
        finally:
            os.chmod(config.patterns_dir, 0o755)


# ────────────────────────────────────────────
# Hook Failure Tracking (BUG-020)
# ────────────────────────────────────────────


class TestHookStats:
    """HookManager failure tracking — BUG-020: silent learning failures made visible."""

    def test_initial_state(self, hooks):
        """stats starts at all-zero, health ok."""
        s = hooks.stats
        assert s["total_calls"] == 0
        assert s["total_failures"] == 0
        assert s["consecutive_failures"] == 0
        assert s["health"] == HookManager.HEALTH_OK

    def test_success_increments_total_calls(self, hooks):
        """Successful hook call increments total_calls."""
        hooks.post_step("task", "output")
        assert hooks.stats["total_calls"] == 1
        assert hooks.stats["total_failures"] == 0

    def test_success_resets_consecutive_failures(self, hooks, config):
        """After a failure, a successful call resets consecutive_failures to 0."""
        # Force a failure, then succeed
        hooks._record_failure("test")
        assert hooks.stats["consecutive_failures"] == 1

        hooks.post_step("task", "output")
        assert hooks.stats["consecutive_failures"] == 0

    def test_failure_increments_all_counters(self, hooks):
        """_record_failure increments total_calls, total_failures, consecutive."""
        hooks._record_failure("test_hook")
        s = hooks.stats
        assert s["total_calls"] == 1
        assert s["total_failures"] == 1
        assert s["consecutive_failures"] == 1

    def test_consecutive_failures_accumulate(self, hooks):
        """Multiple consecutive failures accumulate in consecutive_failures."""
        for _ in range(3):
            hooks._record_failure("test_hook")
        assert hooks.stats["consecutive_failures"] == 3
        assert hooks.stats["total_failures"] == 3

    def test_health_degraded_at_consecutive_failure_alert(self, hooks):
        """health becomes HEALTH_DEGRADED when consecutive_failures >= CONSECUTIVE_FAILURE_ALERT."""
        assert hooks.stats["health"] == HookManager.HEALTH_OK

        for _ in range(HookManager.CONSECUTIVE_FAILURE_ALERT - 1):
            hooks._record_failure("h")
        assert hooks.stats["health"] == HookManager.HEALTH_OK  # below threshold

        hooks._record_failure("h")
        assert hooks.stats["health"] == HookManager.HEALTH_DEGRADED  # at threshold

    def test_alert_fires_at_five_consecutive_failures(self, hooks, caplog):
        """logger.error alert fires when consecutive_failures reaches CONSECUTIVE_FAILURE_ALERT."""
        with caplog.at_level(logging.ERROR, logger="netanel_core.hooks"):
            for _ in range(HookManager.CONSECUTIVE_FAILURE_ALERT - 1):
                hooks._record_failure("hook_x")
            assert "ALERT" not in caplog.text  # No alert yet

            hooks._record_failure("hook_x")  # 5th consecutive failure
            assert "ALERT" in caplog.text
            assert "hook_x" in caplog.text

    def test_alert_repeats_after_threshold(self, hooks, caplog):
        """Alert continues firing (not just once) for every failure after threshold."""
        with caplog.at_level(logging.ERROR, logger="netanel_core.hooks"):
            for _ in range(HookManager.CONSECUTIVE_FAILURE_ALERT + 2):
                hooks._record_failure("hook_y")
            # Should have multiple ERROR log records
            error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
            assert len(error_records) >= 3  # fires at 5, 6, 7

    def test_post_step_failure_tracked(self, hooks, config):
        """post_step failure increments failure counter."""
        # Make logs_dir read-only to force a write failure
        if _CHMOD_UNRELIABLE:
            pytest.skip("chmod unreliable on this platform")
        os.chmod(hooks.logs_dir, 0o444)
        try:
            hooks.post_step("task", "output")
            assert hooks.stats["total_failures"] == 1
            assert hooks.stats["consecutive_failures"] == 1
        finally:
            os.chmod(hooks.logs_dir, 0o755)

    def test_on_complete_failure_tracked(self, hooks, config):
        """on_complete failure increments failure counter."""
        if _CHMOD_UNRELIABLE:
            pytest.skip("chmod unreliable on this platform")
        os.chmod(config.patterns_dir, 0o444)
        try:
            hooks.on_complete("task", "result")
            assert hooks.stats["total_failures"] == 1
        finally:
            os.chmod(config.patterns_dir, 0o755)

    def test_pre_compaction_fallback_tracked_as_failure(self, hooks, config):
        """Fallback activation in pre_compaction is tracked as a failure."""
        summary = config.namespace_dir / "progress_summary.md"
        summary.mkdir(parents=True, exist_ok=True)
        try:
            hooks.pre_compaction("context")
            assert hooks.stats["total_failures"] == 1
        finally:
            summary.rmdir()

    def test_post_compaction_success_tracked(self, hooks):
        """Successful post_compaction call is counted."""
        hooks.post_compaction()
        assert hooks.stats["total_calls"] == 1
        assert hooks.stats["total_failures"] == 0

    def test_mixed_calls_track_correctly(self, hooks):
        """Mix of successes and failures tracked across calls."""
        hooks._record_success()
        hooks._record_success()
        hooks._record_failure("h")
        hooks._record_success()

        assert hooks.stats["total_calls"] == 4
        assert hooks.stats["total_failures"] == 1
        assert hooks.stats["consecutive_failures"] == 0  # Last call was success


# ────────────────────────────────────────────
# Log cleanup (BUG-046)
# ────────────────────────────────────────────


class TestLogCleanup:
    """_cleanup_logs / on_complete: bounds logs_dir growth (JPL Rule 5)."""

    def test_on_complete_trims_logs_over_limit(self, hooks, create_step_logs):
        """on_complete removes oldest log files, keeping the newest MAX_LOG_FILES."""
        limit = 5
        original = hooks.MAX_LOG_FILES
        try:
            hooks.MAX_LOG_FILES = limit
            filenames = create_step_logs(count=limit + 2)

            hooks.on_complete(task="t", result="r")

            # Determine which files should have been kept (newest limit files)
            sorted_names = sorted(filenames)
            expected_kept = set(sorted_names[-limit:])
            expected_deleted = set(sorted_names[:-limit])

            # Assert newest limit files exist
            for name in expected_kept:
                assert (hooks.logs_dir / name).exists(), f"Expected {name} to exist but it was deleted"

            # Assert oldest files were removed
            for name in expected_deleted:
                assert not (hooks.logs_dir / name).exists(), f"Expected {name} to be deleted but it still exists"

            # Confirm total count is within limit
            log_files = [f for f in hooks.logs_dir.glob("*.md") if f.name != "progress_summary.md"]
            assert len(log_files) <= limit
        finally:
            hooks.MAX_LOG_FILES = original

    def test_on_complete_does_not_trim_when_under_limit(self, hooks, create_step_logs):
        """on_complete leaves logs alone when under MAX_LOG_FILES."""
        create_step_logs(count=5)

        hooks.on_complete(task="t", result="r")

        log_files = [f for f in hooks.logs_dir.glob("*.md") if f.name != "progress_summary.md"]
        assert len(log_files) == 5

    def test_cleanup_excludes_progress_summary(self, hooks, create_step_logs):
        """_cleanup_logs never deletes progress_summary.md even when over limit."""
        limit = 10
        original = hooks.MAX_LOG_FILES
        try:
            hooks.MAX_LOG_FILES = limit
            create_step_logs(count=limit + 10)
            # Plant progress_summary.md in logs_dir (defensive guard)
            summary = hooks.logs_dir / "progress_summary.md"
            summary.write_text("preserved", encoding="utf-8")

            hooks.on_complete(task="t", result="r")

            # progress_summary.md must survive cleanup
            assert summary.exists()
            assert summary.read_text(encoding="utf-8") == "preserved"
            # Regular logs were trimmed
            remaining = [f for f in hooks.logs_dir.glob("*.md") if f.name != "progress_summary.md"]
            assert len(remaining) <= limit
        finally:
            hooks.MAX_LOG_FILES = original

    def test_cleanup_logs_error_on_invalid_max_log_files(self, hooks, caplog):
        """_cleanup_logs logs ERROR and returns (no raise) when MAX_LOG_FILES is below minimum (BUG-071)."""
        original = hooks.MAX_LOG_FILES
        try:
            hooks.MAX_LOG_FILES = 0  # below LOG_FILES_MIN=1
            with caplog.at_level(logging.ERROR, logger="netanel_core.hooks"):
                hooks._cleanup_logs()  # must not raise
            assert "MAX_LOG_FILES" in caplog.text
        finally:
            hooks.MAX_LOG_FILES = original

    def test_cleanup_logs_error_on_max_log_files_over_limit(self, hooks, caplog):
        """_cleanup_logs logs ERROR and returns (no raise) when MAX_LOG_FILES exceeds limit (BUG-071)."""
        original = hooks.MAX_LOG_FILES
        try:
            hooks.MAX_LOG_FILES = HookManager.LOG_FILES_MAX_LIMIT + 1
            with caplog.at_level(logging.ERROR, logger="netanel_core.hooks"):
                hooks._cleanup_logs()  # must not raise
            assert "MAX_LOG_FILES" in caplog.text
        finally:
            hooks.MAX_LOG_FILES = original

    def test_on_complete_does_not_raise_when_cleanup_bounds_invalid(self, hooks, tmp_path):
        """on_complete returns None gracefully when MAX_LOG_FILES is out of bounds (BUG-071)."""
        original = hooks.MAX_LOG_FILES
        try:
            hooks.MAX_LOG_FILES = 0  # trigger the bounds guard inside _cleanup_logs
            result = hooks.on_complete("task", "result")  # must not raise
            assert result is None or result is not None  # any value, just no exception
        finally:
            hooks.MAX_LOG_FILES = original

    def test_cleanup_logs_info_when_trimming(self, hooks, create_step_logs, caplog):
        """_cleanup_logs emits INFO when files are trimmed."""
        limit = 10
        original = hooks.MAX_LOG_FILES
        try:
            hooks.MAX_LOG_FILES = limit
            create_step_logs(count=limit + 5)

            with caplog.at_level(logging.INFO, logger="netanel_core.hooks"):
                hooks._cleanup_logs()

            assert "Trimmed" in caplog.text
        finally:
            hooks.MAX_LOG_FILES = original


# ────────────────────────────────────────────
# BUG-065: on_complete content truncation
# BUG-022: write_memory skip when learnings already stored
# ────────────────────────────────────────────


class TestOnCompleteWriteMemory:
    """on_complete: truncation (BUG-065) and write_memory=False guard (BUG-022)."""

    def test_truncates_long_result(self, hooks, store):
        """BUG-065: result longer than MAX_ON_COMPLETE_CONTENT_LENGTH is stored truncated."""
        long_result = "X" * (HookManager.MAX_ON_COMPLETE_CONTENT_LENGTH + 500)
        path = hooks.on_complete(task="task", result=long_result)

        assert path is not None
        assert path.exists()
        memories = store.read_all_memories()
        stored = next(m for m in memories if "Completed:" in m.title)
        assert len(stored.content) <= HookManager.MAX_ON_COMPLETE_CONTENT_LENGTH

    def test_result_within_limit_stored_in_full(self, hooks, store):
        """BUG-065: result <= MAX_ON_COMPLETE_CONTENT_LENGTH is stored verbatim."""
        short_result = "A" * HookManager.MAX_ON_COMPLETE_CONTENT_LENGTH
        hooks.on_complete(task="short task", result=short_result)

        memories = store.read_all_memories()
        stored = next(m for m in memories if "short task" in m.title)
        assert stored.content == short_result

    def test_write_memory_false_skips_memory_write(self, hooks, store):
        """BUG-022: write_memory=False skips the write_memory call — no pattern stored."""
        path = hooks.on_complete(task="no-write task", result="some result", write_memory=False)

        assert path is None
        memories = store.read_all_memories()
        assert not any("no-write task" in m.title for m in memories)

    def test_write_memory_false_still_cleans_up_progress_summary(self, hooks):
        """BUG-022: even with write_memory=False, progress summary cleanup still runs."""
        hooks.pre_compaction("context")
        summary_path = hooks._progress_summary_path
        assert summary_path.exists()

        hooks.on_complete(task="task", result="result", write_memory=False)

        assert not summary_path.exists()
        assert hooks._progress_summary_path is None

    def test_write_memory_false_still_records_success(self, hooks):
        """BUG-022: write_memory=False counts as a success call in stats."""
        hooks.on_complete(task="task", result="result", write_memory=False)
        assert hooks.stats["total_calls"] == 1
        assert hooks.stats["total_failures"] == 0

    def test_write_memory_true_is_default(self, hooks, store):
        """BUG-022: default behaviour (write_memory=True) stores the pattern."""
        hooks.on_complete(task="default task", result="stored content")

        memories = store.read_all_memories()
        assert any("default task" in m.title for m in memories)


# ────────────────────────────────────────────
# BUG-005: Pydantic serialization warning suppressed at import
# ────────────────────────────────────────────


class TestPydanticWarningSuppressed:
    """BUG-005: UserWarning matching 'Expected `none`' is suppressed after import."""

    def test_pydantic_filter_pattern_suppresses_warning(self):
        """BUG-005: the filter pattern from __init__.py suppresses the pydantic noise.

        Pytest resets warnings.filters per-test via catch_warnings, so we cannot
        observe the filter registered at import time.  Instead we verify that the
        *pattern* applied in __init__.py is correct — i.e. applying it inside a
        catch_warnings context silences the 'Expected `none`' UserWarning.
        """
        import warnings

        with warnings.catch_warnings(record=True) as caught:
            # Replicate exactly what __init__.py registers:
            warnings.filterwarnings("ignore", message=r"Expected `none`", category=UserWarning)
            warnings.warn("Expected `none` - serialized value may not be as expected", UserWarning)

        assert not any(
            "Expected `none`" in str(w.message) for w in caught
        ), "Filter pattern does not suppress 'Expected `none`' UserWarning"

    def test_unrelated_user_warning_not_suppressed(self):
        """BUG-005: filter is narrow — unrelated UserWarnings still pass through."""
        import warnings

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            warnings.filterwarnings("ignore", message=r"Expected `none`", category=UserWarning)
            warnings.warn("Some other unrelated warning", UserWarning)

        assert any(
            "Some other unrelated warning" in str(w.message) for w in caught
        ), "Unrelated UserWarning should not be suppressed by BUG-005 filter"
