"""DevinSource — adapter implementing the SessionSource protocol for the Devin CLI."""

from __future__ import annotations

import json
import shutil
import sqlite3
import subprocess
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from loguru import logger

from devin_session import queries
from devin_session.config import DevinConfig
from session_store import SessionExport, SessionInfo, SessionMessage
from session_store.errors import SessionLoadError, SessionNotFoundError


def parse_datetime(value: str | int | float | None) -> datetime:
    """Parse a timestamp into a datetime.

    Handles:
    - ISO timestamp strings (with optional 'Z' suffix)
    - Unix timestamps as integers or floats (seconds since epoch)
    - None (falls back to ``datetime.now(UTC)``)
    """
    if value is None:
        return datetime.now(UTC)
    if isinstance(value, int | float):
        try:
            return datetime.fromtimestamp(value, tz=UTC)
        except (ValueError, OSError, OverflowError):
            logger.warning("Failed to parse numeric datetime value: {!r}", value)
            return datetime.now(UTC)
    text = value.strip()
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    try:
        return datetime.fromisoformat(text)
    except (ValueError, TypeError):
        logger.warning("Failed to parse datetime value: {!r}", value)
        return datetime.now(UTC)


class DevinSource:
    """SessionSource adapter reading from the Devin CLI SQLite database."""

    def __init__(self, config: DevinConfig | None = None) -> None:
        self.config = config or DevinConfig()

    # ------------------------------------------------------------------
    # Protocol
    # ------------------------------------------------------------------

    def tool_name(self) -> str:
        return "devin"

    def list_sessions(self, limit: int = 20) -> list[SessionInfo]:
        """List sessions ordered by last activity (descending)."""
        # Prefer the export CLI when available.
        if shutil.which(self.config.export_cli) is not None:
            try:
                result = self._list_sessions_via_cli(limit)
                if result is not None:
                    return result
            except Exception as exc:  # noqa: BLE001
                logger.debug("devin-session-export list failed, falling back to SQLite: {}", exc)

        return self._list_sessions_via_sqlite(limit)

    def load_session(self, session_id: str) -> SessionExport:
        """Load a full session export by session ID."""
        # Prefer the export CLI when available.
        if shutil.which(self.config.export_cli) is not None:
            try:
                result = self._load_session_via_cli(session_id)
                if result is not None:
                    return result
            except SessionNotFoundError:
                raise
            except Exception as exc:  # noqa: BLE001
                logger.debug("devin-session-export load failed, falling back to SQLite: {}", exc)

        return self._load_session_via_sqlite(session_id)

    def detect_current_session(self) -> str | None:
        """Detect the current/active session, never raising."""
        # Tier 1: current-devin-session CLI.
        if shutil.which(self.config.current_session_cli) is not None:
            try:
                sid = self._detect_via_cli()
                if sid is not None:
                    return sid
            except Exception as exc:  # noqa: BLE001
                logger.debug("current-devin-session CLI failed, falling back to SQLite: {}", exc)

        # Tier 2 & 3: SQLite fallbacks.
        return self._detect_via_sqlite()

    def detect_schema_version(self) -> int | None:
        """Return the schema migration version of the current DB, or None.

        Reads the ``refinery_schema_history`` table to determine how many
        migrations have been applied. Returns ``None`` if the table is missing
        or the DB is inaccessible.
        """
        if not Path(self.config.db_path).exists():
            return None
        try:
            conn = self._connect()
        except SessionLoadError:
            return None
        try:
            cur = conn.execute(queries.SCHEMA_VERSION)
            row = cur.fetchone()
            return int(row[0]) if row and row[0] is not None else None
        except sqlite3.Error as exc:
            logger.debug("Failed to detect Devin schema version: {}", exc)
            return None
        finally:
            conn.close()

    # ------------------------------------------------------------------
    # CLI integrations
    # ------------------------------------------------------------------

    def _list_sessions_via_cli(self, limit: int) -> list[SessionInfo] | None:
        """List sessions via the export CLI.

        The export CLI is primarily designed for single-session export, so this
        helper is best-effort and may return None to signal the caller to fall
        back to SQLite.
        """
        return None

    def _load_session_via_cli(self, session_id: str) -> SessionExport | None:
        """Load a session via ``devin-session-export <session_id> --format jsonl``."""
        cmd = [self.config.export_cli, session_id, "--format", "jsonl"]
        proc = subprocess.run(  # noqa: S603
            cmd,
            capture_output=True,
            text=True,
            check=False,
        )
        if proc.returncode != 0:
            logger.debug(
                "devin-session-export returned {}: {}",
                proc.returncode,
                proc.stderr.strip(),
            )
            return None

        lines = [ln for ln in proc.stdout.splitlines() if ln.strip()]
        if not lines:
            return None

        messages: list[SessionMessage] = []
        title = session_id
        working_directory = ""
        created_at: str | None = None
        last_activity_at: str | None = None
        model: str | None = None
        for line in lines:
            try:
                obj = json.loads(line)
            except json.JSONDecodeError as exc:
                logger.warning("Failed to parse JSONL line: {}", exc)
                continue
            if title == session_id:
                title = obj.get("session_title") or obj.get("title") or session_id
            working_directory = obj.get("working_directory") or working_directory
            if created_at is None:
                created_at = obj.get("session_created_at") or obj.get("created_at")
            if model is None:
                model = obj.get("model")
            # Preserve tool-specific fields in metadata.
            msg_metadata: dict[str, Any] = {}
            for key in ("tool_calls", "tool_call_id"):
                if key in obj:
                    msg_metadata[key] = obj[key]
            if "metadata" in obj and isinstance(obj["metadata"], dict):
                msg_metadata.update(obj["metadata"])
            messages.append(
                SessionMessage(
                    tool="devin",
                    session_id=session_id,
                    node_id=obj.get("node_id", ""),
                    parent_node_id=obj.get("parent_id"),
                    message_id=obj.get("message_id", ""),
                    role=obj.get("role", ""),
                    content=obj.get("content", ""),
                    created_at=parse_datetime(obj.get("created_at")),
                    metadata=msg_metadata,
                )
            )

        return SessionExport(
            tool="devin",
            session_id=session_id,
            title=title,
            working_directory=working_directory,
            created_at=parse_datetime(created_at) if created_at else None,
            last_activity_at=parse_datetime(last_activity_at) if last_activity_at else None,
            model=model,
            messages=messages,
        )

    def _detect_via_cli(self) -> str | None:
        """Detect current session via ``current-devin-session`` CLI returning JSON."""
        cmd = [self.config.current_session_cli]
        proc = subprocess.run(  # noqa: S603
            cmd,
            capture_output=True,
            text=True,
            check=False,
        )
        if proc.returncode != 0 or not proc.stdout.strip():
            return None
        try:
            data = json.loads(proc.stdout)
        except json.JSONDecodeError as exc:
            logger.debug("current-devin-session returned invalid JSON: {}", exc)
            return None
        sid = data.get("id")
        return sid if isinstance(sid, str) and sid else None

    # ------------------------------------------------------------------
    # SQLite fallbacks
    # ------------------------------------------------------------------

    def _connect(self) -> sqlite3.Connection:
        """Open a read-only connection to the Devin sessions DB."""
        db_path = self.config.db_path
        if not Path(db_path).exists():
            raise SessionLoadError(f"Devin sessions DB not found: {db_path}")
        try:
            # read_only via URI mode
            uri = f"file:{db_path}?mode=ro"
            return sqlite3.connect(uri, uri=True)
        except sqlite3.Error as exc:
            raise SessionLoadError(f"Failed to open Devin sessions DB: {exc}") from exc

    @staticmethod
    def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]:
        """Return the set of column names that exist in the given table.

        Used for defensive column access (ADR 001, Layer 2) — queries only
        select columns that actually exist, degrading gracefully on older
        schema versions.
        """
        cur = conn.execute(f"PRAGMA table_info({table})")  # noqa: S608
        return {row[1] for row in cur.fetchall()}

    def _list_sessions_via_sqlite(self, limit: int) -> list[SessionInfo]:
        db_path = self.config.db_path
        if not Path(db_path).exists():
            logger.warning("Devin sessions DB not found at {}", db_path)
            return []

        try:
            conn = self._connect()
        except SessionLoadError as exc:
            logger.warning("{}", exc)
            return []

        try:
            cur = conn.cursor()
            has_hidden = "hidden" in self._existing_columns(conn, "sessions")
            list_cols = ["id", "title", "working_directory", "last_activity_at", "created_at"]
            if has_hidden:
                list_cols.append("hidden")
            query = (
                f"SELECT {', '.join(list_cols)} FROM sessions "
                "ORDER BY last_activity_at DESC LIMIT ?"
            )
            cur.execute(query, (limit,))
            rows = cur.fetchall()
            results: list[SessionInfo] = []
            for row in rows:
                sid, title, working_directory, last_activity_at, _created_at = row[:5]
                hidden = row[5] if has_hidden else 0
                cur.execute(queries.COUNT_MESSAGES, (sid,))
                count = cur.fetchone()[0]
                results.append(
                    SessionInfo(
                        tool="devin",
                        session_id=sid,
                        title=title or "",
                        message_count=count,
                        last_activity=parse_datetime(last_activity_at),
                        working_directory=working_directory or "",
                        hidden=bool(hidden),
                    )
                )
            return results
        finally:
            conn.close()

    def _load_session_via_sqlite(self, session_id: str) -> SessionExport:
        db_path = self.config.db_path
        if not Path(db_path).exists():
            raise SessionLoadError(f"Devin sessions DB not found: {db_path}")

        conn = self._connect()
        try:
            cur = conn.cursor()
            # Build the GET_SESSION query dynamically, only selecting columns
            # that exist in the current schema (ADR 001, Layer 2).
            cols = self._existing_columns(conn, "sessions")
            base_cols = ["id", "title", "working_directory", "last_activity_at", "created_at"]
            optional_cols = ["model", "backend_type", "agent_mode", "metadata"]
            select_cols = base_cols + [c for c in optional_cols if c in cols]
            # Pad with None for optional columns that don't exist.
            col_str = ", ".join(select_cols)
            cur.execute(f"SELECT {col_str} FROM sessions WHERE id = ?", (session_id,))  # noqa: S608
            row = cur.fetchone()
            if row is None:
                raise SessionNotFoundError(f"Session not found: {session_id}")
            # Map columns by position, filling None for missing optional columns.
            row_map = dict(zip(select_cols, row, strict=False))
            title = row_map.get("title")
            working_directory = row_map.get("working_directory")
            last_activity_at = row_map.get("last_activity_at")
            created_at = row_map.get("created_at")
            model = row_map.get("model")
            backend_type = row_map.get("backend_type")
            agent_mode = row_map.get("agent_mode")
            session_metadata_raw = row_map.get("metadata")

            cur.execute(queries.GET_MESSAGE_NODES, (session_id,))
            node_rows = cur.fetchall()

            messages: list[SessionMessage] = []
            for node_id, parent_id, chat_message, node_created_at in node_rows:
                role, content, message_id, msg_created_at, msg_metadata = self._parse_chat_message(
                    chat_message, node_created_at
                )
                messages.append(
                    SessionMessage(
                        tool="devin",
                        session_id=session_id,
                        node_id=str(node_id) if node_id is not None else "",
                        parent_node_id=str(parent_id) if parent_id is not None else None,
                        message_id=message_id,
                        role=role,
                        content=content,
                        created_at=(
                            parse_datetime(msg_created_at)
                            if msg_created_at
                            else parse_datetime(node_created_at)
                        ),
                        metadata=msg_metadata,
                    )
                )

            # Build session-level metadata from tool-specific columns.
            session_metadata: dict[str, Any] = {}
            if backend_type is not None:
                session_metadata["backend_type"] = backend_type
            if agent_mode is not None:
                session_metadata["agent_mode"] = agent_mode
            # Include schema version for debugging (ADR 001, Layer 3).
            schema_version = self.detect_schema_version()
            if schema_version is not None:
                session_metadata["schema_version"] = schema_version
                if schema_version < queries.MINIMUM_SCHEMA_VERSION:
                    logger.warning(
                        "Devin DB schema version {} is below minimum {} — "
                        "some features may not work correctly",
                        schema_version,
                        queries.MINIMUM_SCHEMA_VERSION,
                    )
            if session_metadata_raw:
                try:
                    parsed = json.loads(session_metadata_raw)
                    if isinstance(parsed, dict):
                        session_metadata.update(parsed)
                except (json.JSONDecodeError, TypeError) as exc:
                    logger.warning("Failed to parse session metadata JSON: {}", exc)

            return SessionExport(
                tool="devin",
                session_id=session_id,
                title=title or "",
                working_directory=working_directory or "",
                created_at=parse_datetime(created_at) if created_at else None,
                last_activity_at=parse_datetime(last_activity_at) if last_activity_at else None,
                model=model,
                messages=messages,
                metadata=session_metadata,
            )
        except SessionNotFoundError:
            raise
        except sqlite3.Error as exc:
            raise SessionLoadError(f"Failed to load session {session_id}: {exc}") from exc
        finally:
            conn.close()

    def _detect_via_sqlite(self) -> str | None:
        db_path = self.config.db_path
        if not Path(db_path).exists():
            return None

        try:
            conn = self._connect()
        except SessionLoadError:
            return None

        try:
            cur = conn.cursor()
            cwd = str(Path.cwd())
            cur.execute(queries.DETECT_BY_CWD, (cwd,))
            row = cur.fetchone()
            if row is not None:
                return str(row[0])

            cur.execute(queries.DETECT_MOST_RECENT)
            row = cur.fetchone()
            if row is not None:
                return str(row[0])
            return None
        except sqlite3.Error as exc:
            logger.debug("SQLite detect failed: {}", exc)
            return None
        finally:
            conn.close()

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _parse_chat_message(
        chat_message: str | None, created_at: str | None
    ) -> tuple[str, str, str, str | None, dict[str, Any]]:
        """Parse the ``chat_message`` JSON column.

        Returns a tuple of (role, content, message_id, created_at, metadata).
        On parse failure, logs a warning and returns empty content/role with
        the node created_at preserved and an empty metadata dict.
        """
        if not chat_message:
            return "", "", "", created_at, {}
        try:
            obj: dict[str, Any] = json.loads(chat_message)
        except (json.JSONDecodeError, TypeError) as exc:
            logger.warning("Failed to parse chat_message JSON: {}", exc)
            return "", "", "", created_at, {}
        role = obj.get("role", "")
        content = obj.get("content", "")
        if not isinstance(content, str):
            content = json.dumps(content) if content is not None else ""
        message_id = obj.get("message_id", "")
        msg_created_at = obj.get("created_at", created_at)
        # Preserve tool-specific fields in the metadata escape hatch.
        metadata: dict[str, Any] = {}
        if "tool_calls" in obj:
            metadata["tool_calls"] = obj["tool_calls"]
        if "tool_call_id" in obj:
            metadata["tool_call_id"] = obj["tool_call_id"]
        if "metadata" in obj and isinstance(obj["metadata"], dict):
            metadata.update(obj["metadata"])
        return role, content, message_id, msg_created_at, metadata
