"""Session discovery and I/O for Claude Code JSONL files."""

from __future__ import annotations

import hashlib
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Literal

from .types import Message


# ─── Concurrent-write safety primitives ──────────────────────────────────────

class PruneConflictError(Exception):
    """The session file's original bytes changed during pruning.

    Raised by save_messages() when a snapshot is provided and the file's
    prefix was mutated (re-written or truncated) between snapshot time and
    replace time.  The caller should discard the pruned output and retry
    from a fresh load on the next cycle.
    """


class PruneLockError(Exception):
    """The prune lock is held by another process or guard cycle."""


class _FileSnapshot:
    """Immutable point-in-time identity of a JSONL file.

    Captures inode, size, and an MD5 of the full content so that save_messages()
    can classify what happened to the file while pruning was in progress:

    - "unchanged"  — byte-for-byte identical; safe to replace normally.
    - "appended"   — file grew; all original bytes are intact as prefix.
                     Delta lines can be appended to the pruned output.
    - "conflict"   — inode changed, file shrank, or prefix was mutated.
                     Caller must abort and retry.
    """
    __slots__ = ("inode", "size", "content_hash")

    def __init__(self, path: Path) -> None:
        st = path.stat()
        self.inode: int = st.st_ino
        self.size: int = st.st_size
        self.content_hash: str = hashlib.md5(path.read_bytes()).hexdigest()

    def classify(self, path: Path) -> Literal["unchanged", "appended", "conflict"]:
        """Classify what happened to the file since this snapshot was taken."""
        try:
            st = path.stat()
        except OSError:
            return "conflict"
        if st.st_ino != self.inode:
            return "conflict"
        if st.st_size == self.size:
            return "unchanged"
        if st.st_size > self.size:
            data = path.read_bytes()
            if hashlib.md5(data[: self.size]).hexdigest() == self.content_hash:
                return "appended"
        return "conflict"

    def read_delta(self, path: Path) -> bytes:
        """Return bytes appended since snapshot. Caller must verify 'appended' first."""
        return path.read_bytes()[self.size :]


def snapshot_session(path: Path) -> _FileSnapshot:
    """Snapshot a session file's identity before loading, for append-safe writes."""
    return _FileSnapshot(path)


def _parse_delta_lines(delta: bytes) -> list[str]:
    """Parse appended bytes into validated JSONL lines.

    Raises ValueError if the delta does not end on a newline boundary (Claude
    mid-write) or json.JSONDecodeError if any line is not valid JSON.
    Returns a list of raw JSON line strings (no trailing newline per element).
    """
    text = delta.decode("utf-8", errors="replace")
    if not text.endswith("\n"):
        raise ValueError("delta does not end on newline boundary — Claude may be mid-write")
    lines = []
    for raw in text.splitlines():
        raw = raw.strip()
        if not raw:
            continue
        json.loads(raw)  # validates; raises json.JSONDecodeError if corrupt
        lines.append(raw)
    return lines


class _PruneLock:
    """Advisory lock preventing concurrent prune cycles on the same session file.

    Uses fcntl.LOCK_EX|LOCK_NB on a companion .prune-lock file so two guard
    instances (or a guard + a manual `cozempic treat --execute`) cannot race
    each other.  Falls back silently to a no-op on platforms without fcntl
    (Windows).
    """

    def __init__(self, session_path: Path) -> None:
        self._lock_path = session_path.with_suffix(".prune-lock")
        self._fh = None

    def __enter__(self) -> "_PruneLock":
        try:
            import fcntl
            self._fh = open(self._lock_path, "w", encoding="utf-8")
            fcntl.flock(self._fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except ImportError:
            self._fh = None  # Windows — skip locking
        except OSError as exc:
            if self._fh is not None:
                self._fh.close()
                self._fh = None
            raise PruneLockError(
                f"Another prune cycle is active for {self._lock_path.name}"
            ) from exc
        return self

    def __exit__(self, *_) -> None:
        if self._fh is not None:
            try:
                import fcntl
                fcntl.flock(self._fh, fcntl.LOCK_UN)
            except Exception:
                pass
            self._fh.close()
            self._fh = None
        self._lock_path.unlink(missing_ok=True)


def get_claude_dir() -> Path:
    import os
    config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
    if config_dir:
        return Path(config_dir)
    return Path.home() / ".claude"


def get_claude_json_path() -> Path:
    import os
    config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
    if config_dir:
        return Path(config_dir) / ".claude.json"
    return Path.home() / ".claude.json"


def get_projects_dir() -> Path:
    """Return the Claude projects directory."""
    return get_claude_dir() / "projects"


def find_project_dirs(project_filter: str | None = None) -> list[Path]:
    """Find project directories, optionally filtered by name."""
    projects = get_projects_dir()
    if not projects.exists():
        return []
    dirs = sorted(projects.iterdir())
    if project_filter:
        dirs = [d for d in dirs if project_filter.lower() in d.name.lower()]
    return [d for d in dirs if d.is_dir()]


def find_sessions(project_filter: str | None = None) -> list[dict]:
    """Find all JSONL session files with metadata."""
    sessions = []
    for proj_dir in find_project_dirs(project_filter):
        for f in sorted(proj_dir.glob("*.jsonl")):
            if ".jsonl.bak" in f.name or f.name.endswith(".bak"):
                continue
            size = f.stat().st_size
            mtime = datetime.fromtimestamp(f.stat().st_mtime)
            session_id = f.stem
            line_count = 0
            with open(f, "r", encoding="utf-8") as fh:
                for _ in fh:
                    line_count += 1
            sessions.append({
                "path": f,
                "project": proj_dir.name,
                "session_id": session_id,
                "size": size,
                "mtime": mtime,
                "lines": line_count,
            })
    return sessions


def cwd_to_project_slug(cwd: str | None = None) -> str:
    """Convert a working directory path to the Claude project slug format.

    Claude stores projects under ~/.claude/projects/ using the path with
    slashes replaced by dashes, e.g. /Users/foo/myproject -> -Users-foo-myproject
    """
    import os
    if cwd is None:
        cwd = os.getcwd()
    return cwd.replace("/", "-")


def project_slug_to_path(slug: str) -> str:
    """Convert a Claude project slug back to a directory path.

    e.g. -Users-foo-myproject -> /Users/foo/myproject
    """
    # Slug starts with '-' because paths start with '/'
    return slug.replace("-", "/")


def find_claude_pid() -> int | None:
    """Walk up the process tree to find the Claude Code node process."""
    from .helpers import is_ssh_session

    try:
        pid = os.getpid()
        for _ in range(10):
            result = subprocess.run(
                ["ps", "-o", "ppid=,comm=", "-p", str(pid)],
                capture_output=True, text=True,
            )
            parts = result.stdout.strip().split(None, 1)
            if len(parts) < 2:
                break
            ppid, comm = int(parts[0]), parts[1]
            if "node" in comm.lower() or "claude" in comm.lower():
                return pid
            pid = ppid
            if pid <= 1:
                break
    except (ValueError, OSError):
        pass
    # Fallback: PPID is often Claude when invoked from within a session.
    # Skip over SSH — PPID would be the SSH shell, not Claude.
    if is_ssh_session():
        return None
    ppid = os.getppid()
    if ppid > 1:
        return ppid
    return None


def _session_id_from_process() -> str | None:
    """Detect the current session ID from Claude's open file descriptors.

    Claude keeps .claude/tasks/<session-id>/ directories open. We can use
    lsof to find the session UUID from the parent Claude process.
    """
    claude_pid = find_claude_pid()
    if not claude_pid:
        return None

    try:
        result = subprocess.run(
            ["lsof", "-p", str(claude_pid)],
            capture_output=True, text=True, timeout=5,
        )
        import re
        # Match UUID pattern in .claude/tasks/ paths
        uuids = re.findall(
            r'\.claude/tasks/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
            result.stdout,
        )
        if uuids:
            # Return the most common one (in case of duplicates)
            from collections import Counter
            return Counter(uuids).most_common(1)[0][0]
    except (subprocess.TimeoutExpired, OSError):
        pass
    return None


def _match_session_by_text(sessions: list[dict], match_text: str) -> dict | None:
    """Find a session by matching text in its last N lines.

    Searches the tail of each session file for the given text snippet.
    Useful when multiple sessions are active and CWD/process detection fails.
    """
    for sess in sorted(sessions, key=lambda s: s["mtime"], reverse=True):
        try:
            with open(sess["path"], "r", encoding="utf-8") as f:
                # Read last 50 lines efficiently
                lines = f.readlines()
                tail = lines[-50:] if len(lines) > 50 else lines
                tail_text = "".join(tail)
                if match_text in tail_text:
                    return sess
        except (OSError, UnicodeDecodeError):
            continue
    return None


def find_current_session(
    cwd: str | None = None,
    match_text: str | None = None,
    strict: bool = False,
) -> dict | None:
    """Find the current Claude Code session using multiple strategies.

    Detection priority:
    1. Process-based: lsof on parent Claude process to find session UUID
    2. Text matching: search session files for a unique text snippet
    3. CWD slug: match working directory against project directory names
    4. Fallback: most recently modified session (only when strict=False)

    When strict=True, Strategy 4 is disabled — callers that perform
    destructive writes must not proceed on an ambiguous match.
    """
    sessions = find_sessions()
    if not sessions:
        return None

    # Strategy 1: Process-based detection (most reliable for active sessions)
    proc_session_id = _session_id_from_process()
    if proc_session_id:
        for s in sessions:
            if s["session_id"] == proc_session_id:
                return s

    # Strategy 2: Text matching (for multi-session disambiguation)
    if match_text:
        matched = _match_session_by_text(sessions, match_text)
        if matched:
            return matched

    # Strategy 3: CWD slug match
    slug = cwd_to_project_slug(cwd)
    matching = [s for s in sessions if slug in s["project"]]
    if matching:
        return max(matching, key=lambda s: s["mtime"])

    # Strategy 4: Fallback to most recently modified
    # Disabled in strict mode — refuse to guess on destructive paths.
    if strict:
        return None
    return max(sessions, key=lambda s: s["mtime"])


def resolve_session(
    session_arg: str,
    project_filter: str | None = None,
    strict: bool = False,
) -> Path:
    """Resolve a session argument to a JSONL file path.

    Accepts: full path, UUID, UUID prefix, or "current" for auto-detection.
    When strict=True, auto-detection refuses to fall back to "most recent session".
    """
    if session_arg == "current":
        sess = find_current_session(strict=strict)
        if sess:
            return sess["path"]
        print("Error: Could not auto-detect current session.", file=sys.stderr)
        if strict:
            print("Cannot determine session unambiguously — use an explicit session ID.", file=sys.stderr)
        print("Use 'cozempic list' to find the session ID.", file=sys.stderr)
        sys.exit(1)

    p = Path(session_arg)
    if p.exists() and p.suffix == ".jsonl":
        return p

    for sess in find_sessions(project_filter):
        if sess["session_id"] == session_arg:
            return sess["path"]
        if sess["session_id"].startswith(session_arg):
            return sess["path"]

    print(f"Error: Cannot find session '{session_arg}'", file=sys.stderr)
    print("Use 'cozempic list' to see available sessions.", file=sys.stderr)
    sys.exit(1)


# ─── Session sidecar store ────────────────────────────────────────────────────
#
# Maps session_id → {cwd, context_window, created_at, last_seen_at}.
# Populated by the guard daemon at startup and refreshed on each checkpoint.
# Consumers (reload, guard resume) prefer this over slug reversal, which is
# ambiguous for paths containing hyphens.

_SIDECAR_FILENAME = "cozempic-sessions.json"
_SIDECAR_MAX_ENTRIES = 200


def get_sidecar_path() -> Path:
    """Return the path to the session sidecar store."""
    return get_claude_dir() / _SIDECAR_FILENAME


def _load_sidecar() -> dict:
    """Load the sidecar store. Returns {} on missing or corrupt file."""
    p = get_sidecar_path()
    try:
        if p.exists():
            return json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        pass
    return {}


def _save_sidecar(data: dict) -> None:
    """Atomically write the sidecar store."""
    p = get_sidecar_path()
    tmp = p.with_suffix(".tmp")
    try:
        tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
        os.replace(tmp, p)
    except Exception:
        try:
            tmp.unlink(missing_ok=True)
        except OSError:
            pass
        raise


def record_session(
    session_id: str,
    cwd: str,
    context_window: int | None = None,
) -> None:
    """Record or refresh a session's cwd and context window in the sidecar store.

    Called from the guard daemon at startup and on each checkpoint so the map
    stays current across long-running sessions. Capped at _SIDECAR_MAX_ENTRIES
    (oldest last_seen_at evicted first) to prevent unbounded growth.
    """
    if not session_id or not cwd:
        return
    data = _load_sidecar()
    existing = data.get(session_id, {})
    now = datetime.now().isoformat(timespec="seconds")
    data[session_id] = {
        "cwd": cwd,
        "context_window": (
            context_window if context_window is not None
            else existing.get("context_window")
        ),
        "created_at": existing.get("created_at", now),
        "last_seen_at": now,
    }
    if len(data) > _SIDECAR_MAX_ENTRIES:
        by_age = sorted(data, key=lambda k: data[k].get("last_seen_at", ""), reverse=True)
        data = {k: data[k] for k in by_age[:_SIDECAR_MAX_ENTRIES]}
    _save_sidecar(data)


def get_session_cwd(session_id: str) -> str | None:
    """Return the recorded cwd for a session from the sidecar store, or None."""
    if not session_id:
        return None
    rec = _load_sidecar().get(session_id)
    return rec.get("cwd") if rec else None


def get_session_context_window(session_id: str) -> int | None:
    """Return the recorded context window for a session from the sidecar, or None."""
    if not session_id:
        return None
    rec = _load_sidecar().get(session_id)
    return rec.get("context_window") if rec else None


# ─── JSONL I/O ────────────────────────────────────────────────────────────────

MAX_LINE_BYTES = 10 * 1024 * 1024  # 10MB per-line safety limit


def load_messages(path: Path) -> list[Message]:
    """Load JSONL file. Returns list of (line_index, message_dict, byte_size)."""
    messages: list[Message] = []
    with open(path, "r", encoding="utf-8") as f:
        for i, line in enumerate(f):
            line = line.strip()
            if not line:
                continue
            if len(line) > MAX_LINE_BYTES:
                print(f"  Warning: skipping oversized line {i} ({len(line)} bytes)", file=sys.stderr)
                continue
            try:
                msg = json.loads(line)
                messages.append((i, msg, len(line.encode("utf-8"))))
            except json.JSONDecodeError:
                messages.append((i, {"_raw": line, "_parse_error": True}, len(line.encode("utf-8"))))
    return messages


def save_messages(
    path: Path,
    messages: list[Message],
    create_backup: bool = True,
    snapshot: _FileSnapshot | None = None,
) -> Path | None:
    """Save messages back to JSONL, optionally creating a timestamped backup.

    When *snapshot* is provided (taken via snapshot_session() before load_messages()),
    the file is classified before replacing:

    - "unchanged"  — safe to replace; proceeds normally.
    - "appended"   — Claude wrote new lines while pruning was in progress; the
                     delta is validated and appended to the pruned output so no
                     messages are lost.
    - "conflict"   — prefix was mutated (rewrite or truncation); raises
                     PruneConflictError.  The backup is NOT created and the
                     original file is left untouched.

    Returns the backup path if created, else None.
    """
    tmp_path = path.with_suffix(".tmp")
    try:
        with open(tmp_path, "w", encoding="utf-8") as f:
            for _, msg, _ in messages:
                if msg.get("_parse_error"):
                    f.write(msg["_raw"] + "\n")
                else:
                    f.write(json.dumps(msg, separators=(",", ":")) + "\n")
            f.flush()
            os.fsync(f.fileno())

        # ── Append-aware conflict detection ──────────────────────────────────
        if snapshot is not None:
            state = snapshot.classify(path)
            if state == "conflict":
                tmp_path.unlink(missing_ok=True)
                raise PruneConflictError(
                    f"Session file was modified (prefix changed) while pruning: {path}"
                )
            if state == "appended":
                delta = snapshot.read_delta(path)
                try:
                    extra_lines = _parse_delta_lines(delta)
                except (ValueError, json.JSONDecodeError) as exc:
                    # Claude is mid-write — treat as conflict; retry next cycle.
                    tmp_path.unlink(missing_ok=True)
                    raise PruneConflictError(
                        f"Session file has an incomplete append — deferring prune: {path}"
                    ) from exc
                if extra_lines:
                    with open(tmp_path, "a", encoding="utf-8") as fa:
                        for line in extra_lines:
                            fa.write(line + "\n")
                        fa.flush()
                        os.fsync(fa.fileno())
        # ─────────────────────────────────────────────────────────────────────

        # Backup is created after conflict check so orphaned backups are not
        # left behind when a conflict causes an early return.
        backup_path: Path | None = None
        if create_backup:
            ts = datetime.now().strftime("%Y%m%d_%H%M%S")
            backup_path = path.with_suffix(f".{ts}.jsonl.bak")
            shutil.copy2(path, backup_path)

        os.replace(tmp_path, path)
    except Exception:
        tmp_path.unlink(missing_ok=True)
        raise

    return backup_path


def cleanup_old_backups(session_path: Path, keep: int = 3) -> int:
    """Delete old timestamped .jsonl.bak files for this session, keeping the newest `keep`.

    Prevents disk fill when the guard fires many prune cycles (#19).
    Returns the number of files deleted.
    """
    pattern = f"{session_path.stem}.*.jsonl.bak"
    bak_files = sorted(
        session_path.parent.glob(pattern),
        key=lambda p: p.stat().st_mtime,
        reverse=True,
    )
    deleted = 0
    for old in bak_files[keep:]:
        try:
            old.unlink()
            deleted += 1
        except OSError:
            pass
    return deleted
