"""Lifecycle hooks for nathan-core.

Hooks fire at key moments during agent execution to preserve knowledge
across compaction cycles and log progress. They NEVER crash the agent —
graceful degradation is guaranteed.

Architecture ref: ARCHITECTURE.md Sections 4 (Component 6), 6 (Flow), 8 (Edge Cases)
Design decision: HookManager class over plain functions (state sharing, DI, codebase pattern)
"""

from __future__ import annotations

import contextlib
import logging
from datetime import UTC, datetime
from pathlib import Path

from netanel_core.memory_store import Memory, MemoryStore

logger = logging.getLogger(__name__)


class HookManager:
    """Lifecycle hooks for DeepAgent execution.

    Manages four hooks that fire at critical moments:
    - post_step: micro-store facts after each ReAct step
    - pre_compaction: full save before context is compacted
    - post_compaction: re-inject knowledge after compaction
    - on_complete: final learnings when task finishes

    All hooks follow graceful degradation — failures are logged, never propagated.
    pre_compaction has an additional fallback: raw context dump to flat file.

    Directory structure:
        logs/{namespace}/           # Ephemeral operational logs
            {timestamp}_step.md
            {timestamp}_compaction.md
            {timestamp}_fallback.md
        memories/{namespace}/       # Persistent knowledge
            progress_summary.md     # Compaction bridge (temporary)
            patterns/               # Via MemoryStore
    """

    MAX_ITEMS: int = 100
    """Max decisions/artifacts per pre_compaction. JPL Rule 5 — bounded writes."""

    CONSECUTIVE_FAILURE_ALERT: int = 5
    """Number of consecutive failures before logger.error alert fires (BUG-020)."""

    HEALTH_OK: str = "ok"
    """Health status when consecutive failures are below threshold."""

    HEALTH_DEGRADED: str = "degraded"
    """Health status when consecutive failures reach or exceed CONSECUTIVE_FAILURE_ALERT."""

    MAX_LOG_FILES: int = 500
    """Maximum number of log files to retain. Oldest are removed when exceeded."""

    LOG_FILES_MIN: int = 1
    """Minimum allowed value for MAX_LOG_FILES."""

    LOG_FILES_MAX_LIMIT: int = 1000
    """Maximum allowed value for MAX_LOG_FILES."""

    MAX_ON_COMPLETE_CONTENT_LENGTH: int = 500
    """Maximum chars stored by on_complete (BUG-065). Raw results can be 2000-3500 chars;
    cap at 500 to prevent disk bloat while preserving the task summary."""

    def __init__(self, store: MemoryStore) -> None:
        self._store = store
        self._progress_summary_path: Path | None = None
        self._total_calls: int = 0
        self._total_failures: int = 0
        self._consecutive_failures: int = 0

    @property
    def logs_dir(self) -> Path:
        """Namespaced logs directory. Created on first access."""
        path = self._store.config.memories_dir.parent / "logs" / self._store.config.namespace
        path.mkdir(parents=True, exist_ok=True)
        return path

    @property
    def stats(self) -> dict:
        """Hook health statistics (BUG-020 fix).

        Returns:
            Dict with total_calls, total_failures, consecutive_failures, health.
            health is HEALTH_DEGRADED when consecutive_failures >= CONSECUTIVE_FAILURE_ALERT,
            else HEALTH_OK.
        """
        health = (
            self.HEALTH_DEGRADED
            if self._consecutive_failures >= self.CONSECUTIVE_FAILURE_ALERT
            else self.HEALTH_OK
        )
        return {
            "total_calls": self._total_calls,
            "total_failures": self._total_failures,
            "consecutive_failures": self._consecutive_failures,
            "health": health,
        }

    def _record_success(self) -> None:
        """Record a successful hook call — increments calls, resets consecutive."""
        self._total_calls += 1
        self._consecutive_failures = 0

    def _record_failure(self, hook_name: str) -> None:
        """Record a failed hook call — increments all counters, alerts if threshold reached.

        Validates hook_name (logs warning if missing/empty). Clamps counters to non-negative
        and asserts invariants: total_calls >= total_failures, consecutive_failures <= total_failures.
        """
        sanitized_name = hook_name.strip() if isinstance(hook_name, str) else ""
        if not sanitized_name:
            logger.warning(
                "_record_failure called with missing or empty hook_name; "
                "recording failure without name."
            )
            sanitized_name = "<unknown>"

        self._total_calls += 1
        self._total_failures += 1
        self._consecutive_failures += 1

        # Counters start at 0 and only ever increment — no clamping needed.
        # Assert invariants
        assert self._total_calls >= self._total_failures, (
            f"Invariant violated: total_calls ({self._total_calls}) < "
            f"total_failures ({self._total_failures})"
        )
        assert self._consecutive_failures <= self._total_failures, (
            f"Invariant violated: consecutive_failures ({self._consecutive_failures}) > "
            f"total_failures ({self._total_failures})"
        )

        if self._consecutive_failures >= self.CONSECUTIVE_FAILURE_ALERT:
            logger.error(
                "ALERT: %d consecutive hook failures (latest: %s). "
                "Learning may be broken — check disk space and permissions.",
                self._consecutive_failures,
                sanitized_name,
            )

    def post_step(self, task: str, step_output: str) -> Path | None:
        """Micro-store facts after each ReAct step.

        Writes step output to a timestamped log file. If the agent crashes,
        partial work is preserved in these files.

        Args:
            task: The current task description.
            step_output: Output from this ReAct step.

        Returns:
            Path to the written file, or None on failure.
        """
        try:
            timestamp = self._timestamp()
            filepath = self.logs_dir / f"{timestamp}_step.md"
            content = (
                f"# Step Log\n\n"
                f"**Task:** {task}\n"
                f"**Timestamp:** {timestamp}\n\n"
                f"---\n\n"
                f"{step_output}"
            )
            filepath.write_text(content, encoding="utf-8")
            self._record_success()
            return filepath
        except Exception:
            logger.warning("post_step hook failed", exc_info=True)
            self._record_failure("post_step")
            return None

    def pre_compaction(
        self,
        context: str,
        decisions: list[str] | None = None,
        artifacts: list[str] | None = None,
    ) -> Path:
        """Full save before context compaction.

        Writes progress summary, stores patterns, and creates a compaction log.
        On ANY error, falls back to a raw context dump. Never raises.

        The progress_summary.md is stored for post_compaction to re-inject.

        Args:
            context: Full context to preserve.
            decisions: Key decisions made during execution.
            artifacts: Important artifacts or outputs.

        Returns:
            Path to progress_summary.md, or fallback file path on error.
        """
        timestamp = self._timestamp()
        try:
            # Bounded: JPL Rule 5 — prevent runaway writes
            decisions = (decisions or [])[: self.MAX_ITEMS]
            artifacts = (artifacts or [])[: self.MAX_ITEMS]

            # 1. Write progress summary to memories (persists across compaction)
            summary_path = self._store.config.namespace_dir / "progress_summary.md"
            summary_path.parent.mkdir(parents=True, exist_ok=True)
            summary_content = self._build_progress_summary(context, decisions, artifacts, timestamp)
            summary_path.write_text(summary_content, encoding="utf-8")
            self._progress_summary_path = summary_path

            # 2. Store decisions as patterns via MemoryStore
            for decision in decisions:
                self._store.write_memory(
                    title=f"Decision: {decision[:50]}",
                    content=decision,
                    metadata={"source": "pre_compaction", "timestamp": timestamp},
                    source_task="pre_compaction",
                )

            # 3. Store artifacts as patterns via MemoryStore
            for artifact in artifacts:
                self._store.write_memory(
                    title=f"Artifact: {artifact[:50]}",
                    content=artifact,
                    metadata={"source": "pre_compaction", "timestamp": timestamp},
                    source_task="pre_compaction",
                )

            # 4. Write compaction log
            log_path = self.logs_dir / f"{timestamp}_compaction.md"
            log_content = (
                f"# Compaction Log\n\n"
                f"**Timestamp:** {timestamp}\n"
                f"**Decisions:** {len(decisions)}\n"
                f"**Artifacts:** {len(artifacts)}\n\n"
                f"---\n\n"
                f"Progress summary written to: {summary_path}\n"
            )
            log_path.write_text(log_content, encoding="utf-8")

            self._record_success()
            return summary_path

        except Exception:
            # FALLBACK: raw context dump — never silent failure
            logger.warning("pre_compaction fallback activated", exc_info=True)
            self._record_failure("pre_compaction")
            try:
                fallback_path = self.logs_dir / f"{timestamp}_fallback.md"
                fallback_content = (
                    f"# Fallback Dump\n\n"
                    f"**Timestamp:** {timestamp}\n"
                    f"**Reason:** pre_compaction error — raw context preserved\n\n"
                    f"---\n\n"
                    f"{context}"
                )
                fallback_path.write_text(fallback_content, encoding="utf-8")
                self._progress_summary_path = fallback_path
                return fallback_path
            except Exception:
                # Even fallback failed (disk full, permissions)
                # Log and return the intended path — we've done everything we can
                logger.error("pre_compaction fallback write also failed", exc_info=True)
                return self.logs_dir / f"{timestamp}_fallback.md"

    def post_compaction(self) -> tuple[list[Memory], str | None]:
        """Re-inject knowledge after context compaction.

        Reads all memories and the progress summary so the agent can continue
        with full knowledge after compaction.

        Returns:
            Tuple of (all memories, progress summary content or None).
        """
        _had_failure = False

        try:
            memories = self._store.read_all_with_global()
        except Exception:
            logger.warning("post_compaction memory read failed", exc_info=True)
            memories = []
            _had_failure = True

        summary_content = None
        if self._progress_summary_path and self._progress_summary_path.exists():
            try:
                summary_content = self._progress_summary_path.read_text(encoding="utf-8")
            except Exception:
                logger.warning("post_compaction progress summary read failed", exc_info=True)
                _had_failure = True

        if _had_failure:
            self._record_failure("post_compaction")
        else:
            self._record_success()

        return memories, summary_content

    def on_complete(self, task: str, result: str, *, write_memory: bool = True) -> Path | None:
        """Store final learnings when task completes.

        Writes the result as a pattern memory (truncated to MAX_ON_COMPLETE_CONTENT_LENGTH)
        and cleans up the temporary progress_summary.md if it exists.

        Args:
            task: The completed task description.
            result: The final result/output.
            write_memory: When False, skip the write_memory call (BUG-022).
                         Use when LearningLLM already stored extracted learnings,
                         to avoid redundant storage.  Side effects (cleanup,
                         counters, _cleanup_logs) still run regardless.

        Returns:
            Path to the stored memory file, or None when write_memory=False or on failure.
        """
        try:
            path: Path | None = None
            if write_memory:
                # BUG-065: truncate to avoid storing full 2000-3500 char LLM responses
                content = result[: self.MAX_ON_COMPLETE_CONTENT_LENGTH]
                path = self._store.write_memory(
                    title=f"Completed: {task[:50]}",
                    content=content,
                    metadata={"source": "on_complete"},
                    source_task=task,
                )

            # Clean up progress summary (temporary file)
            if self._progress_summary_path and self._progress_summary_path.exists():
                with contextlib.suppress(OSError):
                    self._progress_summary_path.unlink()
                self._progress_summary_path = None

            self._record_success()
            return path
        except Exception:
            logger.warning("on_complete hook failed", exc_info=True)
            self._record_failure("on_complete")
            return None
        finally:
            self._cleanup_logs()

    def _cleanup_logs(self) -> None:
        """Remove oldest log files when exceeding MAX_LOG_FILES.

        Called after on_complete to bound logs_dir growth (JPL Rule 5).
        Removes oldest files first — preserves most recent MAX_LOG_FILES entries.
        """
        if not (self.LOG_FILES_MIN <= self.MAX_LOG_FILES <= self.LOG_FILES_MAX_LIMIT):
            logger.error(
                "MAX_LOG_FILES (%d) out of bounds [%d, %d] — skipping cleanup.",
                self.MAX_LOG_FILES,
                self.LOG_FILES_MIN,
                self.LOG_FILES_MAX_LIMIT,
            )
            return
        try:
            log_files = sorted(
                f for f in self.logs_dir.glob("*.md") if f.name != "progress_summary.md"
            )
            if len(log_files) <= self.MAX_LOG_FILES:
                return
            for filepath in log_files[: len(log_files) - self.MAX_LOG_FILES]:
                with contextlib.suppress(OSError):
                    filepath.unlink()
            logger.info(
                "Trimmed logs_dir from %d to %d files.",
                len(log_files),
                self.MAX_LOG_FILES,
            )
        except Exception:
            logger.warning("_cleanup_logs failed", exc_info=True)

    @staticmethod
    def _timestamp() -> str:
        """Generate a filesystem-safe UTC timestamp."""
        return datetime.now(UTC).strftime("%Y%m%dT%H%M%S_%f")

    @staticmethod
    def _build_progress_summary(
        context: str,
        decisions: list[str],
        artifacts: list[str],
        timestamp: str,
    ) -> str:
        """Build the progress summary markdown content."""
        lines = [
            "# Progress Summary",
            "",
            f"**Created:** {timestamp}",
            "**Task context preserved before compaction.**",
            "",
            "---",
            "",
            "## Context",
            "",
            context,
        ]

        if decisions:
            lines.extend(["", "## Decisions", ""])
            for decision in decisions:
                lines.append(f"- {decision}")

        if artifacts:
            lines.extend(["", "## Artifacts", ""])
            for artifact in artifacts:
                lines.append(f"- {artifact}")

        return "\n".join(lines)
