#!/usr/bin/env python3
"""Shared session management utilities for Claude Code hooks.

Provides per-terminal session isolation so multiple Claude Code instances
running simultaneously each get their own session file and ID.

Terminal Identification:
  1. Primary: os.getppid() — Claude Code PID (hooks are child processes)
  2. Fallback: CLAUDE_SESSION_ID environment variable
  3. Last resort: os.getpid() (less stable but never fails)

Session files: .omni-cortex/sessions/{terminal_id}.json
Backward compat: migrates old .omni-cortex/current_session.json on first use.
"""

import ctypes
import json
import os
import sqlite3
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional


# Session timeout in seconds (4 hours of inactivity = new session)
SESSION_TIMEOUT_SECONDS = 4 * 60 * 60

# Only run stale cleanup when this many session files exist
_CLEANUP_THRESHOLD = 10


def generate_session_id() -> str:
    """Generate a unique session ID matching the MCP format."""
    timestamp_ms = int(time.time() * 1000)
    random_hex = os.urandom(4).hex()
    return f"sess_{timestamp_ms}_{random_hex}"


def _get_project_path() -> str:
    """Get the project path from environment or cwd."""
    return os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())


def get_terminal_id() -> str:
    """Get unique identifier for the current terminal.

    Uses parent process ID (PPID) as primary identifier since each
    Claude Code instance is a separate process and hooks are children.

    Returns:
        Terminal identifier string (e.g., "ppid_12345")
    """
    # Primary: PPID — Claude Code process ID
    ppid = os.getppid()
    if ppid and ppid > 1:
        # Validate PPID is a real process on Windows
        if sys.platform == "win32":
            if _is_process_alive(ppid):
                return f"ppid_{ppid}"
        else:
            return f"ppid_{ppid}"

    # Fallback: CLAUDE_SESSION_ID env var
    env_session = os.environ.get("CLAUDE_SESSION_ID")
    if env_session:
        return f"env_{env_session}"

    # Last resort: own PID (less stable — changes each hook invocation)
    return f"pid_{os.getpid()}"


def _is_process_alive(pid: int) -> bool:
    """Check if a process is still running."""
    if sys.platform != "win32":
        try:
            os.kill(pid, 0)
            return True
        except OSError:
            return False

    try:
        kernel32 = ctypes.windll.kernel32
        PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
        STILL_ACTIVE = 259

        kernel32.OpenProcess.restype = ctypes.c_void_p
        kernel32.OpenProcess.argtypes = [ctypes.c_uint, ctypes.c_int, ctypes.c_uint]
        handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid)
        if not handle:
            return False

        # OpenProcess can succeed for recently-dead processes; verify with exit code
        exit_code = ctypes.c_ulong()
        kernel32.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
        result = kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
        kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
        kernel32.CloseHandle(handle)

        if result:
            return exit_code.value == STILL_ACTIVE
        return False
    except Exception:
        return True  # Assume alive on error (fail-open)


def _get_sessions_dir() -> Path:
    """Get the per-terminal sessions directory."""
    project_path = _get_project_path()
    return Path(project_path) / ".omni-cortex" / "sessions"


def _get_session_file_path(terminal_id: str) -> Path:
    """Get the session file path for a specific terminal."""
    return _get_sessions_dir() / f"{terminal_id}.json"


def _get_legacy_session_path() -> Path:
    """Get the old single-file session path (for migration)."""
    project_path = _get_project_path()
    return Path(project_path) / ".omni-cortex" / "current_session.json"


def _migrate_legacy_session(terminal_id: str) -> Optional[dict]:
    """Migrate old current_session.json to per-terminal format.

    Returns migrated session data if migration occurred, None otherwise.
    """
    legacy_path = _get_legacy_session_path()
    new_path = _get_session_file_path(terminal_id)

    if not legacy_path.exists() or new_path.exists():
        return None

    try:
        with open(legacy_path, "r") as f:
            session_data = json.load(f)

        # Add terminal_id field
        session_data["terminal_id"] = terminal_id

        # Save to new location
        new_path.parent.mkdir(parents=True, exist_ok=True)
        with open(new_path, "w") as f:
            json.dump(session_data, f, indent=2)

        # Rename old file to mark as migrated (don't delete — other hooks might reference it)
        migrated_path = legacy_path.with_suffix(".json.migrated")
        try:
            legacy_path.rename(migrated_path)
        except OSError:
            pass  # Non-critical if rename fails

        return session_data
    except Exception:
        return None


def load_session_file(terminal_id: str = None) -> Optional[dict]:
    """Load the session file for a terminal.

    Args:
        terminal_id: Terminal identifier. If None, uses current terminal.

    Returns:
        Session data dict or None if not found/invalid.
    """
    if terminal_id is None:
        terminal_id = get_terminal_id()

    session_file = _get_session_file_path(terminal_id)
    if not session_file.exists():
        return None

    try:
        with open(session_file, "r") as f:
            return json.load(f)
    except (json.JSONDecodeError, IOError):
        return None


def save_session_file(session_data: dict, terminal_id: str = None) -> None:
    """Save session data to the terminal-specific session file.

    Args:
        session_data: Session dict with session_id, terminal_id, etc.
        terminal_id: Terminal identifier. If None, uses current terminal.
    """
    if terminal_id is None:
        terminal_id = session_data.get("terminal_id", get_terminal_id())

    session_file = _get_session_file_path(terminal_id)
    session_file.parent.mkdir(parents=True, exist_ok=True)

    with open(session_file, "w") as f:
        json.dump(session_data, f, indent=2)


def is_session_valid(session_data: dict) -> bool:
    """Check if a session is still valid (not timed out)."""
    last_activity = session_data.get("last_activity_at")
    if not last_activity:
        return False

    try:
        last_time = datetime.fromisoformat(last_activity.replace("Z", "+00:00"))
        now = datetime.now(timezone.utc)
        elapsed_seconds = (now - last_time).total_seconds()
        return elapsed_seconds < SESSION_TIMEOUT_SECONDS
    except (ValueError, TypeError):
        return False


def cleanup_stale_sessions() -> int:
    """Remove session files for terminals that no longer exist.

    Only scans ppid_*.json files since pid-based sessions are inherently
    short-lived and env-based ones can't be validated.

    Returns:
        Number of stale session files removed.
    """
    sessions_dir = _get_sessions_dir()
    if not sessions_dir.exists():
        return 0

    removed = 0
    try:
        for session_file in sessions_dir.glob("ppid_*.json"):
            # Extract PID from filename: ppid_12345.json -> 12345
            try:
                pid_str = session_file.stem.split("_", 1)[1]
                pid = int(pid_str)
            except (IndexError, ValueError):
                continue

            if not _is_process_alive(pid):
                try:
                    session_file.unlink()
                    removed += 1
                except OSError:
                    pass  # File locked or already gone
    except Exception:
        pass  # Cleanup is best-effort

    return removed


def clear_terminal_session(terminal_id: str = None) -> bool:
    """Clear session file for a specific terminal. Used by /handoff.

    Args:
        terminal_id: Terminal to clear. Defaults to current terminal.

    Returns:
        True if session file was found and deleted.
    """
    if terminal_id is None:
        terminal_id = get_terminal_id()

    session_file = _get_session_file_path(terminal_id)
    if session_file.exists():
        try:
            session_file.unlink()
            return True
        except OSError:
            return False
    return False


def create_session_in_db(conn: sqlite3.Connection, session_id: str, project_path: str) -> None:
    """Create a new session record in the database."""
    cursor = conn.cursor()
    now = datetime.now(timezone.utc).isoformat()

    # Ensure sessions table exists
    cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
    if cursor.fetchone() is None:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS sessions (
                id TEXT PRIMARY KEY,
                project_path TEXT NOT NULL,
                started_at TEXT NOT NULL,
                ended_at TEXT,
                summary TEXT,
                tags TEXT,
                metadata TEXT
            );
            CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
            CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
        """)
        conn.commit()

    _retry_backoff = [0.2, 0.5, 1.0, 2.0, 4.0]
    for _attempt in range(5):
        try:
            cursor.execute(
                "INSERT OR IGNORE INTO sessions (id, project_path, started_at) VALUES (?, ?, ?)",
                (session_id, project_path, now),
            )
            conn.commit()
            break
        except sqlite3.OperationalError as e:
            if "database is locked" in str(e) and _attempt < 4:
                time.sleep(_retry_backoff[_attempt])
                continue
            raise


def get_or_create_session(conn: sqlite3.Connection, project_path: str) -> str:
    """Get the current session ID, creating a new one if needed.

    Uses per-terminal session files for multi-terminal isolation.
    Signature unchanged from v1 for backward compatibility.

    Args:
        conn: SQLite database connection
        project_path: The project directory path

    Returns:
        The session ID to use for activity logging
    """
    terminal_id = get_terminal_id()
    now_iso = datetime.now(timezone.utc).isoformat()

    # Try loading per-terminal session file
    session_data = load_session_file(terminal_id)

    # If no per-terminal file, try migrating legacy file
    if session_data is None:
        session_data = _migrate_legacy_session(terminal_id)

    if session_data and is_session_valid(session_data):
        # Update last activity time
        session_data["last_activity_at"] = now_iso
        save_session_file(session_data, terminal_id)
        return session_data["session_id"]

    # Create new session
    session_id = generate_session_id()
    create_session_in_db(conn, session_id, project_path)

    session_data = {
        "session_id": session_id,
        "terminal_id": terminal_id,
        "project_path": project_path,
        "started_at": now_iso,
        "last_activity_at": now_iso,
    }
    save_session_file(session_data, terminal_id)

    # Opportunistic stale cleanup
    try:
        sessions_dir = _get_sessions_dir()
        if sessions_dir.exists():
            file_count = len(list(sessions_dir.glob("*.json")))
            if file_count >= _CLEANUP_THRESHOLD:
                cleanup_stale_sessions()
    except Exception:
        pass  # Never block on cleanup

    return session_id
