"""
Auto-compacting tool for handling conversations with massive tool results.

Automatically triggers when conversation has massive tool results that would
prevent resumption, compacting them to allow the conversation to continue.
"""

import logging
import re
import time
from collections.abc import Generator
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Literal

from .. import llm
from ..context import strip_reasoning
from ..hooks import HookType, StopPropagation, trigger_hook
from ..llm.models import get_default_model, get_model
from ..logmanager import Log, prepare_messages
from ..message import Message, len_tokens
from ..util.context import md_codeblock
from ..util.master_context import (
    MessageByteRange,
    build_master_context_index,
    create_master_context_reference,
)
from ..util.output_storage import create_tool_result_summary
from ..util.reduce import reduce_log
from .base import ToolSpec

if TYPE_CHECKING:
    from ..logmanager import LogManager

logger = logging.getLogger(__name__)

# Reentrancy guard to prevent infinite loops
_last_autocompact_time = 0.0
_autocompact_min_interval = 60  # Minimum 60 seconds between autocompact attempts

# Minimum savings threshold - don't compact unless we can achieve meaningful savings
# This prevents triggering compaction that only saves a few percent (not worth cache invalidation)
MIN_SAVINGS_RATIO = 0.10  # Require at least 10% savings to justify compaction

CompactAction = Literal["none", "rule_based", "summarize"]


# --- Enhanced Scoring Patterns (Issue #149) ---
# Semantic patterns for value-aware retention
# These patterns identify high-value content that should be preserved during compression
# Pre-compiled at module level for performance

# Decision patterns - highest value (+2.0)
_DECISION_PATTERNS = [
    re.compile(r"\bwe('ll| will) use\b", re.IGNORECASE),
    re.compile(r"\bdecided to\b", re.IGNORECASE),
    re.compile(r"\bgoing with\b", re.IGNORECASE),
    re.compile(r"\bchoosing\b", re.IGNORECASE),
    re.compile(r"\bsolution is\b", re.IGNORECASE),
    re.compile(r"\bapproach is\b", re.IGNORECASE),
    re.compile(r"\bwe chose\b", re.IGNORECASE),
]

# Conclusion patterns (+1.5)
_CONCLUSION_PATTERNS = [
    re.compile(r"\btherefore\b", re.IGNORECASE),
    re.compile(r"\bin summary\b", re.IGNORECASE),
    re.compile(r"\bthe result is\b", re.IGNORECASE),
    re.compile(r"\bthis means\b", re.IGNORECASE),
    re.compile(r"\bconfirmed that\b", re.IGNORECASE),
    re.compile(r"\bin conclusion\b", re.IGNORECASE),
    re.compile(r"\bkey finding\b", re.IGNORECASE),
]

# Commitment patterns (+1.5)
# NOTE: More specific patterns to reduce false positives from generic "i will" usage
_COMMITMENT_PATTERNS = [
    re.compile(r"\bi'll\b", re.IGNORECASE),  # Contraction is usually commitment
    re.compile(
        r"\bi will (implement|create|fix|add|update|write|build)\b", re.IGNORECASE
    ),
    re.compile(r"\bnext steps?:?", re.IGNORECASE),  # Colon optional
    re.compile(r"\baction items?:?", re.IGNORECASE),  # Colon optional
    re.compile(r"\btodo:", re.IGNORECASE),
    re.compile(r"\bwill implement\b", re.IGNORECASE),
    re.compile(r"\bplan to\b", re.IGNORECASE),
    re.compile(
        r"\bgoing to (implement|create|fix|add|update|write|build)\b", re.IGNORECASE
    ),
]

# Action result patterns (+1.0)
_ACTION_RESULT_PATTERNS = [
    re.compile(r"\bcreated file\b", re.IGNORECASE),
    re.compile(r"\bfixed\b", re.IGNORECASE),
    re.compile(r"\bupdated\b", re.IGNORECASE),
    re.compile(r"\bimplemented\b", re.IGNORECASE),
    re.compile(r"\bcompleted\b", re.IGNORECASE),
    re.compile(r"\bmerged\b", re.IGNORECASE),
]

# Reference patterns - content likely to be referenced later
# Unix paths: /path/to/file.ext, ~/path/to/file, /usr/bin/script (extension optional)
# Windows paths: C:\path\to\file.ext, C:/path/to/file (extension optional)
_FILE_PATH_PATTERN = re.compile(
    r"(?:[/~][a-zA-Z0-9_\-./]+(?:\.[a-zA-Z0-9]+)?|[A-Za-z]:[/\\][a-zA-Z0-9_\-./\\]+(?:\.[a-zA-Z0-9]+)?)"
)
_URL_PATTERN = re.compile(r'https?://[^\s<>"\')]+')
_ERROR_INDICATOR_PATTERNS = [
    re.compile(r"\b(error|exception|traceback)\b", re.IGNORECASE),
    re.compile(r"\bfailed\b", re.IGNORECASE),
    re.compile(r"\bfailure\b", re.IGNORECASE),
]


def _score_semantic_importance(sentence: str) -> float:
    """
    Score sentence based on semantic content type.

    High-value content types:
    - Decisions: +2.0
    - Conclusions: +1.5
    - Commitments: +1.5
    - Action results: +1.0
    """
    score = 0.0

    # Decision patterns (highest value)
    # Patterns are pre-compiled with IGNORECASE, so no need to lowercase
    for pattern in _DECISION_PATTERNS:
        if pattern.search(sentence):
            score += 2.0
            break  # Don't double-count

    # Conclusion patterns
    for pattern in _CONCLUSION_PATTERNS:
        if pattern.search(sentence):
            score += 1.5
            break

    # Commitment patterns
    for pattern in _COMMITMENT_PATTERNS:
        if pattern.search(sentence):
            score += 1.5
            break

    # Action result patterns
    for pattern in _ACTION_RESULT_PATTERNS:
        if pattern.search(sentence):
            score += 1.0
            break

    return score


def _score_reference_potential(sentence: str) -> float:
    """
    Score sentence based on likelihood of being referenced later.

    High-reference content:
    - File paths (Unix and Windows): +1.0
    - URLs: +0.5
    - Error messages: +1.5
    """
    score = 0.0

    # File paths (Unix: /path/file.ext, ~/path, Windows: C:\path\file.ext)
    if _FILE_PATH_PATTERN.search(sentence):
        score += 1.0

    # URLs
    if _URL_PATTERN.search(sentence):
        score += 0.5

    # Error indicators (pre-compiled with IGNORECASE)
    for pattern in _ERROR_INDICATOR_PATTERNS:
        if pattern.search(sentence):
            score += 1.5
            break

    return score


def extract_code_blocks(content: str) -> tuple[str, list[tuple[str, str]]]:
    """
    Extract code blocks from content, returning cleaned content and blocks.

    Args:
        content: Message content with potential code blocks

    Returns:
        Tuple of (content without code blocks, list of (marker, code block) tuples)
    """
    code_blocks: list[tuple[str, str]] = []
    code_block_pattern = r"```[\s\S]*?```"

    def replacer(match):
        marker = f"__CODE_BLOCK_{len(code_blocks)}__"
        code_blocks.append((marker, match.group(0)))
        return marker

    cleaned = re.sub(code_block_pattern, replacer, content)
    return cleaned, code_blocks


def score_sentence(sentence: str, position: int, total: int) -> float:
    """
    Score sentence importance using heuristics and semantic patterns.

    Higher scores for:
    - Sentences at beginning/end (positional bias)
    - Sentences with key terms
    - Shorter sentences (more information-dense)
    - Decisions, conclusions, and commitments (semantic patterns)
    - File paths, URLs, and error messages (reference potential)

    Args:
        sentence: The sentence to score
        position: Position in message (0-indexed)
        total: Total number of sentences

    Returns:
        Importance score (higher = more important)
    """
    score = 0.0

    # Positional bias: first and last sentences more important
    if position == 0:
        score += 2.0
    elif position == total - 1:
        score += 1.5
    elif position < 3:
        score += 1.0

    # Key term presence
    key_terms = [
        "error",
        "fail",
        "success",
        "complete",
        "implement",
        "fix",
        "bug",
        "issue",
        "result",
        "output",
        "TODO",
        "FIXME",
        "NOTE",
        "WARNING",
    ]
    lower_sentence = sentence.lower()
    for term in key_terms:
        if term.lower() in lower_sentence:
            score += 0.5

    # Length penalty: prefer shorter, denser sentences
    # But not too short (less than 10 chars is probably not useful)
    length = len(sentence)
    if length < 10:
        score -= 1.0
    elif length < 50:
        score += 0.3
    elif length > 200:
        score -= 0.2

    # Enhanced scoring: semantic importance and reference potential (Issue #149)
    # These patterns help preserve decisions, conclusions, file paths, etc.
    score += _score_semantic_importance(sentence)
    score += _score_reference_potential(sentence)

    return score


def compress_content(content: str, target_ratio: float = 0.7) -> str:
    """
    Compress content using extractive summarization.

    Preserves:
    - Code blocks (always kept)
    - Important sentences based on scoring
    - Overall structure

    Args:
        content: Content to compress
        target_ratio: Target length as ratio of original (0.7 = 30% reduction)

    Returns:
        Compressed content
    """
    # Extract and preserve code blocks
    cleaned, code_blocks = extract_code_blocks(content)

    # Split into sentences (simple split on . ! ?)
    sentences = re.split(r"(?<=[.!?])\s+", cleaned)
    if len(sentences) <= 3:
        # Too few sentences to compress meaningfully
        return content

    # Keep sentences that contain code block markers (don't score them)
    marker_sentences = []
    scoreable_sentences = []
    for i, sent in enumerate(sentences):
        if "__CODE_BLOCK_" in sent:
            marker_sentences.append((i, sent))
        else:
            scoreable_sentences.append((i, sent))

    # Score scoreable sentences
    scored = [
        (score_sentence(sent, i, len(sentences)), i, sent)
        for i, sent in scoreable_sentences
    ]

    # Sort by score (keep highest scoring)
    scored.sort(key=lambda x: x[0], reverse=True)

    # Select top sentences to meet target ratio (excluding marker sentences)
    target_count = max(2, int(len(scoreable_sentences) * target_ratio))
    selected = scored[:target_count]

    # Combine selected sentences with marker sentences and sort by original position
    all_selected = [(i, sent) for _, i, sent in selected] + marker_sentences
    all_selected.sort(key=lambda x: x[0])

    # Reconstruct compressed content
    compressed = " ".join(sent for _, sent in all_selected)

    # Restore code blocks
    for marker, code_block in code_blocks:
        compressed = compressed.replace(marker, code_block)

    return compressed


def auto_compact_log(
    log: list[Message],
    limit: int | None = None,
    max_tool_result_tokens: int = 2000,
    reasoning_strip_age_threshold: int = 5,
    logdir: Path | None = None,
) -> Generator[Message, None, None]:
    """
    Auto-compact log for conversations with massive tool results.

    More aggressive than reduce_log - implements strategic removal:
    1. Strips reasoning tags from older messages (age-based)
    2. Removes massive tool results (largest-first strategy for efficiency)
    3. Extractive compression for long assistant messages (Phase 3)

    The "truncate largest first" strategy (inspired by oh-my-opencode) ensures we
    achieve target token reduction with minimal information loss by prioritizing
    the largest outputs for truncation.

    Master Context Architecture:
    The original conversation.jsonl serves as the master context - an append-only
    log that is never compacted. When truncating content, we include byte range
    references to the master context for exact recovery. This allows aggressive
    compaction while preserving full context accessibility.

    Args:
        log: List of messages to compact
        limit: Token limit (defaults to 80% of model context)
        max_tool_result_tokens: Maximum tokens allowed in a tool result before removal
        reasoning_strip_age_threshold: Strip reasoning from messages >N positions back
        logdir: Path to conversation directory for saving removed outputs
    """

    # Build master context index for byte-range references
    master_context_index: list[MessageByteRange] = []
    master_logfile: Path | None = None
    if logdir:
        master_logfile = logdir / "conversation.jsonl"
        if master_logfile.exists():
            master_context_index = build_master_context_index(master_logfile)
            logger.debug(
                f"Built master context index with {len(master_context_index)} entries"
            )

    # get the token limit
    model = get_default_model() or get_model("gpt-4")
    if limit is None:
        limit = int(0.8 * model.context)

    # if we are below the limit AND don't need compacting, return the log as-is
    tokens = len_tokens(log, model=model.model)
    close_to_limit = tokens >= int(0.7 * model.context)

    # Calculate message positions from end (for age-based reasoning stripping)
    log_length = len(log)

    # Check if any reasoning stripping is needed
    needs_reasoning_strip = any(
        (log_length - idx - 1) >= reasoning_strip_age_threshold
        and ("<think>" in msg.content or "<thinking>" in msg.content)
        for idx, msg in enumerate(log)
    )
    needs_compacting = tokens > limit or close_to_limit

    # Check if any messages need Phase 3 compression
    needs_phase3_compression = any(
        (log_length - idx - 1) >= 3  # Don't compress very recent messages
        and msg.role == "assistant"  # Only compress assistant responses
        and len_tokens(msg.content, model.model) > 1000  # Only compress long messages
        for idx, msg in enumerate(log)
    )

    # Only return early if nothing needs processing
    if (
        not needs_reasoning_strip
        and not needs_compacting
        and not needs_phase3_compression
    ):
        yield from log
        return

    if needs_compacting:
        logger.info(f"Auto-compacting log: {tokens} tokens exceeds limit of {limit}")
    if needs_reasoning_strip:
        logger.info(
            f"Stripping reasoning from messages beyond threshold {reasoning_strip_age_threshold}"
        )

    # Phase 1: Strategic reasoning stripping for older messages
    # Process all messages first to strip reasoning
    compacted_log: list[Message] = []
    reasoning_tokens_saved = 0

    for idx, msg in enumerate(log):
        if msg.pinned:
            compacted_log.append(msg)
            continue

        distance_from_end = log_length - idx - 1

        # Strip reasoning from messages beyond the threshold
        if distance_from_end >= reasoning_strip_age_threshold:
            stripped_content, reasoning_saved = strip_reasoning(
                msg.content, model.model
            )
            if reasoning_saved > 0:
                msg = msg.replace(content=stripped_content)
                reasoning_tokens_saved += reasoning_saved
                logger.debug(
                    f"Stripped reasoning from message {idx}: "
                    f"saved {reasoning_saved} tokens (distance from end: {distance_from_end})"
                )

        compacted_log.append(msg)

    # Phase 2: Truncate largest tool results first (oh-my-opencode strategy)
    # Instead of truncating all large results in order, prioritize the largest
    # This achieves target reduction with minimal information loss
    tool_result_tokens_saved = 0
    compression_tokens_saved = 0
    current_tokens = len_tokens(compacted_log, model.model)
    target_tokens = int(0.8 * model.context)  # Target 80% of context

    if current_tokens > target_tokens:
        # Identify all candidate tool results for truncation (with original indices)
        candidates: list[tuple[int, int, Message]] = []  # (idx, tokens, msg)
        for idx, msg in enumerate(compacted_log):
            if msg.pinned:
                continue
            if msg.role == "system":
                msg_tokens = len_tokens(msg.content, model.model)
                if msg_tokens > max_tool_result_tokens:
                    candidates.append((idx, msg_tokens, msg))

        # Sort by token count descending (largest first)
        candidates.sort(key=lambda x: x[1], reverse=True)

        # Truncate largest outputs until under target
        truncated_indices: set[int] = set()
        for idx, msg_tokens, msg in candidates:
            if current_tokens <= target_tokens:
                logger.info(
                    f"Reached target tokens ({current_tokens} <= {target_tokens}), "
                    f"stopping truncation early"
                )
                break

            # Replace with a brief summary message
            summary_content = create_tool_result_summary(
                content=msg.content,
                original_tokens=msg_tokens,
                logdir=logdir,
                tool_name="autocompact",
            )

            # Add master context reference for exact recovery
            # Note: idx must match the message position in conversation.jsonl
            # This is safe because Phase 1-2 preserve message positions (1:1 mapping)
            if master_logfile and idx < len(master_context_index):
                byte_range = master_context_index[idx]
                # Get first line as preview
                preview = msg.content.split("\n")[0] if msg.content else None
                master_ref = create_master_context_reference(
                    logfile=master_logfile,
                    byte_range=byte_range,
                    original_tokens=msg_tokens,
                    preview=preview,
                )
                summary_content += f"\n\n{master_ref}"
                logger.debug(
                    f"Added master context reference for idx {idx}: "
                    f"bytes {byte_range.byte_start}-{byte_range.byte_end}"
                )

            summary_msg = msg.replace(content=summary_content)
            compacted_log[idx] = summary_msg
            truncated_indices.add(idx)

            saved = msg_tokens - len_tokens(summary_content, model.model)
            tool_result_tokens_saved += saved
            current_tokens -= saved
            logger.debug(
                f"Truncated largest tool result at idx {idx}: "
                f"{msg_tokens} -> {len_tokens(summary_content, model.model)} tokens "
                f"(saved {saved}, now at {current_tokens} tokens)"
            )

    # Phase 3: Extractive compression for long assistant messages
    compacted_log_len = len(compacted_log)
    for idx, msg in enumerate(compacted_log):
        if msg.pinned:
            continue

        distance_from_end = compacted_log_len - idx - 1
        msg_tokens = len_tokens(msg.content, model.model)

        # Compress messages >1000 tokens that aren't very recent
        if (
            distance_from_end >= 3  # Don't compress very recent messages
            and msg.role == "assistant"  # Only compress assistant responses
            and msg_tokens > 1000  # Only compress long messages
        ):
            compressed_content = compress_content(msg.content, target_ratio=0.7)
            compressed_tokens = len_tokens(compressed_content, model.model)

            if compressed_tokens < msg_tokens:
                # Add master context reference for exact recovery
                # Note: idx must match the message position in conversation.jsonl
                # This is safe because Phases 1-3 preserve message positions (1:1 mapping)
                if master_logfile and idx < len(master_context_index):
                    byte_range = master_context_index[idx]
                    # Get first line as preview
                    preview = msg.content.split("\n")[0] if msg.content else None
                    master_ref = create_master_context_reference(
                        logfile=master_logfile,
                        byte_range=byte_range,
                        original_tokens=msg_tokens,
                        preview=preview,
                    )
                    compressed_content += f"\n\n{master_ref}"
                    logger.debug(
                        f"Added master context reference for compressed idx {idx}"
                    )

                compacted_log[idx] = msg.replace(content=compressed_content)
                compression_saved = msg_tokens - compressed_tokens
                compression_tokens_saved += compression_saved
                logger.debug(
                    f"Compressed message {idx}: {msg_tokens} -> {compressed_tokens} tokens "
                    f"({compression_saved} saved, {(compression_saved / msg_tokens) * 100:.1f}% reduction)"
                )

    # Check if we're now within limits
    final_tokens = len_tokens(compacted_log, model.model)
    total_saved = (
        tool_result_tokens_saved + compression_tokens_saved + reasoning_tokens_saved
    )
    if final_tokens <= limit:
        # Calculate reduction percentage
        reduction_pct = ((tokens - final_tokens) / tokens * 100) if tokens > 0 else 0.0

        # Build detailed breakdown message
        breakdown_parts = []
        if reasoning_tokens_saved > 0:
            pct = (reasoning_tokens_saved / total_saved * 100) if total_saved > 0 else 0
            breakdown_parts.append(
                f"reasoning: {reasoning_tokens_saved:,} ({pct:.0f}%)"
            )
        if tool_result_tokens_saved > 0:
            pct = (
                (tool_result_tokens_saved / total_saved * 100) if total_saved > 0 else 0
            )
            breakdown_parts.append(
                f"tool results: {tool_result_tokens_saved:,} ({pct:.0f}%)"
            )
        if compression_tokens_saved > 0:
            pct = (
                (compression_tokens_saved / total_saved * 100) if total_saved > 0 else 0
            )
            breakdown_parts.append(
                f"compression: {compression_tokens_saved:,} ({pct:.0f}%)"
            )

        breakdown_str = ", ".join(breakdown_parts) if breakdown_parts else "no savings"
        logger.info(
            f"Auto-compacting successful: {tokens:,} -> {final_tokens:,} tokens "
            f"({reduction_pct:.1f}% reduction, saved {total_saved:,} tokens) "
            f"[{breakdown_str}]"
        )
        yield from compacted_log
        return

    # If still over limit, fall back to regular reduction
    logger.info("Auto-compacting not sufficient, falling back to regular reduction")

    yield from reduce_log(compacted_log, limit)


def estimate_compaction_savings(
    log: list[Message],
    limit: int | None = None,
    max_tool_result_tokens: int = 2000,
    reasoning_strip_age_threshold: int = 5,
    assistant_compression_age_threshold: int = 3,
    assistant_compression_min_tokens: int = 1000,
) -> tuple[int, int, int]:
    """
    Estimate potential savings from auto-compaction without actually compacting.

    Args:
        log: The conversation log to estimate savings for
        limit: Token limit (defaults to 90% of model context)
        max_tool_result_tokens: Threshold for "massive" tool results
        reasoning_strip_age_threshold: Distance from end before stripping reasoning
        assistant_compression_age_threshold: Distance from end before compressing assistant messages
        assistant_compression_min_tokens: Minimum tokens for assistant message compression

    Returns:
        Tuple of (total_tokens, estimated_savings, savings_from_reasoning)

    This allows us to decide if compaction is worth the cost (cache invalidation)
    before actually triggering it.

    Note: Estimation matches actual compaction logic:
    - Phase 1: Reasoning stripping (always applied to old messages)
    - Phase 2: Tool result removal (only when over/close to limit)
    - Phase 3: Assistant message compression (only when over/close to limit)
    """

    model = get_default_model() or get_model("gpt-4")
    total_tokens = len_tokens(log, model.model)
    log_length = len(log)

    if limit is None:
        limit = int(0.9 * model.context)

    # Match actual compaction logic: only remove tool results when over/close to limit
    close_to_limit = total_tokens >= int(0.8 * model.context)
    would_remove_tool_results = total_tokens > limit or close_to_limit

    estimated_tool_result_savings = 0
    estimated_reasoning_savings = 0
    estimated_compression_savings = 0

    for idx, msg in enumerate(log):
        if msg.pinned:
            continue

        msg_tokens = len_tokens(msg.content, model.model)
        distance_from_end = log_length - idx - 1

        # Phase 1: Estimate reasoning stripping savings (always applied to old messages)
        if distance_from_end >= reasoning_strip_age_threshold:
            if "<think>" in msg.content or "<thinking>" in msg.content:
                # Rough estimate: reasoning usually ~30-50% of content
                estimated_reasoning_savings += int(msg_tokens * 0.3)

        # Phase 2: Estimate massive tool result removal savings
        # CRITICAL: Only count if we would actually remove tool results
        if (
            would_remove_tool_results
            and msg.role == "system"
            and msg_tokens > max_tool_result_tokens
        ):
            # Tool results get reduced to summary (~200 tokens)
            estimated_tool_result_savings += msg_tokens - 200

        # Phase 3: Estimate assistant message compression savings
        # Only for older messages (distance >= threshold) with enough tokens
        if (
            would_remove_tool_results
            and distance_from_end >= assistant_compression_age_threshold
            and msg.role == "assistant"
            and msg_tokens > assistant_compression_min_tokens
        ):
            # Compression targets 70% of original, so saves ~30%
            estimated_compression_savings += int(msg_tokens * 0.3)

    total_estimated_savings = (
        estimated_tool_result_savings
        + estimated_reasoning_savings
        + estimated_compression_savings
    )
    return total_tokens, total_estimated_savings, estimated_reasoning_savings


def should_auto_compact(log: list[Message], limit: int | None = None) -> CompactAction:
    """
    Check if a log should be auto-compacted.

    Returns:
        "rule_based" if the log would benefit from rule-based auto-compacting,
        "summarize" if the conversation is over-limit but rule-based savings are too low
            (LLM-powered summarization should be used instead),
        "none" if no compaction is needed.

    Auto-compacting is triggered when:
    1. The conversation exceeds the limit, OR
    2. The conversation is close to the limit (80%+) AND contains massive tool results
    3. AND estimated savings exceed MIN_SAVINGS_RATIO (to justify cache invalidation)
    """

    model = get_default_model() or get_model("gpt-4")
    if limit is None:
        limit = int(0.9 * model.context)

    total_tokens = len_tokens(log, model.model)
    close_to_limit = total_tokens >= int(
        0.5 * model.context
    )  # 50% threshold (more proactive)

    # Check if there are any massive system messages (tool results)
    has_massive_tool_result = False
    for msg in log:
        if not msg.pinned and msg.role == "system":
            msg_tokens = len_tokens(msg.content, model.model)
            if msg_tokens > 2000:  # Threshold for "massive"
                has_massive_tool_result = True
                break

    # First check: would we trigger based on token count?
    would_trigger = total_tokens > limit or (close_to_limit and has_massive_tool_result)

    if not would_trigger:
        return "none"

    # Second check: estimate if savings would be worth it
    total, estimated_savings, reasoning_savings = estimate_compaction_savings(
        log, limit
    )
    savings_ratio = estimated_savings / total if total > 0 else 0

    if savings_ratio < MIN_SAVINGS_RATIO:
        logger.info(
            f"Skipping rule-based auto-compact: estimated savings {savings_ratio:.1%} "
            f"({estimated_savings} tokens) below threshold {MIN_SAVINGS_RATIO:.0%}. "
            f"Will use LLM-powered summarization instead."
        )
        return "summarize"

    logger.debug(
        f"Auto-compact viable: estimated {savings_ratio:.1%} savings "
        f"({estimated_savings} tokens, {reasoning_savings} from reasoning)"
    )
    return "rule_based"


def cmd_compact_handler(ctx) -> Generator[Message, None, None]:
    """Command handler for /compact - compact the conversation using auto-compacting or LLM-powered resume generation."""

    ctx.manager.undo(1, quiet=True)

    # Parse arguments
    method = ctx.args[0] if ctx.args else "auto"

    if method not in ["auto", "resume"]:
        yield Message(
            "system",
            "Invalid compact method. Use 'auto' for auto-compacting or 'resume' for LLM-powered resume generation.\n"
            "Usage: /compact [auto|resume]",
        )
        return

    msgs = ctx.manager.log.messages[:-1]  # Exclude the /compact command itself

    if method == "auto":
        yield from _compact_auto(ctx, msgs)
    elif method == "resume":
        yield from _compact_resume(ctx, msgs)


def _compact_auto(ctx, msgs: list[Message]) -> Generator[Message, None, None]:
    """Auto-compact using the aggressive compacting algorithm."""

    if should_auto_compact(msgs) != "rule_based":
        yield Message(
            "system",
            "Auto-compacting not needed. Conversation doesn't contain massive tool results or isn't close to context limits.",
        )
        return

    # Apply auto-compacting
    compacted_msgs = list(auto_compact_log(msgs, logdir=ctx.manager.logdir))

    # Calculate reduction stats
    original_count = len(msgs)
    compacted_count = len(compacted_msgs)
    m = get_default_model()
    original_tokens = len_tokens(msgs, m.model) if m else 0
    compacted_tokens = len_tokens(compacted_msgs, m.model) if m else 0

    # Replace the conversation history
    ctx.manager.log = Log(compacted_msgs)
    ctx.manager.write()

    reduction_pct = (
        ((original_tokens - compacted_tokens) / original_tokens * 100)
        if original_tokens > 0
        else 0.0
    )
    yield Message(
        "system",
        f"✅ Auto-compacting completed:\n"
        f"• Messages: {original_count} → {compacted_count}\n"
        f"• Tokens: {original_tokens:,} → {compacted_tokens:,} "
        f"({reduction_pct:.1f}% reduction)",
    )


def _parse_context_files(content: str) -> list[str]:
    """
    Parse file paths from the LLM-generated resume.

    Looks for a "Context Files" or "Files to Include" section and extracts
    file paths that start with / or ./ or are relative paths.

    Args:
        content: The LLM-generated resume content

    Returns:
        List of file paths found in the response
    """
    import re

    file_paths: list[str] = []

    # Find the Context Files section (case-insensitive)
    # Look for patterns like "## Context Files" or "### Files to Include"
    context_section_pattern = r"(?:#{1,4}\s*(?:Context Files|Files to Include|Recommended Files|Key Files)[^\n]*\n)([\s\S]*?)(?=\n#{1,4}\s|\Z)"
    match = re.search(context_section_pattern, content, re.IGNORECASE)

    if match:
        section_content = match.group(1)
    else:
        # If no explicit section, scan the whole content
        section_content = content

    # Extract file paths from markdown list items
    # Matches: - `/path/to/file.py` or - `./relative/path.md` or - path/to/file.txt
    # Also matches: - `/path/to/file.py` - description
    path_patterns = [
        # Backtick-wrapped paths: - `path/to/file`
        r"[-*]\s*`([^`]+)`",
        # Paths starting with / or ./ or ~/
        r"[-*]\s*([/~.][\w./\-]+(?:\.\w+)?)",
        # Relative paths without leading dot (common patterns)
        r"[-*]\s*((?:src|docs|tests|config|scripts|tasks|journal|knowledge|lessons)/[\w./\-]+(?:\.\w+)?)",
    ]

    for pattern in path_patterns:
        matches = re.findall(pattern, section_content)
        for m in matches:
            path = m.strip()
            # Filter out common false positives
            if path and not path.startswith("http") and not path.startswith("#"):
                # Normalize path
                path = path.removeprefix("./")
                file_paths.append(path)

    # Remove duplicates while preserving order
    seen = set()
    unique_paths = []
    for p in file_paths:
        if p not in seen:
            seen.add(p)
            unique_paths.append(p)

    return unique_paths


def _load_context_files(
    file_paths: list[str],
    workspace: Path | None = None,
    max_tokens_per_file: int = 2000,
) -> list[tuple[str, str]]:
    """
    Load contents of specified files that exist.

    Args:
        file_paths: List of file paths to load
        workspace: Workspace directory for resolving relative paths
        max_tokens_per_file: Maximum tokens to include per file

    Returns:
        List of (path, content) tuples for files that exist and are readable
    """
    loaded_files: list[tuple[str, str]] = []
    workspace_path = workspace or Path.cwd()

    for file_path in file_paths:
        # Resolve path
        if file_path.startswith("/"):
            full_path = Path(file_path)
        elif file_path.startswith("~"):
            full_path = Path(file_path).expanduser()
        else:
            full_path = workspace_path / file_path

        try:
            if full_path.exists() and full_path.is_file():
                content = full_path.read_text(encoding="utf-8", errors="replace")

                # Truncate if too long
                tokens = len_tokens(content, "gpt-4")
                if tokens > max_tokens_per_file:
                    # Simple truncation with note
                    lines = content.split("\n")
                    truncated_lines = []
                    current_tokens = 0
                    for line in lines:
                        line_tokens = len_tokens(line, "gpt-4")
                        if current_tokens + line_tokens > max_tokens_per_file - 50:
                            truncated_lines.append(
                                f"\n... (truncated, {tokens - current_tokens} tokens remaining)"
                            )
                            break
                        truncated_lines.append(line)
                        current_tokens += line_tokens
                    content = "\n".join(truncated_lines)

                loaded_files.append((str(file_path), content))
                logger.info(
                    f"Loaded context file: {file_path} ({len_tokens(content, 'gpt-4')} tokens)"
                )
        except Exception as e:
            logger.warning(f"Could not load context file {file_path}: {e}")

    return loaded_files


def _resume_via_llm(
    manager: "LogManager",
    msgs: list[Message],
    use_view_branch: bool = False,
) -> Generator[Message, None, None]:
    """Core LLM-powered resume logic: summarize conversation and replace history.

    Args:
        manager: LogManager that owns the conversation.
        msgs: Messages to summarize.
        use_view_branch: If True, create a view branch (for auto-triggered resume)
            and mark status messages as hidden. If False, replace the log directly
            (for user-invoked /compact resume).
    """

    # Prepare messages for summarization
    prepared_msgs = prepare_messages(msgs)

    if len(prepared_msgs) < 3:
        yield Message(
            "system",
            "Not enough conversation history to create a meaningful resume.",
            hide=use_view_branch,
        )
        return

    # Generate conversation summary using LLM
    yield Message(
        "system",
        "🔄 Generating conversation resume with LLM...",
        hide=use_view_branch,
    )

    resume_prompt = """Please create a comprehensive resume of this conversation that includes:

1. **Conversation Summary**: Key topics, decisions made, and progress achieved
2. **Technical Context**: Important code changes, configurations, or technical details
3. **Current State**: What was accomplished and what remains to be done
4. **Context Files**: List the specific files that should be included in future context

For the Context Files section, use this format:
## Context Files

List each file on its own line with a bullet point and backticks:
- `path/to/file.py` - Brief rationale for including this file
- `docs/spec.md` - Contains the specification being implemented

Include files such as:
- Specs, PRDs, or design documents being referenced
- Source files being actively modified
- Configuration files relevant to the work
- Task or plan files tracking progress

Format the response as a structured document that could serve as a RESUME.md file."""

    # Create a temporary message for the LLM prompt
    resume_request = Message("user", resume_prompt)
    # Use full prepared messages for prompt caching friendliness
    llm_msgs = prepared_msgs + [resume_request]

    # Generate the resume using LLM
    m = get_default_model()
    if not m:
        yield Message(
            "system",
            "❌ Failed to generate resume: No default model configured. "
            "Set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.",
            hide=use_view_branch,
        )
        return
    resume_response = llm.reply(llm_msgs, model=m.full, tools=[], workspace=None)
    resume_content = resume_response.content

    # Save RESUME.md to logdir (not workspace) for reference/debugging
    resume_path: Path | None = None
    if manager.logdir:
        resume_path = manager.logdir / "RESUME.md"
        try:
            with open(resume_path, "w") as f:
                f.write(resume_content)
            logger.info(f"Saved resume to {resume_path}")
        except Exception as e:
            logger.warning(f"Failed to save resume file: {e}")
            resume_path = None

    # Parse and load context files suggested by the LLM
    suggested_files = _parse_context_files(resume_content)
    workspace = manager.workspace
    loaded_files = _load_context_files(suggested_files, workspace=workspace)

    # Extract original system messages (before any user/assistant messages)
    # These contain essential context: core prompt, tool instructions, workspace info
    original_system_msgs = []
    for msg in msgs:
        if msg.role == "system":
            original_system_msgs.append(msg)
        elif msg.role in ("user", "assistant"):
            # Stop when we hit the first non-system message
            break

    # Create file context messages for each loaded file
    file_context_msgs = []
    for file_path, file_content in loaded_files:
        file_msg = Message(
            "system",
            f"Context file `{file_path}`:\n{md_codeblock('', file_content)}",
        )
        file_context_msgs.append(file_msg)

    # Create the resume intro message
    files_note = ""
    if loaded_files:
        files_note = f" (with {len(loaded_files)} context files)"
    resume_source = str(resume_path) if resume_path else "LLM-generated summary"
    resume_intro_msg = Message(
        "system", f"Previous conversation resumed from {resume_source}{files_note}:"
    )
    resume_msg = Message("assistant", resume_content)

    new_log = original_system_msgs + file_context_msgs + [resume_intro_msg, resume_msg]

    if use_view_branch:
        view_name = manager.get_next_view_name()
        manager.create_view(view_name, new_log)
        manager.switch_view(view_name)
    else:
        # Replace the log directly (user-invoked /compact resume)
        manager.log = Log(new_log)
        manager.write()

    # Build status message
    files_loaded_str = ""
    if loaded_files:
        files_loaded_str = f"• Context files loaded: {len(loaded_files)}\n"
        for fp, _ in loaded_files[:5]:  # Show first 5
            files_loaded_str += f"  - {fp}\n"
        if len(loaded_files) > 5:
            files_loaded_str += f"  ... and {len(loaded_files) - 5} more\n"
    elif suggested_files:
        files_loaded_str = f"• Suggested files not found: {len(suggested_files)}\n"

    view_note = ""
    if use_view_branch:
        view_note = f"• View: {view_name} (master branch preserved with full history)\n"

    resume_note = ""
    if resume_path:
        resume_note = f"• Resume saved to: {resume_path.absolute()}\n"

    yield Message(
        "system",
        f"✅ LLM-powered resume completed:\n"
        f"• Original conversation ({len(prepared_msgs)} messages) compressed to resume\n"
        f"{resume_note}"
        f"{files_loaded_str}"
        f"{view_note}"
        f"• Conversation history replaced with resume",
        hide=use_view_branch,
    )


def _compact_resume(ctx, msgs: list[Message]) -> Generator[Message, None, None]:
    """LLM-powered compact that creates RESUME.md, extracts key files, and starts a new conversation with the context."""

    try:
        yield from _resume_via_llm(ctx.manager, msgs, use_view_branch=False)
    except Exception as e:
        # Include exception type for better debugging when message is empty
        error_msg = str(e).strip() or f"({type(e).__name__})"
        yield Message("system", f"❌ Failed to generate resume: {error_msg}")


def _get_compacted_name(conversation_name: str) -> str:
    """
    Get a unique name for the compacted conversation fork.

    The original conversation stays untouched as the backup.
    The fork gets a new name with timestamp to identify when compaction occurred.

    Strips any existing -compacted-YYYYMMDD-HHMMSS suffixes to prevent accumulation
    on repeated compactions.

    Examples:
    - "my-conversation" -> "my-conversation-compacted-20251029-073045"
    - "my-conversation-compacted-20251029-073045" -> "my-conversation-compacted-20251029-080000"

    Args:
        conversation_name: The current conversation directory name

    Returns:
        The compacted conversation name with timestamp

    Raises:
        ValueError: If conversation_name is empty
    """
    if not conversation_name:
        raise ValueError("conversation name cannot be empty")

    # Strip any existing compacted suffixes: -compacted-YYYYMMDDHHMM
    # This handles repeated compactions by removing previous timestamps
    base_name = conversation_name
    while True:
        # Match -compacted-{8 digits}-{6 digits} pattern
        new_name = re.sub(r"-compacted-\d{8}-\d{6}$", "", base_name)
        if new_name == base_name:  # No more changes
            break
        base_name = new_name

    if not base_name:  # Safety: if entire name was the suffix (shouldn't happen)
        base_name = conversation_name

    timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H%M%S")
    return f"{base_name}-compacted-{timestamp}"


def autocompact_hook(
    manager: "LogManager",
) -> Generator[Message | StopPropagation, None, None]:
    """
    Hook that checks if auto-compacting is needed and applies it.

    Runs after each message is processed to check if the conversation
    has grown too large with massive tool results.

    If compacting is needed:
    1. Creates compacted fork (original stays as backup)
    2. Manager switches to fork automatically
    3. Applies auto-compacting to fork conversation
    4. User continues in compacted conversation

    Args:
        manager: Conversation manager with log and workspace
    """

    global _last_autocompact_time

    # Check if enough time has passed since last autocompact attempt
    current_time = time.time()
    if current_time - _last_autocompact_time < _autocompact_min_interval:
        logger.debug(
            f"Skipping autocompact: {current_time - _last_autocompact_time:.1f}s "
            f"since last attempt (min interval: {_autocompact_min_interval}s)"
        )
        return

    messages = manager.log.messages

    action = should_auto_compact(messages)
    if action == "none":
        return

    _last_autocompact_time = current_time

    if action == "rule_based":
        logger.info("Auto-compacting triggered: conversation has massive tool results")

        # Apply auto-compacting to get compacted messages
        try:
            compacted_msgs = list(auto_compact_log(messages, logdir=manager.logdir))

            # Calculate reduction stats
            m = get_default_model()
            original_count = len(messages)
            compacted_count = len(compacted_msgs)
            original_tokens = len_tokens(messages, m.model) if m else 0
            compacted_tokens = len_tokens(compacted_msgs, m.model) if m else 0

            # Create a view branch with compacted content
            # Master branch (main) stays intact with full history
            view_name = manager.get_next_view_name()
            manager.create_view(view_name, compacted_msgs)
            manager.switch_view(view_name)

            # Trigger CACHE_INVALIDATED hook - perfect time for plugins to update state
            # (e.g., attention-router can batch-apply decay and re-evaluate tiers)
            yield from trigger_hook(
                HookType.CACHE_INVALIDATED,
                manager=manager,
                reason="compact",
                tokens_before=original_tokens,
                tokens_after=compacted_tokens,
            )

            reduction_pct = (
                ((original_tokens - compacted_tokens) / original_tokens * 100)
                if original_tokens > 0
                else 0.0
            )
            # Yield a message indicating what happened
            yield Message(
                "system",
                f"🔄 Auto-compacted conversation to view branch:\n"
                f"• Messages: {original_count} → {compacted_count}\n"
                f"• Tokens: {original_tokens:,} → {compacted_tokens:,} "
                f"({reduction_pct:.1f}% reduction)\n"
                f"• View: {view_name} (master branch preserved with full history)",
                hide=True,  # Hide to prevent triggering responses
            )
        except Exception as e:
            logger.error(f"Auto-compact failed during compaction: {e}")
            # Don't yield error message to avoid triggering more hooks
            return

    elif action == "summarize":
        logger.info("Auto-summarize triggered: rule-based compaction insufficient")
        try:
            m = get_default_model()
            original_tokens = len_tokens(messages, m.model) if m else 0

            yield from _resume_via_llm(manager, messages, use_view_branch=True)

            compacted_tokens = len_tokens(manager.log.messages, m.model) if m else 0

            # Trigger CACHE_INVALIDATED hook — resume is even more aggressive
            # than rule-based compaction, so plugins need to know
            yield from trigger_hook(
                HookType.CACHE_INVALIDATED,
                manager=manager,
                reason="compact",
                tokens_before=original_tokens,
                tokens_after=compacted_tokens,
            )
        except Exception as e:
            logger.error(f"Auto-summarize failed: {e}")
            return


# Tool specification

tool = ToolSpec(
    name="autocompact",
    desc="Automatically compact conversations with massive tool results",
    instructions="",  # No user-facing instructions, runs automatically
    hooks={
        "autocompact": (
            HookType.TURN_POST,
            autocompact_hook,
            100,
        ),  # Low priority, runs after other hooks
    },
    commands={
        "compact": cmd_compact_handler,
    },
)
__doc__ = tool.desc
