"""Session file management for claudeconvo.

Provides functionality for discovering, parsing, and displaying Claude session files,
with support for security validation, format adaptation, and watch mode.
"""

import json
import os
import sys
from pathlib import Path
from typing import Any

from .constants import (
    BYTES_PER_KB,
    BYTES_PER_MB,
    CLAUDE_PROJECTS_DIR,
    ESC_KEY_CODE,
    MAX_FILE_SIZE,
    MAX_FILE_SIZE_MB,
    MAX_LINE_SIZE,
    WATCH_POLL_INTERVAL,
)
from .parsers.adaptive import AdaptiveParser
from .styles import render, render_inline
from .tool_tracker import ToolInvocationTracker
from .utils import log_debug

# Session processing constants
# Priority markers - checked in order of preference
# Project-specific markers should come before generic .claude
ROOT_MARKERS = [".git", "pyproject.toml", "package.json", "setup.py", ".hg", ".svn", ".claude"]


################################################################################

def path_to_session_dir(path: str) -> Path:
    """
    Convert a file path to Claude's session directory naming convention.

    Args:
        path: File system path to convert

    Returns:
        Path object for the session directory
    """
    # Convert path to Claude's naming convention
    # Format: Leading dash, path with slashes replaced by dashes
    # Hidden folders (starting with .) get the dot removed and double dash
    # Underscores also become dashes
    parts           = Path(path).parts
    converted_parts = []

    for part in parts:
        if part and part != os.sep:  # Skip empty parts and root separator
            # Replace underscores with dashes
            part = part.replace("_", "-")
            if part.startswith("."):
                # Remove the dot and add extra dash for hidden folders
                converted_parts.append("-" + part[1:])
            else:
                converted_parts.append(part)

    project_name = "-" + "-".join(converted_parts)
    return Path.home() / CLAUDE_PROJECTS_DIR / project_name


################################################################################

def find_project_root(start_path: str | None = None) -> str:
    """
    Find the project root by looking for markers like .git, .claude, etc.

    Strategy: First check if current directory has a Claude session (even without markers).
    Then collect all candidate roots while walking up, and pick the best one.
    Preference order:
    1. Current directory if it has a Claude session (even without project markers)
    2. Deepest directory with a matching Claude session directory
    3. Deepest directory with a project-specific marker (.git, pyproject.toml, etc.)
    4. Directory with .claude marker (less preferred as it might be a parent workspace)
    5. Original path if nothing found

    Args:
        start_path: Starting directory (defaults to current working directory)

    Returns:
        Path to project root, or original path if no root found
    """
    if start_path is None:
        start_path = os.getcwd()

    current          = Path(start_path).resolve()
    start_resolved   = str(current)

    # FIRST: Check if current directory itself has a Claude session
    # This handles projects without traditional markers but opened in Claude Code
    current_session_dir = path_to_session_dir(start_resolved)
    if current_session_dir.exists():
        return start_resolved

    candidates       = []  # List of (path, marker, has_session)

    # Walk up the directory tree and collect all candidates
    while current != current.parent:
        for marker in ROOT_MARKERS:
            if (current / marker).exists():
                # Check if a Claude session exists for this path
                session_dir = path_to_session_dir(str(current))
                has_session = session_dir.exists()

                candidates.append((str(current), marker, has_session))
                break  # Found a marker at this level, move up
        current = current.parent

    if not candidates:
        # No markers found, return resolved original path
        return start_resolved

    # Prioritize candidates:
    # 1. First, try candidates with existing Claude sessions
    session_candidates = [c for c in candidates if c[2]]
    if session_candidates:
        return session_candidates[0][0]  # Return deepest with session

    # 2. Next, prefer project-specific markers over .claude
    project_markers = [".git", "pyproject.toml", "package.json", "setup.py", ".hg", ".svn"]
    for candidate in candidates:
        if candidate[1] in project_markers:
            return candidate[0]

    # 3. Fall back to any marker (including .claude)
    return candidates[0][0]


################################################################################

def get_project_session_dir() -> Path:
    """
    Get the session directory for the current project.

    Returns:
        Path to the session directory for the current project
    """
    # Find the project root first
    project_root = find_project_root()
    return path_to_session_dir(project_root)


################################################################################

def list_session_files(session_dir: Path) -> list[Path]:
    """
    List all session files in the directory, sorted by modification time.

    Args:
        session_dir: Directory to search for session files

    Returns:
        List of session file paths, sorted by modification time (newest first)
    """
    if not session_dir.exists():
        return []

    jsonl_files = list(session_dir.glob("*.jsonl"))
    # Sort by modification time (newest first)
    jsonl_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
    return jsonl_files


################################################################################

def parse_session_file(filepath: Path) -> list[dict[str, Any]]:
    """
    Parse a JSONL session file and return its contents.

    Args:
        filepath: Path to the JSONL session file to parse

    Returns:
        List of parsed session entries
    """
    sessions: list[dict[str, Any]] = []
    parser   = AdaptiveParser()  # Will auto-load config if available
    tracker  = ToolInvocationTracker()  # Track tool invocations

    # Validate file path
    filepath = Path(filepath)

    # Check if path is a symlink and refuse to follow symlinks for security
    try:
        if filepath.is_symlink():
            err_msg = "Security: Refusing to follow symlink for session file"
            print(render_inline("error", err_msg), file=sys.stderr)
            return sessions
    except (OSError, TypeError) as e:
        # Path doesn't exist or is mocked in tests - continue with validation
        log_debug(f"Could not check symlink status for {filepath}: {e}")

    # Resolve to absolute path after symlink check
    filepath      = filepath.resolve()
    home_sessions = Path.home() / CLAUDE_PROJECTS_DIR

    # Ensure the file is within the expected Claude sessions directory
    if not (home_sessions in filepath.parents or filepath.parent == home_sessions):
        err_msg = "Security: Refusing to read file outside Claude sessions directory"
        print(render_inline("error", err_msg), file=sys.stderr)
        return sessions

    try:
        with open(filepath, encoding='utf-8') as f:
            # Check file size using fstat on open file handle to prevent TOCTOU
            try:
                import os

                file_stat = os.fstat(f.fileno())
                if file_stat.st_size > MAX_FILE_SIZE:
                    size_str = format_file_size(file_stat.st_size)
                    max_mb   = f"{MAX_FILE_SIZE_MB}MB"
                    err_msg  = f"Warning: File too large ({size_str}). "
                    err_msg += f"Maximum size is {max_mb}"
                    print(render_inline("warning", err_msg), file=sys.stderr)
                    return sessions
            except OSError as e:
                # Log for debugging if needed but continue processing
                # File size check is a safety measure, not a hard requirement
                log_debug(f"Could not check file size for {filepath}: {e}")

            for line_num, line in enumerate(f, 1):
                # Check line size before processing to prevent memory exhaustion
                if len(line) > MAX_LINE_SIZE:
                    err_msg = (f"Warning: Line {line_num} exceeds size limit "
                              f"({len(line)} > {MAX_LINE_SIZE}), skipping")
                    print(render_inline("warning", err_msg), file=sys.stderr)
                    continue

                line = line.strip()
                if line:
                    try:
                        raw_data    = json.loads(line)
                        # Use the adaptive parser to normalize the entry
                        parsed_data = parser.parse_entry(raw_data)

                        # Track tool invocations
                        tracker.track_tool_use(parsed_data)

                        # If this is any tool result, enhance it with tool info
                        if tracker.is_tool_result(parsed_data):
                            tool_info = tracker.get_tool_info_for_entry(parsed_data)
                            if tool_info:
                                # Special handling for Task results
                                if tracker.is_task_result(parsed_data):
                                    parsed_data["_task_info"] = tool_info
                                else:
                                    parsed_data["_tool_info"] = tool_info

                        sessions.append(parsed_data)
                    except json.JSONDecodeError as e:
                        err_msg = f"Warning: JSON parse error on line {line_num}: {e}"
                        print(render_inline("warning", err_msg), file=sys.stderr)
                    except (ValueError, TypeError, KeyError, AttributeError) as e:
                        # Parser errors - log but don't expose full error details
                        err_type = type(e).__name__
                        err_msg = f"Warning: Parse error on line {line_num}: {err_type}"
                        print(render_inline("warning", err_msg), file=sys.stderr)
                        # Don't include raw conversation data to prevent PII exposure
                        # Only include minimal error information
                        error_entry = {
                            "_parse_error": err_type,
                            "_line_number": line_num,
                            "type": "parse_error"
                        }
                        sessions.append(error_entry)
    except (OSError, PermissionError) as e:
        # Sanitize error message to avoid exposing sensitive paths
        err_msg = f"Error reading session file: {type(e).__name__}"
        print(render_inline("error", err_msg), file=sys.stderr)
    except UnicodeDecodeError:
        # Handle encoding issues
        err_msg = "Error reading session file: Encoding error"
        print(render_inline("error", err_msg), file=sys.stderr)
    except MemoryError:
        # Handle large file memory issues
        err_msg = "Error reading session file: Out of memory"
        print(render_inline("error", err_msg), file=sys.stderr)

    return sessions


################################################################################

def display_session(
    filepath       : Path,
    show_options   : Any,
    watch_mode     : bool = False,
    show_timestamp : bool = False
) -> None:
    """
    Display a session file with optional watch mode.

    Args:
        filepath: Path to session file
        show_options: ShowOptions instance for filtering/formatting
        watch_mode: If True, watch for new entries continuously
        show_timestamp: Whether to include timestamps in output
    """
    import time

    from .formatters import format_conversation_entry

    if not filepath.exists():
        print(render_inline("error", f"Session file not found: {filepath}"), file=sys.stderr)
        return

    # Set up keyboard handling for ESC detection (needed for finally block)
    old_settings = None

    if watch_mode:
        print(render("system", content=f"Watching session: {filepath.name}"))
        print(render_inline("info", "Press ESC or Ctrl+C to exit") + "\n")
        try:
            import select
            import termios
            import tty

            old_settings = termios.tcgetattr(sys.stdin)
            tty.setcbreak(sys.stdin.fileno())
        except (ImportError, OSError, AttributeError):
            old_settings = None
            print(render_inline("info", "Press Ctrl+C to exit") + "\n")

    try:
        displayed_count = 0

        while True:
            # Parse the current session file
            sessions = parse_session_file(filepath)

            # Display any new entries
            for entry in sessions[displayed_count:]:
                formatted = format_conversation_entry(
                    entry, show_options, show_timestamp=show_timestamp
                )
                if formatted:
                    print(formatted)
                    if watch_mode:
                        sys.stdout.flush()  # Ensure immediate output in watch mode

            displayed_count = len(sessions)

            if not watch_mode:
                break  # Normal mode: exit after displaying all entries

            # Watch mode: check for ESC key and continue monitoring
            if old_settings:
                if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
                    ch = sys.stdin.read(1)
                    if ord(ch) == ESC_KEY_CODE:  # ESC key
                        print(render("system", content="Stopped watching"))
                        break

            time.sleep(WATCH_POLL_INTERVAL)  # Poll interval

    except KeyboardInterrupt:
        if watch_mode:
            print(render("system", content="Interrupted"))
    finally:
        # Restore terminal settings
        if old_settings:
            try:
                import termios

                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
            except (ImportError, OSError, AttributeError):
                pass


################################################################################

def format_file_size(size_bytes: int) -> str:
    """
    Format file size in human-readable format.

    Args:
        size_bytes: Size in bytes to format

    Returns:
        Human-readable size string (e.g., "1.2KB", "5.3MB")
    """
    if size_bytes < BYTES_PER_KB:
        return f"{size_bytes}B"
    elif size_bytes < BYTES_PER_MB:
        return f"{size_bytes/BYTES_PER_KB:.1f}KB"
    else:
        return f"{size_bytes/BYTES_PER_MB:.1f}MB"
