"""Tool selection system for netanel-core.

Learns which tools work best by logging call results and scoring by success rate.
Passive scoring engine — provides signals for autonomous tool acquisition in Step 10.

Architecture ref: ARCHITECTURE.md Section 5b (Tool Selection), Section 11 (Tool Registry),
Section 13 Step 9.

Design decisions:
- Sliding window scoring: last N calls per tool (JPL bounded input)
- Cold start: configurable default score (0.5) for unknown tools
- In-memory caching: load scores on init, persist on change
- Count-based compaction: cap history at configurable max entries
- Direct file I/O: tool_scores.json + tool_history.jsonl (not MemoryStore)
- Graceful degradation: every method catches errors, returns defaults, never crashes
- Ordering only: scores influence tool ordering, never remove tools
"""

from __future__ import annotations

import contextlib
import json
import logging
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any

from filelock import FileLock
from netanel_core._atomic import atomic_write_json, atomic_write_text

from netanel_core.config import NathanConfig

logger = logging.getLogger(__name__)


# ────────────────────────────────────────────
# Data Types
# ────────────────────────────────────────────


@dataclass
class ToolCallRecord:
    """Single tool call event — one line in tool_history.jsonl.

    Matches the schema agreed at Gate 1 (with error_message refinement).
    """

    tool: str
    """Tool name."""

    timestamp: str
    """ISO-8601 timestamp."""

    success: bool
    """Whether the call succeeded."""

    task_type: str
    """What kind of task (e.g., 'code_review', 'general')."""

    duration_ms: int
    """How long the call took in milliseconds."""

    error_message: str | None = None
    """Error details on failure, None on success."""

    def to_dict(self) -> dict[str, Any]:
        """Serialize to JSON-compatible dict for JSONL storage."""
        return {
            "tool": self.tool,
            "timestamp": self.timestamp,
            "success": self.success,
            "task_type": self.task_type,
            "duration_ms": self.duration_ms,
            "error_message": self.error_message,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ToolCallRecord:
        """Deserialize from a JSON dict (one JSONL line)."""
        return cls(
            tool=str(data.get("tool", "")),
            timestamp=str(data.get("timestamp", "")),
            success=bool(data.get("success", False)),
            task_type=str(data.get("task_type", "general")),
            duration_ms=int(data.get("duration_ms", 0)),
            error_message=data.get("error_message"),
        )


@dataclass
class ToolScore:
    """Scoring stats for a single tool.

    Computed from the sliding window of recent calls.
    """

    score: float
    """Success rate (0.0 to 1.0)."""

    total_calls: int
    """Lifetime total calls."""

    recent_successes: int
    """Successes within the scoring window."""

    recent_total: int
    """Total calls within the scoring window."""

    last_updated: str
    """ISO-8601 timestamp of last update."""

    def to_dict(self) -> dict[str, Any]:
        """Serialize to JSON-compatible dict."""
        return {
            "score": self.score,
            "total_calls": self.total_calls,
            "recent_successes": self.recent_successes,
            "recent_total": self.recent_total,
            "last_updated": self.last_updated,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ToolScore:
        """Deserialize from a JSON dict."""
        raw_score = float(data.get("score", 0.5))
        return cls(
            score=max(0.0, min(1.0, raw_score)),
            total_calls=int(data.get("total_calls", 0)),
            recent_successes=int(data.get("recent_successes", 0)),
            recent_total=int(data.get("recent_total", 0)),
            last_updated=str(data.get("last_updated", "")),
        )


@dataclass
class ScoredTool:
    """Tool name + score — returned by get_scored_tools().

    Simple container for ordering results.
    """

    name: str
    """Tool name."""

    score: float
    """Current score (0.0 to 1.0)."""


# ────────────────────────────────────────────
# ToolSelector
# ────────────────────────────────────────────


class ToolSelector:
    """Learns tool preferences from call history.

    Logs every tool call result, scores tools by success rate using a sliding
    window, and returns tools ordered by score (best first). Pure file I/O + math,
    no LLM calls.

    Cold start: tools with no history get a configurable default score (0.5).
    This ensures new tools are tried rather than ignored.

    Signals for Step 10 (autonomous acquisition):
    - get_failing_tools() surfaces tools that consistently fail
    - DeepAgent can read these signals and trigger tool discovery

    Args:
        config: NathanConfig (single source of truth).
    """

    def __init__(self, config: NathanConfig) -> None:
        self._config = config
        self._scores: dict[str, ToolScore] = self._read_scores()
        # BUG-023: in-memory buffer — avoids re-reading disk on every log_tool_call.
        # Buffer and counters are populated once on init and updated incrementally.
        window = self._config.tool_selector.scoring_window
        self._history_buffer: dict[str, deque[bool]]
        _history_counts: dict[str, int]
        self._history_buffer, _history_counts = self._build_history_buffer(window)
        self._history_line_count: int = self._count_history_lines()
        # Per-tool total call count (authoritative, BUG-021 + BUG-023).
        # Seed from scores first; fall back to history-derived counts when
        # tool_scores.json is missing/corrupt (BUG-023 R2).
        self._total_calls: dict[str, int] = {
            tool: score.total_calls for tool, score in self._scores.items()
        }
        for tool, count in _history_counts.items():
            self._total_calls.setdefault(tool, count)

    # ────────────────────────────────────────
    # Public API
    # ────────────────────────────────────────

    def log_tool_call(
        self,
        tool: str,
        success: bool,
        task_type: str = "general",
        duration_ms: int = 0,
        error_message: str | None = None,
    ) -> None:
        """Log a tool call result and recalculate the tool's score.

        Appends to tool_history.jsonl, recalculates from the sliding window,
        updates in-memory cache, and persists to tool_scores.json.

        Args:
            tool: Tool name.
            success: Whether the call succeeded.
            task_type: What kind of task (default: 'general').
            duration_ms: Call duration in milliseconds.
            error_message: Error details on failure.
        """
        # Validate inputs fail-fast (programmer errors) — BEFORE the I/O try/except.
        # isinstance guards prevent AttributeError when callers pass None or non-string.
        # Matches the pattern used in call() and register_tool(): public APIs raise
        # immediately; try/except only wraps I/O and external operations.
        if not isinstance(tool, str) or not tool.strip():
            raise ValueError("tool must be a non-empty string")
        if not isinstance(task_type, str) or not task_type.strip():
            raise ValueError("task_type must be a non-empty string")
        if not isinstance(success, bool):
            raise ValueError("success must be a bool")
        if not isinstance(duration_ms, int | float) or duration_ms < 0:
            raise ValueError("duration_ms must be a non-negative number")

        try:
            record = ToolCallRecord(
                tool=tool,
                timestamp=datetime.now(UTC).isoformat(),
                success=success,
                task_type=task_type,
                duration_ms=duration_ms,
                error_message=error_message,
            )
            self._append_history(record)
            self._recalculate_score(tool)
            self._write_scores()
            self._trim_history()
        except Exception:
            logger.warning(
                "Failed to log tool call for '%s'. Continuing without logging.",
                tool,
                exc_info=True,
            )

    def get_scored_tools(self, available_tools: list[str] | None = None) -> list[ScoredTool]:
        """Return tools ordered by score, best first.

        Stable sort — tools with the same score maintain their relative order.
        Tools with no history get the configured default score.

        Args:
            available_tools: List of tool names to score. If None, uses
                config.tools (registered tools for this namespace).

        Returns:
            List of ScoredTool, sorted by score descending.
        """
        try:
            tools = available_tools if available_tools is not None else self._config.tools
            default = self._config.tool_selector.default_score

            scored = [
                ScoredTool(
                    name=t,
                    score=self._scores[t].score if t in self._scores else default,
                )
                for t in tools
            ]
            # Stable sort — same-score tools keep original order
            scored.sort(key=lambda s: s.score, reverse=True)
            return scored
        except Exception:
            logger.warning(
                "Failed to get scored tools. Returning defaults.",
                exc_info=True,
            )
            tools = available_tools if available_tools is not None else self._config.tools
            default = self._config.tool_selector.default_score
            return [ScoredTool(name=t, score=default) for t in tools]

    def get_tool_score(self, tool: str) -> float:
        """Get score for a single tool.

        Returns the configured default score if the tool has no history.

        Args:
            tool: Tool name.

        Returns:
            Score between 0.0 and 1.0.
        """
        if tool in self._scores:
            return self._scores[tool].score
        return self._config.tool_selector.default_score

    def get_failing_tools(self, threshold: float | None = None) -> list[str]:
        """Get tools that are consistently failing.

        Signal method for Step 10 — surfaces tool inadequacy so DeepAgent
        can trigger autonomous tool discovery/acquisition.

        Args:
            threshold: Score threshold. Tools below this are considered failing.
                Defaults to config.tool_selector.failing_threshold (0.3).

        Returns:
            List of tool names with score < threshold.
        """
        if threshold is None:
            threshold = self._config.tool_selector.failing_threshold
        return [name for name, score in self._scores.items() if score.score < threshold]

    def clear_history(self) -> None:
        """Clear all tool history and scores. Useful for testing/reset.

        Removes both tool_history.jsonl and tool_scores.json, and resets
        in-memory state.
        """
        self._scores = {}
        history_path = self._config.namespace_dir / "tool_history.jsonl"
        scores_path = self._config.namespace_dir / "tool_scores.json"
        with contextlib.suppress(OSError):
            history_path.unlink()
        with contextlib.suppress(OSError):
            scores_path.unlink()
        # Reinitialize in-memory state to match cleared disk state (BUG-023 Gate 9 R1)
        # Direct assignment — no need to call _build_history_buffer after unlink,
        # file no longer exists so it would return empty dicts anyway.
        self._history_buffer = {}
        self._history_line_count = 0
        self._total_calls = {}

    # ────────────────────────────────────────
    # Private — File I/O
    # ────────────────────────────────────────

    def _read_scores(self) -> dict[str, ToolScore]:
        """Read tool_scores.json from namespace dir.

        Returns empty dict if missing or corrupt (graceful degradation).
        """
        scores_path = self._config.namespace_dir / "tool_scores.json"
        if not scores_path.exists():
            return {}

        try:
            raw = json.loads(scores_path.read_text(encoding="utf-8"))
            if not isinstance(raw, dict):
                logger.warning("tool_scores.json is not a JSON object. Resetting.")
                return {}
            scores: dict[str, ToolScore] = {}
            for name, data in raw.items():
                if not isinstance(data, dict):
                    continue
                try:
                    scores[name] = ToolScore.from_dict(data)
                except (ValueError, TypeError):
                    logger.warning(
                        "Malformed entry '%s' in tool_scores.json. Skipping.",
                        name,
                    )
            return scores
        except (json.JSONDecodeError, OSError):
            logger.warning(
                "Failed to read tool_scores.json. Starting fresh.",
                exc_info=True,
            )
            return {}

    def _write_scores(self) -> None:
        """Write in-memory scores to tool_scores.json.

        BUG-019: acquire a file lock before writing to prevent concurrent-process
        data loss. Lock file: tool_scores.json.lock.
        Creates parent dirs if needed. Logs error on failure.
        """
        scores_path = self._config.namespace_dir / "tool_scores.json"
        lock_path = scores_path.with_suffix(scores_path.suffix + ".lock")
        try:
            data = {name: score.to_dict() for name, score in self._scores.items()}
            # BUG-019: lock prevents concurrent writes; BUG-033: atomic rename
            with FileLock(lock_path, timeout=10):
                atomic_write_json(scores_path, data)
        except OSError:
            logger.warning(
                "Failed to write tool_scores.json.",
                exc_info=True,
            )

    def _build_history_buffer(self, window: int) -> tuple[dict[str, deque[bool]], dict[str, int]]:
        """Build per-tool in-memory buffer from the history file (called once on init).

        Each tool gets a deque capped at `window` entries (the scoring window).
        Also returns per-tool lifetime call counts for merging into _total_calls
        when tool_scores.json is missing/corrupt (BUG-023 R2).
        Bounded to max_history_entries for JPL-style bounded init cost (BUG-023 R2).

        Avoids re-reading disk on every log_tool_call (BUG-023 fix).
        """
        history_path = self._config.namespace_dir / "tool_history.jsonl"
        buffer: dict[str, deque[bool]] = {}
        counts: dict[str, int] = {}
        if not history_path.exists():
            return buffer, counts
        max_entries = self._config.tool_selector.max_history_entries
        try:
            lines = history_path.read_text(encoding="utf-8").splitlines()
            # JPL bounded: only process the most recent max_history_entries lines
            bounded_lines = lines[-max_entries:]
            malformed_seen = False
            for line in bounded_lines:
                line = line.strip()
                if not line:
                    continue
                data = None
                with contextlib.suppress(json.JSONDecodeError, TypeError):
                    data = json.loads(line)
                if data is None:
                    malformed_seen = True
                    continue
                if not isinstance(data, dict):
                    malformed_seen = True
                    continue
                tool = str(data.get("tool", ""))
                success = bool(data.get("success", False))
                if tool:
                    if tool not in buffer:
                        buffer[tool] = deque(maxlen=window)
                    buffer[tool].append(success)
                    counts[tool] = counts.get(tool, 0) + 1
            if malformed_seen:
                logger.warning(
                    "Skipped malformed lines in %s",
                    history_path,
                )
        except OSError:
            logger.warning(
                "Failed to read tool_history.jsonl during buffer init.",
                exc_info=True,
            )
        return buffer, counts

    def _count_history_lines(self) -> int:
        """Return total non-empty line count in tool_history.jsonl.

        Bounded to max_history_entries for JPL-style safety.
        Early-exits once the cap is reached to avoid unbounded scanning.
        """
        history_path = self._config.namespace_dir / "tool_history.jsonl"
        if not history_path.exists():
            return 0
        max_entries = self._config.tool_selector.max_history_entries
        try:
            count = 0
            with history_path.open("r", encoding="utf-8") as f:
                for line in f:
                    if line.strip():
                        count += 1
                        if count >= max_entries:
                            return count
            return count
        except OSError:
            return 0

    def _read_history(self) -> list[ToolCallRecord]:
        """Read all records from tool_history.jsonl.

        Skips malformed lines (logs warning). Returns empty list if missing.
        """
        history_path = self._config.namespace_dir / "tool_history.jsonl"
        if not history_path.exists():
            return []

        records: list[ToolCallRecord] = []
        try:
            lines = history_path.read_text(encoding="utf-8").splitlines()
            for i, line in enumerate(lines):
                line = line.strip()
                if not line:
                    continue
                try:
                    data = json.loads(line)
                    if isinstance(data, dict):
                        try:
                            records.append(ToolCallRecord.from_dict(data))
                        except (ValueError, TypeError):
                            logger.warning(
                                "Malformed record on line %d in tool_history.jsonl. Skipping.",
                                i + 1,
                            )
                except json.JSONDecodeError:
                    logger.warning("Malformed line %d in tool_history.jsonl. Skipping.", i + 1)
        except OSError:
            logger.warning(
                "Failed to read tool_history.jsonl.",
                exc_info=True,
            )
        return records

    def _append_history(self, record: ToolCallRecord) -> None:
        """Append a single record to tool_history.jsonl.

        Creates file and parent dirs if needed. Also updates the in-memory
        buffer, line count, and total_calls counter (BUG-023).
        """
        history_path = self._config.namespace_dir / "tool_history.jsonl"
        history_path.parent.mkdir(parents=True, exist_ok=True)
        with history_path.open("a", encoding="utf-8") as f:
            f.write(json.dumps(record.to_dict()) + "\n")
        # Update in-memory buffer and counters (BUG-023 fix — no disk re-read needed)
        window = self._config.tool_selector.scoring_window
        if record.tool not in self._history_buffer:
            self._history_buffer[record.tool] = deque(maxlen=window)
        self._history_buffer[record.tool].append(record.success)
        self._history_line_count += 1
        self._total_calls[record.tool] = self._total_calls.get(record.tool, 0) + 1

    def _trim_history(self) -> None:
        """Trim tool_history.jsonl if it exceeds max entries.

        BUG-019: file lock for the full read-modify-write cycle prevents corruption.
        BUG-023 fix: uses self._history_line_count to check threshold before disk I/O.
        Keeps only the most recent entries. JPL bounded growth.
        """
        max_entries = self._config.tool_selector.max_history_entries

        # Fast path: use in-memory count — no disk read needed (BUG-023)
        if self._history_line_count <= max_entries:
            return

        history_path = self._config.namespace_dir / "tool_history.jsonl"
        if not history_path.exists():
            return

        lock_path = history_path.with_suffix(history_path.suffix + ".lock")
        try:
            # BUG-019: lock for read-modify-write; BUG-033: atomic_write_text
            with FileLock(lock_path, timeout=10):
                lines = history_path.read_text(encoding="utf-8").splitlines()
                if len(lines) <= max_entries:
                    # Count was stale (e.g. blank lines) — sync it
                    self._history_line_count = len(lines)
                    return

                # Keep only the most recent entries
                trimmed = lines[-max_entries:]
                atomic_write_text(history_path, "\n".join(trimmed) + "\n")
                self._history_line_count = len(trimmed)
            logger.info(
                "Trimmed tool_history.jsonl from %d to %d entries.",
                len(lines),
                len(trimmed),
            )
        except OSError:
            logger.warning(
                "Failed to trim tool_history.jsonl.",
                exc_info=True,
            )

    # ────────────────────────────────────────
    # Private — Scoring
    # ────────────────────────────────────────

    def _recalculate_score(self, tool: str) -> None:
        """Recalculate score for a specific tool from the in-memory buffer.

        BUG-023 fix: uses self._history_buffer instead of reading disk.
        The buffer holds the last scoring_window booleans per tool and is
        kept up to date by _append_history, so no disk read is needed here.

        BUG-021 preserved: total_calls uses self._total_calls (authoritative
        per-tool counter, not the capped buffer length).
        """
        # Use in-memory buffer — O(window), no disk I/O (BUG-023)
        recent = list(self._history_buffer.get(tool, deque()))

        recent_total = len(recent)
        recent_successes = sum(1 for success in recent if success)
        score = (
            recent_successes / recent_total
            if recent_total > 0
            else self._config.tool_selector.default_score
        )

        # Use in-memory counter as authoritative total_calls (BUG-021 + BUG-023)
        # _total_calls is always monotonic (incremented at line 513 before this call)
        total_calls = self._total_calls.get(tool, recent_total)

        self._scores[tool] = ToolScore(
            score=score,
            total_calls=total_calls,
            recent_successes=recent_successes,
            recent_total=recent_total,
            last_updated=datetime.now(UTC).isoformat(),
        )
