"""Context window management for the agent.

Provides token tracking, context compaction, and backup functionality
to manage the LLM context window effectively.
"""

import json
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from typing import Any

from cadecoder.core.constants import DEFAULT_AI_MODEL

# Module-level logger (avoids circular import)
log = logging.getLogger("cadecoder")


class CompactionStrategy(str, Enum):
    """Strategy for compacting context when window is full."""

    KEEP_RECENT = "keep_recent"  # Keep most recent messages
    SUMMARIZE_EARLY = "summarize_early"  # Summarize early messages, keep recent
    KEEP_TOOL_RESULTS_FINAL = "keep_tool_results_final"  # Only keep final tool results
    DROP_TOOL_OUTPUTS = "drop_tool_outputs"  # Remove tool outputs, keep structure


@dataclass
class TokenEstimate:
    """Token estimate for a message or context."""

    text_tokens: int
    tool_call_tokens: int
    total: int

    @classmethod
    def from_text(cls, text: str, chars_per_token: float = 4.0) -> "TokenEstimate":
        """Estimate tokens from text using character ratio."""
        text_tokens = int(len(text) / chars_per_token)
        return cls(text_tokens=text_tokens, tool_call_tokens=0, total=text_tokens)

    @classmethod
    def from_message(cls, message: dict[str, Any], chars_per_token: float = 4.0) -> "TokenEstimate":
        """Estimate tokens from a message dict."""
        text_tokens = 0
        tool_tokens = 0

        content = message.get("content", "")
        if content:
            if isinstance(content, str):
                text_tokens = int(len(content) / chars_per_token)
            elif isinstance(content, list):
                # Anthropic-style list of content blocks
                total_chars = 0
                for block in content:
                    if isinstance(block, str):
                        total_chars += len(block)
                    elif isinstance(block, dict):
                        total_chars += len(block.get("text", ""))
                        if block.get("type") == "tool_use":
                            total_chars += len(json.dumps(block.get("input", {})))
                        elif block.get("type") == "tool_result":
                            result_content = block.get("content", "")
                            if isinstance(result_content, str):
                                total_chars += len(result_content)
                            elif isinstance(result_content, list):
                                for rb in result_content:
                                    if isinstance(rb, dict):
                                        total_chars += len(rb.get("text", ""))
                text_tokens = int(total_chars / chars_per_token)
            else:
                text_tokens = int(len(str(content)) / chars_per_token)

        # Count tool calls
        tool_calls = message.get("tool_calls", [])
        for tc in tool_calls:
            func = tc.get("function", {})
            name = func.get("name", "")
            args = func.get("arguments", "")
            tool_tokens += int((len(name) + len(args)) / chars_per_token)

        return cls(
            text_tokens=text_tokens,
            tool_call_tokens=tool_tokens,
            total=text_tokens + tool_tokens,
        )


def _extract_topic(messages: list[dict[str, Any]], max_len: int = 80) -> str:
    """Extract a topic string from the first user message."""
    for msg in messages:
        if msg.get("role") == "user":
            content = msg.get("content", "")
            if isinstance(content, str) and content.strip():
                topic = content.strip().replace("\n", " ")
                return topic[:max_len] if len(topic) > max_len else topic
    return ""


@dataclass
class ContextBackup:
    """Backup of context before compaction."""

    timestamp: datetime
    messages: list[dict[str, Any]]
    token_count: int
    compaction_reason: str
    thread_id: str = ""
    topic: str = ""

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        result = {
            "timestamp": self.timestamp.isoformat(),
            "messages": self.messages,
            "token_count": self.token_count,
            "compaction_reason": self.compaction_reason,
        }
        if self.thread_id:
            result["thread_id"] = self.thread_id
        if self.topic:
            result["topic"] = self.topic
        return result

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "ContextBackup":
        """Create from dictionary."""
        return cls(
            timestamp=datetime.fromisoformat(data["timestamp"]),
            messages=data["messages"],
            token_count=data["token_count"],
            compaction_reason=data["compaction_reason"],
            thread_id=data.get("thread_id", ""),
            topic=data.get("topic", ""),
        )


@dataclass
class ToolOutputCollection:
    """Collection of tool outputs with different retention strategies."""

    all_outputs: list[dict[str, Any]] = field(default_factory=list)
    final_outputs: dict[str, str] = field(default_factory=dict)

    def add_output(self, tool_name: str, output: str, tool_call_id: str) -> None:
        """Add a tool output to the collection."""
        self.all_outputs.append(
            {
                "tool_name": tool_name,
                "output": output,
                "tool_call_id": tool_call_id,
                "timestamp": datetime.now(UTC).isoformat(),
            }
        )
        # Always update final output to latest
        self.final_outputs[tool_name] = output

    def get_all_outputs(self) -> list[dict[str, Any]]:
        """Get all collected outputs."""
        return self.all_outputs.copy()

    def get_final_outputs(self) -> dict[str, str]:
        """Get only the final output for each tool."""
        return self.final_outputs.copy()

    def get_total_size(self) -> int:
        """Get total character size of all outputs."""
        return sum(len(o.get("output", "")) for o in self.all_outputs)

    def clear(self) -> None:
        """Clear all collected outputs."""
        self.all_outputs.clear()
        self.final_outputs.clear()


class ContextWindowManager:
    """Manages the LLM context window with compaction and backup support.

    Tracks token usage, manages context size, and provides methods for
    compacting context when the window is full.
    """

    # Model context limits (updated Feb 2026)
    # OpenAI: https://platform.openai.com/docs/models
    # Anthropic: https://docs.anthropic.com/en/docs/about-claude/models
    MODEL_CONTEXT_LIMITS = {
        # OpenAI GPT-4.1 family (1M context via API, max output 32,768)
        "gpt-4.1": 1_000_000,
        "gpt-4.1-mini": 1_000_000,
        "gpt-4.1-nano": 1_000_000,
        # OpenAI GPT-4o family
        "gpt-4o": 128_000,
        "gpt-4o-mini": 128_000,
        # OpenAI legacy models
        "gpt-4-turbo": 128_000,
        "gpt-4": 8_192,
        "gpt-3.5-turbo": 16_385,
        # Anthropic Claude 4 family
        "claude-opus-4.5": 200_000,
        "claude-opus-4-5-20251101": 200_000,
        "claude-sonnet-4": 200_000,
        # Anthropic Claude 3 family
        "claude-3-opus": 200_000,
        "claude-3-sonnet": 200_000,
        "claude-3-haiku": 200_000,
        "claude-3.5-sonnet": 200_000,
        "claude-3.5-haiku": 200_000,
    }

    # Max output tokens per model (for reserving response space)
    MODEL_MAX_OUTPUT = {
        "gpt-4.1": 32_768,
        "gpt-4.1-mini": 32_768,
        "gpt-4.1-nano": 32_768,
        "gpt-4o": 16_384,
        "gpt-4o-mini": 16_384,
        "gpt-4-turbo": 4_096,
        "gpt-4": 4_096,  # Legacy GPT-4 has smaller output
        "gpt-3.5-turbo": 4_096,
        "claude-opus-4.5": 32_000,
        "claude-opus-4-5-20251101": 32_000,
        "claude-sonnet-4": 8_192,
        "claude-3-opus": 4_096,
        "claude-3-sonnet": 4_096,
        "claude-3-haiku": 4_096,
        "claude-3.5-sonnet": 8_192,
        "claude-3.5-haiku": 8_192,
    }

    # Reserve tokens for response (fallback if model not in MODEL_MAX_OUTPUT)
    # Uses 10% of context limit or 4096, whichever is smaller
    RESPONSE_RESERVE = 4_096

    # Compaction threshold (% of window used before compaction)
    COMPACTION_THRESHOLD = 0.85

    @classmethod
    def _lookup_model_limit(cls, model: str, limits: dict[str, int], default: int) -> int:
        """Look up a model limit, trying exact match then prefix match.

        Handles versioned model names like 'claude-opus-4-5-20251101'
        matching against base names like 'claude-opus-4.5'.
        """
        if model in limits:
            return limits[model]

        # Normalize dots/hyphens and try prefix matching
        normalized_model = model.replace(".", "-")
        best_match_value = None
        best_length = 0
        for key, value in limits.items():
            normalized_key = key.replace(".", "-")
            if normalized_model.startswith(normalized_key) and len(normalized_key) > best_length:
                best_match_value = value
                best_length = len(normalized_key)

        if best_match_value is not None:
            return best_match_value

        return default

    def __init__(
        self,
        model: str = DEFAULT_AI_MODEL,
        backup_dir: Path | None = None,
        chars_per_token: float = 4.0,
    ) -> None:
        """Initialize context window manager.

        Args:
            model: Model name for context limit lookup
            backup_dir: Directory for storing context backups
            chars_per_token: Character to token ratio for estimation
        """
        self.model = model
        self.chars_per_token = chars_per_token

        # Set context limit based on model (default to 128K for unknown models)
        self.context_limit = self._lookup_model_limit(model, self.MODEL_CONTEXT_LIMITS, 128_000)

        # Set response reserve based on model's max output tokens
        self.response_reserve = self._lookup_model_limit(
            model, self.MODEL_MAX_OUTPUT, self.RESPONSE_RESERVE
        )
        self.effective_limit = self.context_limit - self.response_reserve

        # Backup directory
        if backup_dir is None:
            backup_dir = Path.home() / ".cadecoder" / "context_backups"
        self.backup_dir = backup_dir
        self.backup_dir.mkdir(parents=True, exist_ok=True)

        # Tool output collection
        self.tool_outputs = ToolOutputCollection()

        # Estimated token overhead from tool definitions (set by orchestrator)
        self.tool_definition_tokens = 0

        # Optional tool result store for retroactive externalization (set by orchestrator)
        self.tool_result_store: Any = None

        # Thread ID for current session (set by orchestrator)
        self.thread_id: str = ""

        # Current context tracking
        self._current_token_count = 0
        self._message_count = 0
        self._backups: list[ContextBackup] = []

        log.info(
            f"ContextWindowManager initialized: model={model}, "
            f"limit={self.context_limit:,}, effective={self.effective_limit:,}"
        )

    def estimate_tokens(self, messages: list[dict[str, Any]]) -> int:
        """Estimate total tokens for a list of messages."""
        total = 0
        for msg in messages:
            estimate = TokenEstimate.from_message(msg, self.chars_per_token)
            total += estimate.total
        return total

    def estimate_message_tokens(self, message: dict[str, Any]) -> TokenEstimate:
        """Estimate tokens for a single message."""
        return TokenEstimate.from_message(message, self.chars_per_token)

    def check_context_status(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
        """Check current context window status.

        Returns:
            Dict with token_count, percentage_used, needs_compaction, available_tokens
        """
        token_count = self.estimate_tokens(messages) + self.tool_definition_tokens
        percentage_used = token_count / self.effective_limit if self.effective_limit > 0 else 1.0
        needs_compaction = percentage_used >= self.COMPACTION_THRESHOLD

        return {
            "token_count": token_count,
            "percentage_used": round(percentage_used * 100, 1),
            "needs_compaction": needs_compaction,
            "available_tokens": self.effective_limit - token_count,
            "effective_limit": self.effective_limit,
            "message_count": len(messages),
        }

    def compact_context(
        self,
        messages: list[dict[str, Any]],
        strategy: CompactionStrategy = CompactionStrategy.KEEP_RECENT,
        target_percentage: float = 0.6,
        thread_id: str = "",
    ) -> tuple[list[dict[str, Any]], ContextBackup]:
        """Compact context to reduce token usage.

        Args:
            messages: Current message list
            strategy: Compaction strategy to use
            target_percentage: Target percentage of context limit after compaction
            thread_id: Thread ID for identifying the backup

        Returns:
            Tuple of (compacted_messages, backup)
        """
        # Create backup before compaction
        current_tokens = self.estimate_tokens(messages)
        backup = ContextBackup(
            timestamp=datetime.now(UTC),
            messages=messages.copy(),
            token_count=current_tokens,
            compaction_reason=f"strategy={strategy.value}, threshold={self.COMPACTION_THRESHOLD}",
            thread_id=thread_id,
            topic=_extract_topic(messages),
        )

        # Save backup to disk
        self._save_backup(backup)
        self._backups.append(backup)

        target_tokens = int(self.effective_limit * target_percentage)

        log.info(
            f"Compacting context: {current_tokens:,} -> {target_tokens:,} tokens "
            f"(strategy={strategy.value})"
        )

        if strategy == CompactionStrategy.KEEP_RECENT:
            compacted = self._compact_keep_recent(messages, target_tokens)
        elif strategy == CompactionStrategy.SUMMARIZE_EARLY:
            compacted = self._compact_summarize_early(messages, target_tokens)
        elif strategy == CompactionStrategy.KEEP_TOOL_RESULTS_FINAL:
            compacted = self._compact_keep_final_tool_results(messages, target_tokens)
        elif strategy == CompactionStrategy.DROP_TOOL_OUTPUTS:
            compacted = self._compact_drop_tool_outputs(messages, target_tokens)
        else:
            compacted = self._compact_keep_recent(messages, target_tokens)

        # Validate message integrity after compaction
        compacted = self._fix_orphaned_tool_messages(compacted)

        new_token_count = self.estimate_tokens(compacted)
        log.info(
            f"Compaction complete: {len(messages)} -> {len(compacted)} messages, "
            f"{current_tokens:,} -> {new_token_count:,} tokens"
        )

        return compacted, backup

    def _compact_keep_recent(
        self, messages: list[dict[str, Any]], target_tokens: int
    ) -> list[dict[str, Any]]:
        """Keep only the most recent messages within token budget.

        Preserves tool_call/tool_result pairs as atomic units to avoid
        breaking Anthropic's requirement that tool_use blocks must have
        corresponding tool_result blocks immediately after.

        Always ensures at least one user message is present so the API
        receives a valid message sequence.
        """
        # Always keep system message if present
        system_msg = None
        other_msgs = []

        for msg in messages:
            if msg.get("role") == "system":
                system_msg = msg
            else:
                other_msgs.append(msg)

        # Group messages into atomic units (preserving tool_call + tool_result pairs)
        message_groups = self._group_messages_for_compaction(other_msgs)

        # Build from most recent groups, working backwards
        compacted_groups: list[list[dict[str, Any]]] = []
        current_tokens = 0

        if system_msg:
            system_tokens = self.estimate_message_tokens(system_msg).total
            current_tokens += system_tokens

        # Add message groups from most recent, working backwards.
        # CRITICAL: Groups are atomic (tool_call + tool_results). Never split a group
        # — doing so creates orphaned tool_use blocks that Anthropic rejects with 400.
        for group in reversed(message_groups):
            group_tokens = sum(self.estimate_message_tokens(msg).total for msg in group)
            if current_tokens + group_tokens <= target_tokens:
                compacted_groups.insert(0, group)
                current_tokens += group_tokens
            else:
                if not compacted_groups:
                    # Most recent group exceeds budget. Try retroactive
                    # externalization of tool results before including it whole.
                    group = self._try_externalize_group(group)
                    group_tokens = sum(self.estimate_message_tokens(msg).total for msg in group)
                    log.warning(
                        f"Most recent message group ({group_tokens:,} tokens) "
                        f"exceeds target ({target_tokens:,}), including whole group "
                        f"to preserve tool_call/tool_result integrity"
                    )
                    compacted_groups.insert(0, group)
                    current_tokens += group_tokens
                break

        # Re-externalize large tool results in older kept groups to reclaim context.
        # The newest group is kept inline so the agent can still act on it.
        if len(compacted_groups) > 1:
            for idx in range(len(compacted_groups) - 1):
                compacted_groups[idx] = self._try_externalize_group(compacted_groups[idx])

        # Flatten groups back to message list
        compacted: list[dict[str, Any]] = []
        if system_msg:
            compacted.append(system_msg)
        for group in compacted_groups:
            compacted.extend(group)

        # Ensure at least one user message exists (API requirement).
        # If the compacted list starts with an assistant/tool message after
        # the system message, prepend a context placeholder as user message.
        non_system = [m for m in compacted if m.get("role") != "system"]
        if not non_system or non_system[0].get("role") != "user":
            insert_idx = 1 if system_msg else 0
            compacted.insert(
                insert_idx,
                {
                    "role": "user",
                    "content": (
                        "[Earlier conversation compacted. Continue from the context below.]"
                    ),
                },
            )

        return compacted

    def _group_messages_for_compaction(
        self, messages: list[dict[str, Any]]
    ) -> list[list[dict[str, Any]]]:
        """Group messages into atomic units for compaction.

        Groups:
        - Assistant message with tool_calls + all following tool result messages
        - Regular user/assistant messages as single-item groups

        This ensures tool_call/tool_result pairs are never split during compaction.
        Handles both OpenAI format (tool_calls array) and Anthropic format
        (content array with tool_use blocks).
        """
        groups: list[list[dict[str, Any]]] = []
        i = 0

        while i < len(messages):
            msg = messages[i]
            role = msg.get("role")

            # Check for OpenAI format: assistant message with tool_calls array
            if role == "assistant" and msg.get("tool_calls"):
                group = [msg]
                tool_call_ids = {tc.get("id") for tc in msg.get("tool_calls", [])}

                # Collect subsequent tool result messages
                j = i + 1
                while j < len(messages):
                    next_msg = messages[j]
                    if next_msg.get("role") == "tool":
                        tool_call_id = next_msg.get("tool_call_id")
                        if tool_call_id in tool_call_ids:
                            group.append(next_msg)
                            tool_call_ids.discard(tool_call_id)
                            j += 1
                            continue
                    break

                groups.append(group)
                i = j

            # Check for Anthropic format: assistant message with tool_use in content
            elif role == "assistant" and self._has_tool_use_blocks(msg):
                group = [msg]
                tool_use_ids = self._extract_tool_use_ids(msg)

                # Collect subsequent user messages that contain tool_result blocks
                j = i + 1
                while j < len(messages) and tool_use_ids:
                    next_msg = messages[j]
                    if next_msg.get("role") == "user":
                        result_ids = self._extract_tool_result_ids(next_msg)
                        if result_ids & tool_use_ids:
                            # This user message has matching tool results
                            group.append(next_msg)
                            tool_use_ids -= result_ids
                            j += 1
                            continue
                    break

                groups.append(group)
                i = j

            else:
                # Single message group
                groups.append([msg])
                i += 1

        return groups

    def _try_externalize_group(self, group: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Try to externalize large tool results in a message group.

        If a tool_result_store is available, replace large tool result
        content with compact references.  Returns the (possibly modified)
        group.
        """
        if self.tool_result_store is None:
            return group

        import sys
        from datetime import UTC, datetime

        from ulid import ulid

        from cadecoder.execution.tool_result_store import (
            NEVER_EXTERNALIZE_TOOLS,
            TINY_RESULT_BYTES,
            ToolResultRecord,
            ToolResultRef,
            _generate_summary,
        )

        # Build tool_call_id → tool_name map from assistant messages in the group
        call_id_to_name: dict[str, str] = {}
        for msg in group:
            if msg.get("role") == "assistant":
                for tc in msg.get("tool_calls", []):
                    tc_id = tc.get("id")
                    fn = tc.get("function", {})
                    name = fn.get("name") if isinstance(fn, dict) else None
                    if tc_id and name:
                        call_id_to_name[tc_id] = name

        externalized = 0
        new_group: list[dict[str, Any]] = []
        now = datetime.now(UTC).isoformat()

        for msg in group:
            if msg.get("role") == "tool":
                content = msg.get("content", "")
                if sys.getsizeof(content) > TINY_RESULT_BYTES and not ToolResultRef.is_externalized(
                    content
                ):
                    tool_call_id = msg.get("tool_call_id", "")
                    tool_name = call_id_to_name.get(tool_call_id, msg.get("name", "unknown"))

                    # Never externalize retrieval tools — would cause infinite loops
                    if tool_name in NEVER_EXTERNALIZE_TOOLS:
                        new_group.append(msg)
                        continue
                    status = "success"
                    summary = _generate_summary(content, tool_name, status)

                    record = ToolResultRecord(
                        record_id=str(ulid()).lower(),
                        tool_call_id=tool_call_id,
                        tool_name=tool_name,
                        thread_id=self.thread_id,
                        timestamp=now,
                        content=content,
                        content_chars=len(content),
                        content_token_estimate=int(len(content) / self.chars_per_token),
                        summary=summary,
                        status=status,
                        turn_sequence=0,
                    )

                    ref = self.tool_result_store.store_result(record)
                    new_group.append({**msg, "content": ref.to_inline_text()})
                    externalized += 1
                    continue

            new_group.append(msg)

        if externalized:
            log.info(f"Retroactively externalized {externalized} tool result(s) during compaction")

        return new_group

    def _has_tool_use_blocks(self, msg: dict[str, Any]) -> bool:
        """Check if message has Anthropic-style tool_use content blocks."""
        content = msg.get("content")
        if not isinstance(content, list):
            return False
        return any(isinstance(block, dict) and block.get("type") == "tool_use" for block in content)

    def _extract_tool_use_ids(self, msg: dict[str, Any]) -> set[str]:
        """Extract tool_use IDs from Anthropic-style assistant message."""
        ids: set[str] = set()
        content = msg.get("content")
        if isinstance(content, list):
            for block in content:
                if isinstance(block, dict) and block.get("type") == "tool_use":
                    tool_id = block.get("id")
                    if tool_id:
                        ids.add(tool_id)
        return ids

    def _extract_tool_result_ids(self, msg: dict[str, Any]) -> set[str]:
        """Extract tool_use_ids from Anthropic-style tool_result content blocks."""
        ids: set[str] = set()
        content = msg.get("content")
        if isinstance(content, list):
            for block in content:
                if isinstance(block, dict) and block.get("type") == "tool_result":
                    tool_use_id = block.get("tool_use_id")
                    if tool_use_id:
                        ids.add(tool_use_id)
        return ids

    def _fix_orphaned_tool_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Remove orphaned tool_call or tool_result messages after compaction.

        Ensures every assistant message with tool_calls has all its tool result
        messages following it, and every tool result message has a preceding
        assistant message with the matching tool_call. Removes messages that
        would cause Anthropic 400 errors about missing pairs.

        Handles OpenAI format (role='tool' with tool_call_id) and Anthropic
        format (content blocks with tool_use/tool_result types).
        """
        # Collect tool_call IDs from assistant messages and tool_result IDs from tool messages
        provided_tool_call_ids: set[str] = set()
        required_tool_result_ids: set[str] = set()

        for msg in messages:
            role = msg.get("role")

            if role == "assistant":
                # OpenAI format: tool_calls array
                for tc in msg.get("tool_calls", []):
                    tc_id = tc.get("id")
                    if tc_id:
                        required_tool_result_ids.add(tc_id)

                # Anthropic format: tool_use blocks in content
                content = msg.get("content")
                if isinstance(content, list):
                    for block in content:
                        if isinstance(block, dict) and block.get("type") == "tool_use":
                            tu_id = block.get("id")
                            if tu_id:
                                required_tool_result_ids.add(tu_id)

            elif role == "tool":
                tc_id = msg.get("tool_call_id")
                if tc_id:
                    provided_tool_call_ids.add(tc_id)

            elif role == "user":
                content = msg.get("content")
                if isinstance(content, list):
                    for block in content:
                        if isinstance(block, dict) and block.get("type") == "tool_result":
                            tu_id = block.get("tool_use_id")
                            if tu_id:
                                provided_tool_call_ids.add(tu_id)

        # Find orphaned tool_call IDs (assistant has tool_calls but no matching results)
        orphaned_tool_call_ids = required_tool_result_ids - provided_tool_call_ids

        # Find orphaned tool_result IDs (tool results with no matching tool_call)
        orphaned_tool_result_ids = provided_tool_call_ids - required_tool_result_ids

        if not orphaned_tool_call_ids and not orphaned_tool_result_ids:
            return messages

        if orphaned_tool_call_ids:
            log.warning(
                f"Found {len(orphaned_tool_call_ids)} orphaned tool_call(s) after compaction, "
                f"removing to prevent API errors: {orphaned_tool_call_ids}"
            )
        if orphaned_tool_result_ids:
            log.warning(
                f"Found {len(orphaned_tool_result_ids)} orphaned tool_result(s) after compaction, "
                f"removing to prevent API errors: {orphaned_tool_result_ids}"
            )

        # Remove orphaned tool_calls from assistant messages and orphaned tool_results
        fixed: list[dict[str, Any]] = []
        for msg in messages:
            role = msg.get("role")

            if role == "assistant":
                # OpenAI format: filter tool_calls array
                tool_calls = msg.get("tool_calls", [])
                if tool_calls:
                    filtered_calls = [
                        tc for tc in tool_calls if tc.get("id") not in orphaned_tool_call_ids
                    ]
                    if filtered_calls:
                        fixed.append({**msg, "tool_calls": filtered_calls})
                    elif msg.get("content"):
                        # Keep the message but without tool_calls
                        clean_msg = {k: v for k, v in msg.items() if k != "tool_calls"}
                        fixed.append(clean_msg)
                    # else: drop the message entirely (no content, no valid tool_calls)
                    continue

                # Anthropic format: filter tool_use blocks from content
                content = msg.get("content")
                if isinstance(content, list):
                    has_tool_use = any(
                        isinstance(b, dict) and b.get("type") == "tool_use" for b in content
                    )
                    if has_tool_use:
                        filtered_content = [
                            b
                            for b in content
                            if not (
                                isinstance(b, dict)
                                and b.get("type") == "tool_use"
                                and b.get("id") in orphaned_tool_call_ids
                            )
                        ]
                        if filtered_content:
                            fixed.append({**msg, "content": filtered_content})
                        continue

            # OpenAI format: skip orphaned tool result messages
            if role == "tool":
                tc_id = msg.get("tool_call_id")
                if tc_id and tc_id in orphaned_tool_result_ids:
                    continue

            # Anthropic format: filter orphaned tool_result blocks from user messages
            if role == "user" and orphaned_tool_result_ids:
                content = msg.get("content")
                if isinstance(content, list):
                    has_tool_result = any(
                        isinstance(b, dict) and b.get("type") == "tool_result" for b in content
                    )
                    if has_tool_result:
                        filtered_content = [
                            b
                            for b in content
                            if not (
                                isinstance(b, dict)
                                and b.get("type") == "tool_result"
                                and b.get("tool_use_id") in orphaned_tool_result_ids
                            )
                        ]
                        if filtered_content:
                            fixed.append({**msg, "content": filtered_content})
                        # else: drop user message entirely if only orphaned tool_results
                        continue

            fixed.append(msg)

        return fixed

    def _compact_summarize_early(
        self, messages: list[dict[str, Any]], target_tokens: int
    ) -> list[dict[str, Any]]:
        """Summarize early messages, keep recent ones in full."""
        # For now, use keep_recent with a summary placeholder
        # Full implementation would call LLM to summarize
        compacted = self._compact_keep_recent(messages, target_tokens)

        # Add summary placeholder for dropped messages
        dropped_count = len(messages) - len(compacted)
        if dropped_count > 0 and compacted:
            # Find first non-system message
            insert_idx = 1 if compacted[0].get("role") == "system" else 0
            summary_msg = {
                "role": "system",
                "content": f"[Context compacted: {dropped_count} earlier messages summarized. "
                "Continue from here.]",
            }
            compacted.insert(insert_idx, summary_msg)

        return compacted

    def _compact_keep_final_tool_results(
        self, messages: list[dict[str, Any]], target_tokens: int
    ) -> list[dict[str, Any]]:
        """Keep only final tool results, replacing intermediate ones with summaries."""
        compacted = []
        tool_results_by_name: dict[str, dict[str, Any]] = {}

        # First pass: identify final tool results
        for msg in messages:
            if msg.get("role") == "tool":
                tool_name = msg.get("tool_name", msg.get("name", "unknown"))
                tool_results_by_name[tool_name] = msg
            else:
                compacted.append(msg)

        # Second pass: add final tool results
        for tool_msg in tool_results_by_name.values():
            compacted.append(tool_msg)

        # If still over budget, apply keep_recent
        if self.estimate_tokens(compacted) > target_tokens:
            compacted = self._compact_keep_recent(compacted, target_tokens)

        return compacted

    def _compact_drop_tool_outputs(
        self, messages: list[dict[str, Any]], target_tokens: int
    ) -> list[dict[str, Any]]:
        """Drop tool output content but keep structure."""
        compacted = []

        for msg in messages:
            if msg.get("role") == "tool":
                # Replace content with placeholder
                compacted.append(
                    {
                        **msg,
                        "content": "[Tool output truncated for context management]",
                    }
                )
            else:
                compacted.append(msg)

        # If still over budget, apply keep_recent
        if self.estimate_tokens(compacted) > target_tokens:
            compacted = self._compact_keep_recent(compacted, target_tokens)

        return compacted

    def _save_backup(self, backup: ContextBackup) -> None:
        """Save backup to disk."""
        try:
            ts = backup.timestamp.strftime("%Y%m%d_%H%M%S")
            if backup.thread_id:
                # Use last 8 chars of thread_id as short identifier
                short_id = backup.thread_id[-8:]
                filename = f"context_backup_{ts}_{short_id}.json"
            else:
                filename = f"context_backup_{ts}.json"
            backup_path = self.backup_dir / filename

            with open(backup_path, "w", encoding="utf-8") as f:
                json.dump(backup.to_dict(), f, indent=2)

            log.debug(f"Context backup saved: {backup_path}")

            # Clean old backups (keep last 10)
            self._cleanup_old_backups(keep_count=10)

        except Exception as e:
            log.warning(f"Failed to save context backup: {e}")

    def _cleanup_old_backups(self, keep_count: int = 10) -> None:
        """Remove old backup files, keeping only the most recent."""
        try:
            backup_files = sorted(
                self.backup_dir.glob("context_backup_*.json"),
                key=lambda p: p.stat().st_mtime,
                reverse=True,
            )

            for old_file in backup_files[keep_count:]:
                old_file.unlink()
                log.debug(f"Removed old context backup: {old_file.name}")

        except Exception as e:
            log.warning(f"Failed to cleanup old backups: {e}")

    def load_backup(self, backup_path: Path) -> ContextBackup | None:
        """Load a backup from disk."""
        try:
            with open(backup_path, encoding="utf-8") as f:
                data = json.load(f)
            return ContextBackup.from_dict(data)
        except Exception as e:
            log.warning(f"Failed to load backup {backup_path}: {e}")
            return None

    def list_backups(self) -> list[Path]:
        """List available backup files."""
        return sorted(
            self.backup_dir.glob("context_backup_*.json"),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )

    def add_tool_output(self, tool_name: str, output: str, tool_call_id: str) -> None:
        """Add a tool output to the collection."""
        self.tool_outputs.add_output(tool_name, output, tool_call_id)

    def get_tool_outputs_summary(self) -> dict[str, Any]:
        """Get summary of collected tool outputs."""
        return {
            "total_outputs": len(self.tool_outputs.all_outputs),
            "unique_tools": len(self.tool_outputs.final_outputs),
            "total_size_chars": self.tool_outputs.get_total_size(),
            "estimated_tokens": int(self.tool_outputs.get_total_size() / self.chars_per_token),
        }

    def clear_tool_outputs(self) -> None:
        """Clear collected tool outputs."""
        self.tool_outputs.clear()


def create_context_manager(
    model: str = DEFAULT_AI_MODEL,
    backup_dir: Path | None = None,
) -> ContextWindowManager:
    """Factory function to create a context window manager.

    Args:
        model: Model name for context limit lookup
        backup_dir: Directory for storing context backups

    Returns:
        Configured ContextWindowManager instance
    """
    return ContextWindowManager(model=model, backup_dir=backup_dir)


__all__ = [
    "ContextWindowManager",
    "CompactionStrategy",
    "TokenEstimate",
    "ContextBackup",
    "ToolOutputCollection",
    "create_context_manager",
]
