"""Session management for chat interface with complete message history tracking."""

import contextlib
import json
import os
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import tiktoken
from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse
from pydantic_ai.usage import RunUsage
from pydantic_core import to_jsonable_python

# Additional imports for session resumption
from rich.console import Console
from uuid_extensions import uuid7str

from libchatinterface.costs import (
    SessionCosts,
    add_usage_to_session,
    calculate_usage_cost,
    format_session_costs_for_metadata,
)


class SessionManager:
    """Manages chat sessions with complete message history and metadata tracking."""

    def __init__(self, app_name: str = "chatinterface", context_window: int | None = None):
        """Initialize session manager with a new session.

        Args:
            app_name: Application name for directory structure (default: "chatinterface")
            context_window: Maximum context window size in tokens (default: from CONTEXT_WINDOW env var or 200,000)
        """
        # Read context window from environment variable if not provided
        if context_window is None:
            context_window = int(os.getenv("CONTEXT_WINDOW", "200000"))
        self.app_name = app_name
        self.sessions_dir = Path.home() / f".{app_name}" / "sessions"
        self.session_id = uuid7str()
        self.session_timestamp = datetime.now(UTC).isoformat().replace(":", "_")  # Keep for metadata
        self.session_dir = self.sessions_dir / self.session_id
        self.metadata_file = self.session_dir / "metadata.json"
        self.history_file = self.session_dir / "history.jsonl"

        # Session tracking
        self.first_user_message: str | None = None
        self.message_count = 0
        self.last_message_timestamp: str | None = None
        self.messages: list[dict[str, Any]] = []

        # Title caching - only generate once
        self._session_title: str | None = None
        self._title_generated = False

        # Cost tracking
        self.session_costs = SessionCosts()

        # Context compression
        self.context_window = context_window
        self.compressed_context: str | None = None
        self._encoding = tiktoken.encoding_for_model("gpt-4")  # Token estimation

        # Create session directory
        self._create_session_directory()

    def _create_session_directory(self) -> None:
        """Create the session directory structure."""
        try:
            self.session_dir.mkdir(parents=True, exist_ok=True)
        except Exception as e:
            # If we can't create the directory, we'll fail silently and continue
            # This prevents the chat from breaking if there are permission issues
            print(f"Warning: Could not create session directory: {e}")

    async def _extract_title(self, message: str) -> str:
        """Extract a meaningful title from the first user message using AI inference.

        Args:
            message: The first user message

        Returns:
            A concise, meaningful title (≤5 words) generated by the title agent
        """
        try:
            # Import here to avoid circular imports
            from libagentic.agents import get_title_agent

            # Create the title generation agent using the established pattern
            title_agent = get_title_agent()

            # Generate title using the agent
            result = await title_agent.run(f"Generate a title for this message: {message}")
            generated_title = result.output.strip()

            # Track usage from title generation
            try:
                title_usage = result.usage()
                title_model = getattr(result, "model_name", None)
                if title_usage:
                    self.log_run_usage(title_usage, title_model)
            except Exception:
                # If we can't get usage data, continue without tracking
                pass

            # Remove quotes if present
            if generated_title.startswith('"') and generated_title.endswith('"'):
                generated_title = generated_title[1:-1].strip()

            # Validate the generated title
            if generated_title and len(generated_title.split()) <= 5:
                return generated_title
            else:
                # Fallback to first 5 words if AI response is invalid
                return " ".join(message.split()[:5])

        except Exception as e:
            # Print error for debugging but continue with fallback
            print(f"AI title generation failed: {e}")
            # Fallback to simple extraction if AI generation fails
            return self._extract_title_fallback(message)

    def _extract_title_fallback(self, message: str) -> str:
        """Fallback title extraction using simple word processing.

        Args:
            message: The user message

        Returns:
            A title with first 5 meaningful words
        """
        # Clean the message and take first 5 words
        clean_message = re.sub(r"[^\w\s]", " ", message)
        words = [word for word in clean_message.split() if word.strip()]

        # Take first 5 words with proper capitalization
        title_words = words[:5]
        if title_words:
            # Capitalize first word, preserve original case for others (like "Python")
            title_words[0] = title_words[0].capitalize()
            title = " ".join(title_words)
            return title

        return "New Chat"

    def _log_message_to_history(self, message_data: dict[str, Any]) -> None:
        """Log a message to the history.jsonl file.

        Args:
            message_data: Message data to log
        """
        try:
            with self.history_file.open("a", encoding="utf-8") as f:
                json_line = json.dumps(message_data, ensure_ascii=False)
                f.write(json_line + "\n")
        except Exception:
            # Silently fail if we can't write to the file
            pass

    def _update_metadata(self, title: str | None = None) -> None:
        """Update the metadata.json file with current session information.

        Args:
            title: Optional pre-generated title to use (only used once)
        """
        with contextlib.suppress(Exception):
            # Set title only once - prioritize provided title
            if title and not self._title_generated:
                self._session_title = title
                self._title_generated = True
            # Don't eagerly generate fallback title - let AI title generation handle this

            # Use current title or placeholder
            current_title = self._session_title or "New Chat"

            metadata = {
                "title": current_title,
                "created_timestamp": self.session_timestamp.replace("_", ":"),  # Convert back to proper ISO format
                "message_count": self.message_count,
                "last_message_timestamp": self.last_message_timestamp,
                "usage": format_session_costs_for_metadata(self.session_costs),
            }

            with self.metadata_file.open("w", encoding="utf-8") as f:
                json.dump(metadata, f, ensure_ascii=False, indent=2)

    async def update_title_with_ai(self) -> None:
        """Update the session title using AI inference after first user message."""
        if not self.first_user_message or self._title_generated:
            return

        try:
            # Generate AI title only if not already generated
            ai_title = await self._extract_title(self.first_user_message)
            # Update metadata with the AI-generated title (this will set _title_generated=True)
            self._update_metadata(title=ai_title)
        except Exception:
            # If AI title generation fails completely, use fallback
            fallback_title = self._extract_title_fallback(self.first_user_message)
            self._update_metadata(title=fallback_title)

    def log_run_usage(self, usage: RunUsage, model_name: str | None = None) -> None:
        """Log token usage and costs from a Pydantic AI run.

        Args:
            usage: RunUsage object from Pydantic AI result
            model_name: Name of the model used (for cost calculation)
        """
        # Calculate costs for this usage
        usage_costs = calculate_usage_cost(usage, model_name)

        # Add to session totals
        add_usage_to_session(self.session_costs, usage_costs)

        # Update metadata to reflect new costs
        self._update_metadata()

    def log_system_prompt(self, system_prompt: str) -> None:
        """Log the system prompt as the first message in the session.

        Args:
            system_prompt: The system prompt content
        """
        timestamp = datetime.now(UTC).isoformat()

        message_data = {
            "timestamp": timestamp,
            "type": "system_prompt",
            "content": system_prompt,
            "message_index": self.message_count,
        }

        self.messages.append(message_data)
        self.message_count += 1
        self.last_message_timestamp = timestamp

        self._log_message_to_history(message_data)
        self._update_metadata()

    def log_user_message(self, message: str) -> None:
        """Log a user message to the session.

        Args:
            message: The user message content
        """
        timestamp = datetime.now(UTC).isoformat()

        # Capture first user message for title generation
        if self.first_user_message is None:
            self.first_user_message = message

        message_data = {
            "timestamp": timestamp,
            "type": "user_message",
            "content": message,
            "message_index": self.message_count,
        }

        self.messages.append(message_data)
        self.message_count += 1
        self.last_message_timestamp = timestamp

        self._log_message_to_history(message_data)
        self._update_metadata()  # Will use fallback title for now

    def log_assistant_response(self, response: str) -> None:
        """Log an assistant response to the session.

        Args:
            response: The assistant response content
        """
        timestamp = datetime.now(UTC).isoformat()

        message_data = {
            "timestamp": timestamp,
            "type": "assistant_response",
            "content": response,
            "message_index": self.message_count,
        }

        self.messages.append(message_data)
        self.message_count += 1
        self.last_message_timestamp = timestamp

        self._log_message_to_history(message_data)
        self._update_metadata()

    def log_pydantic_messages(self, pydantic_messages: list[ModelMessage], skip_user_message: str | None = None) -> None:
        """Log Pydantic AI ModelMessage objects to the session.

        This method extracts and logs all message types from Pydantic AI's message system,
        including system prompts, user messages, and assistant responses.

        Args:
            pydantic_messages: List of Pydantic AI ModelMessage objects
            skip_user_message: Optional user message content to skip (to avoid duplicates)
        """
        for msg in pydantic_messages:
            timestamp = datetime.now(UTC).isoformat()

            if isinstance(msg, ModelRequest):
                # Process each part in the request
                for part in msg.parts:
                    # Handle different part types
                    if part.__class__.__name__ == "ToolReturnPart":
                        # Tool return parts have tool results
                        message_data = {
                            "timestamp": timestamp,
                            "type": "tool_return",
                            "content": str(getattr(part, "content", "")),
                            "message_index": self.message_count,
                            "pydantic_type": part.__class__.__name__,
                            "pydantic_data": to_jsonable_python(part),
                        }
                    elif hasattr(part, "content"):
                        # Determine message type based on part type
                        if part.__class__.__name__ == "SystemPromptPart":
                            msg_type = "system_prompt"
                        elif part.__class__.__name__ == "UserPromptPart":
                            # Check if we should skip this user message to avoid duplicates
                            if skip_user_message and part.content == skip_user_message:
                                continue  # Skip this message as it was already logged
                            
                            msg_type = "user_message"
                            # Capture first user message for title
                            if self.first_user_message is None and hasattr(part, "content"):
                                self.first_user_message = part.content
                        else:
                            msg_type = "request_part"

                        message_data = {
                            "timestamp": timestamp,
                            "type": msg_type,
                            "content": part.content,
                            "message_index": self.message_count,
                            "pydantic_type": part.__class__.__name__,
                            "pydantic_data": to_jsonable_python(part),
                        }
                    else:
                        # Other parts without content
                        continue

                    self.messages.append(message_data)
                    self.message_count += 1
                    self.last_message_timestamp = timestamp

                    self._log_message_to_history(message_data)

            elif isinstance(msg, ModelResponse):
                # Process each part in the response
                for part in msg.parts:
                    # Handle different part types
                    if part.__class__.__name__ == "ToolCallPart":
                        # Tool call parts don't have content but have tool_name and args
                        message_data = {
                            "timestamp": timestamp,
                            "type": "tool_call",
                            "content": f"Tool call: {getattr(part, 'tool_name', 'unknown')}",
                            "message_index": self.message_count,
                            "pydantic_type": part.__class__.__name__,
                            "pydantic_data": to_jsonable_python(part),
                            "model_name": getattr(msg, "model_name", None),
                            "usage": to_jsonable_python(getattr(msg, "usage", None)) if hasattr(msg, "usage") else None,
                        }
                    elif hasattr(part, "content"):
                        # Text parts and other parts with content
                        message_data = {
                            "timestamp": timestamp,
                            "type": "assistant_response",
                            "content": part.content,
                            "message_index": self.message_count,
                            "pydantic_type": part.__class__.__name__,
                            "pydantic_data": to_jsonable_python(part),
                            "model_name": getattr(msg, "model_name", None),
                            "usage": to_jsonable_python(getattr(msg, "usage", None)) if hasattr(msg, "usage") else None,
                        }
                    else:
                        # Other parts without content
                        continue

                    self.messages.append(message_data)
                    self.message_count += 1
                    self.last_message_timestamp = timestamp

                    self._log_message_to_history(message_data)

        # Update metadata after processing all messages
        self._update_metadata()

    def get_session_info(self) -> dict[str, Any]:
        """Get current session information.

        Returns:
            Dictionary with session metadata
        """
        return {
            "session_directory": str(self.session_dir),
            "session_timestamp": self.session_timestamp,
            "title": self._session_title or "New Chat",
            "message_count": self.message_count,
            "last_message_timestamp": self.last_message_timestamp,
        }

    def estimate_tokens(self, text: str) -> int:
        """Estimate token count using tiktoken."""
        return len(self._encoding.encode(text))

    def get_total_context_tokens(self) -> int:
        """Calculate total tokens in current session context using actual LLM usage."""
        # Use actual token usage from cost tracking (more accurate than estimation)
        if hasattr(self.session_costs, "total_usage") and self.session_costs.total_usage.total_tokens > 0:
            actual_tokens = self.session_costs.total_usage.total_tokens
            return actual_tokens

        # Fallback to message estimation if no usage data available
        total = 0
        for message in self.messages:
            if isinstance(message.get("content"), str):
                total += self.estimate_tokens(message["content"])

        if self.compressed_context:
            total += self.estimate_tokens(self.compressed_context)

        return total

    def should_compress_context(self) -> bool:
        """Check if context compression is needed (90% of context window)."""
        total_tokens = self.get_total_context_tokens()
        threshold = self.context_window * 0.9
        should_compress = total_tokens >= threshold

        return should_compress

    async def compress_context_if_needed(self) -> None:
        """Compress context when approaching token limits (reactive compression)."""
        if not self.should_compress_context():
            return

        # Import here to avoid circular imports
        from rich.progress import Progress, SpinnerColumn, TextColumn

        from libagentic.agents import get_compression_agent

        # Get compression metrics
        recent_preserve_count = 3
        messages_to_compress = (
            self.messages[:-recent_preserve_count] if len(self.messages) > recent_preserve_count else []
        )

        if not messages_to_compress:
            print("⚠️  No messages to compress")
            return

        # Simple transient indeterminate progress that disappears when done
        progress = Progress(
            SpinnerColumn(),
            TextColumn("🗜️  Compressing conversation..."),
            transient=True,  # Progress disappears after completion
        )

        with progress:
            # Single indeterminate task
            progress.add_task("", total=None)  # Indeterminate progress

            # Format conversation for compression
            conversation_text = self._format_messages_for_compression(messages_to_compress)

            # Get compression agent and perform compression
            compression_agent = get_compression_agent()
            result = await compression_agent.run(f"Compress this conversation:\n\n{conversation_text}")

            # Store compressed context and remove compressed messages
            self.compressed_context = result.output.strip()
            self.messages = self.messages[-recent_preserve_count:]  # Keep only recent messages

        # Log compression event
        self._log_compression_event(len(messages_to_compress))

    def _format_messages_for_compression(self, messages: list[dict]) -> str:
        """Format messages into readable conversation for compression."""
        formatted = []
        for msg in messages:
            role = msg.get("type", "unknown")
            content = msg.get("content", "")

            if role == "user_message":
                formatted.append(f"USER: {content}")
            elif role == "assistant_response":
                formatted.append(f"ASSISTANT: {content}")
            elif role == "system_prompt":
                formatted.append(f"SYSTEM: {content}")

        return "\n\n".join(formatted)

    def _log_compression_event(self, compressed_message_count: int) -> None:
        """Log context compression as regular session message."""
        timestamp = datetime.now(UTC).isoformat()

        compression_data = {
            "timestamp": timestamp,
            "type": "context_compression",
            "content": f"Compressed {compressed_message_count} messages due to context window limit",
            "message_index": self.message_count,
            "metadata": {
                "compressed_messages": compressed_message_count,
                "remaining_messages": len(self.messages),
                "compressed_context_tokens": self.estimate_tokens(self.compressed_context)
                if self.compressed_context
                else 0,
            },
        }

        self.messages.append(compression_data)
        self.message_count += 1
        self.last_message_timestamp = timestamp
        self._log_message_to_history(compression_data)
        self._update_metadata()

    def get_pydantic_message_history(self) -> list:
        """Convert stored conversation context to Pydantic AI ModelMessage format.

        Returns:
            List of ModelMessage objects for use with Pydantic AI agent.run()
        """
        try:
            from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart, ToolCallPart, ToolReturnPart
            from pydantic_core import from_json
        except ImportError:
            # Fallback if pydantic_ai not available
            return []

        # Use pydantic_data to reconstruct the exact original messages
        pydantic_messages = []
        current_request_parts = []
        current_response_parts = []
        
        for message in self.messages:
            msg_type = message.get("type", "unknown")
            pydantic_data = message.get("pydantic_data")
            
            # Skip system prompts and compression events
            if msg_type in ["system_prompt", "context_compression"]:
                continue
            
            # If we have pydantic_data, reconstruct the exact part
            if pydantic_data:
                try:
                    # Get the original part class name
                    pydantic_type = message.get("pydantic_type", "")
                    
                    # Reconstruct the part from stored data
                    if pydantic_type == "UserPromptPart":
                        part = UserPromptPart(**pydantic_data)
                        current_request_parts.append(part)
                    elif pydantic_type == "ToolReturnPart":
                        # Reconstruct ToolReturnPart from stored data
                        part = ToolReturnPart(**pydantic_data)
                        current_request_parts.append(part)
                    elif pydantic_type == "TextPart":
                        part = TextPart(**pydantic_data)
                        current_response_parts.append(part)
                    elif pydantic_type == "ToolCallPart":
                        # Reconstruct ToolCallPart from stored data
                        part = ToolCallPart(**pydantic_data)
                        current_response_parts.append(part)
                    
                    # Check if we need to create ModelRequest/ModelResponse
                    # UserPromptPart or ToolReturnPart = start of ModelRequest
                    if pydantic_type in ["UserPromptPart", "ToolReturnPart"]:
                        # If we have pending response parts, create ModelResponse first
                        if current_response_parts:
                            response = ModelResponse(parts=current_response_parts)
                            pydantic_messages.append(response)
                            current_response_parts = []
                        # Continue collecting request parts
                    
                    # TextPart or ToolCallPart = part of ModelResponse
                    elif pydantic_type in ["TextPart", "ToolCallPart"]:
                        # If we have pending request parts, create ModelRequest first
                        if current_request_parts:
                            request = ModelRequest(parts=current_request_parts)
                            pydantic_messages.append(request)
                            current_request_parts = []
                        # Continue collecting response parts
                        
                except Exception:
                    # If reconstruction fails, fall back to simple approach
                    continue
            else:
                # Fallback to original logic if no pydantic_data
                content = message.get("content", "")
                
                if msg_type == "user_message":
                    # Create ModelRequest for previous response parts if any
                    if current_response_parts:
                        response = ModelResponse(parts=current_response_parts)
                        pydantic_messages.append(response)
                        current_response_parts = []
                    
                    current_request_parts.append(UserPromptPart(content=content))

                elif msg_type == "assistant_response":
                    # Create ModelRequest for previous request parts if any
                    if current_request_parts:
                        request = ModelRequest(parts=current_request_parts)
                        pydantic_messages.append(request)
                        current_request_parts = []
                    
                    current_response_parts.append(TextPart(content=content))
        
        # Add any remaining parts
        if current_request_parts:
            request = ModelRequest(parts=current_request_parts)
            pydantic_messages.append(request)
        if current_response_parts:
            response = ModelResponse(parts=current_response_parts)
            pydantic_messages.append(response)

        return pydantic_messages

    def get_conversation_context(self) -> list[dict]:
        """Get raw conversation messages for display purposes.
        
        Returns:
            List of raw message dictionaries as stored in the session.
        """
        return self.messages


class SessionLister:
    """Handles scanning and displaying past chat sessions for resumption."""

    def __init__(self, app_name: str = "chatinterface"):
        self.app_name = app_name
        self.sessions_dir = Path.home() / f".{app_name}" / "sessions"

    def get_available_sessions(self) -> list[dict[str, Any]]:
        """Scan for available sessions and return metadata sorted by creation time (newest first)."""
        sessions = []

        if not self.sessions_dir.exists():
            return sessions

        for session_dir in self.sessions_dir.iterdir():
            if not session_dir.is_dir():
                continue

            metadata_file = session_dir / "metadata.json"
            if not metadata_file.exists():
                continue

            try:
                with metadata_file.open("r", encoding="utf-8") as f:
                    metadata = json.load(f)

                # Add session directory path for resumption
                metadata["session_dir"] = str(session_dir)
                metadata["session_timestamp"] = session_dir.name
                sessions.append(metadata)

            except (json.JSONDecodeError, OSError):
                # Skip corrupted or inaccessible sessions
                continue

        # Sort by created_timestamp (newest first)
        sessions.sort(key=lambda s: s.get("created_timestamp", ""), reverse=True)
        return sessions

    def format_session_display(self, session: dict[str, Any]) -> str:
        """Format session metadata for display in the selection interface."""
        title = session.get("title", "Untitled Session")
        created_timestamp = session.get("created_timestamp", "")
        message_count = session.get("message_count", 0)

        # Format timestamp for human readability
        try:
            from datetime import datetime

            created_dt = datetime.fromisoformat(created_timestamp.replace("Z", "+00:00"))
            now = datetime.now(created_dt.tzinfo)
            time_diff = now - created_dt

            if time_diff.days > 0:
                time_str = "1 day ago" if time_diff.days == 1 else f"{time_diff.days} days ago"
            elif time_diff.seconds > 3600:
                hours = time_diff.seconds // 3600
                time_str = f"{hours} hour{'s' if hours != 1 else ''} ago"
            elif time_diff.seconds > 60:
                minutes = time_diff.seconds // 60
                time_str = f"{minutes} minute{'s' if minutes != 1 else ''} ago"
            else:
                time_str = "Just now"
        except (ValueError, AttributeError):
            time_str = "Unknown time"

        # Format display line
        return f"{title:<50} ({time_str}, {message_count} message{'s' if message_count != 1 else ''})"

    def show_session_selection(self, console: Console) -> str | None:
        """Display interactive session selection interface using Rich.

        Returns:
            Selected session directory path, or None if cancelled
        """
        sessions = self.get_available_sessions()

        if not sessions:
            console.print("[yellow]No previous sessions found.[/yellow]")
            return None

        console.print()
        console.print("[bold blue]Resume Session[/bold blue]")
        console.print("[dim]Select a session to resume:[/dim]")
        console.print()

        # Display sessions with numbers
        for i, session in enumerate(sessions, 1):
            display_text = self.format_session_display(session)
            console.print(f"[bold cyan]{i}.[/bold cyan] {display_text}")

        console.print()
        console.print(f"[dim]Enter session number (1-{len(sessions)}) or press Enter to cancel:[/dim]")

        try:
            choice = console.input("[bold green]Select session: [/bold green]").strip()

            if not choice:  # Empty input - cancel
                return None

            session_num = int(choice)
            if 1 <= session_num <= len(sessions):
                return sessions[session_num - 1]["session_dir"]
            else:
                console.print(f"[red]Invalid selection. Please choose 1-{len(sessions)}[/red]")
                return None

        except (ValueError, KeyboardInterrupt):
            return None

        console.print()
        console.print("[bold blue]Resume Session[/bold blue]")
        console.print("[dim]Use ↑/↓ arrows to navigate, Enter to select, Esc to cancel[/dim]")
        console.print()

        selected_index = 0

        # Use alternate screen mode for clean interface
        with console.screen() as screen:
            while True:
                # Clear and redraw
                screen.update(self._render_session_list(sessions, selected_index))

                # Get keyboard input
                try:
                    import getch

                    key = getch.getch()
                except ImportError:
                    # Fallback to simple input if getch not available
                    console.print(
                        f"\n[yellow]Arrow key navigation not available. Enter session number (1-{len(sessions)}):[/yellow]"
                    )
                    try:
                        choice = int(console.input()) - 1
                        if 0 <= choice < len(sessions):
                            return sessions[choice]["session_dir"]
                    except (ValueError, KeyboardInterrupt):
                        pass
                    return None

                # Handle key input
                if key == "\x1b":  # Escape sequence
                    # Check for arrow keys
                    try:
                        key2 = getch.getch()
                        if key2 == "[":
                            key3 = getch.getch()
                            if key3 == "A":  # Up arrow
                                selected_index = max(0, selected_index - 1)
                            elif key3 == "B":  # Down arrow
                                selected_index = min(len(sessions) - 1, selected_index + 1)
                        else:
                            # Pure escape - cancel
                            return None
                    except ImportError:
                        return None
                elif key == "\r" or key == "\n":  # Enter
                    return sessions[selected_index]["session_dir"]
                elif key == "\x03":  # Ctrl+C
                    raise KeyboardInterrupt()


class ResumableSessionManager(SessionManager):
    """Extended SessionManager that can load from existing sessions."""

    @classmethod
    def from_existing_session(
        cls, session_dir: str, app_name: str = "chatinterface", context_window: int | None = None
    ) -> "ResumableSessionManager":
        """Create a SessionManager from an existing session directory.

        Args:
            session_dir: Path to existing session directory
            app_name: Application name for directory structure
            context_window: Maximum context window size in tokens

        Returns:
            ResumableSessionManager instance loaded with existing session data
        """
        session_path = Path(session_dir)
        if not session_path.exists():
            raise ValueError(f"Session directory does not exist: {session_dir}")

        metadata_file = session_path / "metadata.json"
        history_file = session_path / "history.jsonl"

        if not metadata_file.exists() or not history_file.exists():
            raise ValueError(f"Invalid session directory (missing metadata or history): {session_dir}")

        # Create instance using the existing session directory
        instance = cls.__new__(cls)

        # Read context window from environment variable if not provided
        if context_window is None:
            context_window = int(os.getenv("CONTEXT_WINDOW", "200000"))

        # Initialize with existing session path
        instance.app_name = app_name
        instance.sessions_dir = session_path.parent
        instance.session_id = session_path.name  # Could be UUID v7 or old timestamp format
        instance.session_timestamp = session_path.name  # Keep for backward compatibility
        instance.session_dir = session_path
        instance.metadata_file = metadata_file
        instance.history_file = history_file

        # Load existing metadata
        try:
            with metadata_file.open("r", encoding="utf-8") as f:
                metadata = json.load(f)

            instance._session_title = metadata.get("title", "Resumed Session")
            instance._title_generated = True  # Don't regenerate title for resumed sessions
            instance.message_count = metadata.get("message_count", 0)
            instance.last_message_timestamp = metadata.get("last_message_timestamp")

        except (json.JSONDecodeError, OSError):
            # Fallback values if metadata is corrupted
            instance._session_title = "Resumed Session"
            instance._title_generated = True
            instance.message_count = 0
            instance.last_message_timestamp = None

        # Initialize other required attributes
        instance.first_user_message = None
        instance.messages = []

        # Initialize cost tracking - load existing costs from metadata
        from libchatinterface.costs import SessionCosts

        instance.session_costs = SessionCosts()

        # Load existing usage data from metadata if available
        try:
            with metadata_file.open("r", encoding="utf-8") as f:
                metadata = json.load(f)
                usage_data = metadata.get("usage", {})

                # Restore previous token counts if available
                if usage_data:
                    from pydantic_ai.usage import RunUsage

                    from libchatinterface.costs import add_usage_to_session, calculate_usage_cost

                    # Handle both old format (direct keys) and new format (nested under 'total')
                    if "total" in usage_data:
                        # New format: usage.total.input_tokens, etc.
                        total_data = usage_data["total"]
                        input_tokens = total_data.get("input_tokens", 0)
                        output_tokens = total_data.get("output_tokens", 0)
                        total_tokens = total_data.get("total_tokens", 0)
                        requests = total_data.get("requests", 0)
                        cache_read_tokens = total_data.get(
                            "cached_tokens", 0
                        )  # Map old cached_tokens to cache_read_tokens
                    else:
                        # Old format: usage.total_tokens, etc.
                        total_tokens = usage_data.get("total_tokens", 0)
                        input_tokens = usage_data.get("input_tokens", 0)
                        output_tokens = usage_data.get("output_tokens", 0)
                        requests = usage_data.get("requests", 1 if total_tokens > 0 else 0)  # Estimate requests
                        cache_read_tokens = usage_data.get("cached_tokens", 0)

                        # If we only have total_tokens, estimate the split (rough approximation)
                        if total_tokens > 0 and input_tokens == 0 and output_tokens == 0:
                            # Rough estimate: 70% input, 30% output for resumed sessions
                            input_tokens = int(total_tokens * 0.7)
                            output_tokens = total_tokens - input_tokens

                    if total_tokens > 0 or input_tokens > 0 or output_tokens > 0:
                        # Create usage object to restore session costs
                        restored_usage = RunUsage(
                            input_tokens=input_tokens,
                            output_tokens=output_tokens,
                            requests=requests,
                            cache_read_tokens=cache_read_tokens,
                        )

                        # Calculate costs and add to session
                        usage_costs = calculate_usage_cost(restored_usage, None)  # No model name available
                        add_usage_to_session(instance.session_costs, usage_costs)

        except (json.JSONDecodeError, OSError, ImportError, TypeError):
            # If we can't load existing costs, start fresh
            pass

        # Context compression
        instance.context_window = context_window
        instance.compressed_context = None
        instance._encoding = tiktoken.encoding_for_model("gpt-4")

        # Load message history
        instance._load_message_history()

        return instance

    def _load_message_history(self) -> None:
        """Load message history from history.jsonl file into memory."""
        try:
            with self.history_file.open("r", encoding="utf-8") as f:
                for line in f:
                    line = line.strip()
                    if line:
                        try:
                            message_data = json.loads(line)
                            self.messages.append(message_data)

                            # Update message counter to match loaded history
                            msg_index = message_data.get("message_index", 0)
                            if msg_index >= self.message_count:
                                self.message_count = msg_index + 1

                        except json.JSONDecodeError:
                            # Skip corrupted message lines
                            continue

        except (OSError, FileNotFoundError):
            # If we can't load history, start fresh but keep metadata
            pass

