"""Dead Letter Queue (DLQ) for failed jobs.

This module provides a persistent store for jobs that have failed after
exceeding their maximum retry attempts. Jobs in the DLQ can be inspected,
replayed, or deleted.
"""

from __future__ import annotations

import builtins
import json
import logging
import sqlite3
import threading
import traceback
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any

from pydantic import BaseModel, Field

from oclawma.queue.models import Job, JobStatus

logger = logging.getLogger(__name__)

# Schema for the dead letter queue table
DLQ_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS dead_letter_queue (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    job_payload TEXT NOT NULL,
    failure_reason TEXT NOT NULL,
    error_message TEXT,
    stack_trace TEXT,
    retry_count INTEGER NOT NULL DEFAULT 0,
    failed_at TEXT NOT NULL,

    -- Indexes for efficient querying
    CREATE INDEX IF NOT EXISTS idx_dlq_failed_at ON dead_letter_queue(failed_at);
    CREATE INDEX IF NOT EXISTS idx_dlq_failure_reason ON dead_letter_queue(failure_reason);
"""


class DLQError(Exception):
    """Raised when there's an error with DLQ operations."""

    pass


class DLQNotFoundError(DLQError):
    """Raised when a DLQ entry is not found."""

    pass


class DLQEntry(BaseModel):
    """Represents a job in the dead letter queue.

    Attributes:
        id: Unique identifier for the DLQ entry
        original_job_id: The ID of the original job that failed
        job: The original job that failed
        failure_reason: The type/Class of the error (e.g., "ValueError")
        error_message: Human-readable error message
        stack_trace: Full stack trace if available
        retry_count: Number of retry attempts before giving up
        failed_at: When the job was moved to DLQ
        context: Additional context about the failure
        resolved: Whether the entry has been marked as resolved
        resolution_notes: Notes about how the issue was resolved
    """

    id: int | None = Field(default=None, description="Unique DLQ entry identifier")
    original_job_id: int | None = Field(default=None, description="Original job ID")
    job: Job = Field(description="The job that failed")
    failure_reason: str = Field(description="Error type/class")
    error_message: str | None = Field(default=None, description="Error message")
    stack_trace: str | None = Field(default=None, description="Stack trace")
    retry_count: int = Field(default=0, ge=0, description="Retry count when failed")
    failed_at: datetime = Field(default_factory=datetime.utcnow, description="When job entered DLQ")
    context: dict[str, Any] = Field(default_factory=dict, description="Additional context")
    resolved: bool = Field(default=False, description="Whether entry is resolved")
    resolution_notes: str | None = Field(default=None, description="Resolution notes")

    # Convenience properties for accessing job attributes
    @property
    def payload(self) -> dict[str, Any]:
        """Return the job payload."""
        return self.job.payload

    @property
    def priority(self) -> Any:
        """Return the job priority."""
        return self.job.priority

    @property
    def max_retries(self) -> int:
        """Return the job max_retries."""
        return self.job.max_retries

    @property
    def error_type(self) -> str:
        """Return the error type (alias for failure_reason)."""
        return self.failure_reason

    def to_db_dict(self) -> dict[str, Any]:
        """Convert to dictionary suitable for database storage."""

        def fmt_dt(dt: datetime | None) -> str | None:
            if dt is None:
                return None
            return dt.strftime("%Y-%m-%d %H:%M:%S")

        return {
            "id": self.id,
            "original_job_id": self.original_job_id,
            "job_payload": json.dumps(self.job.model_dump(mode="json")),
            "failure_reason": self.failure_reason,
            "error_message": self.error_message,
            "stack_trace": self.stack_trace,
            "retry_count": self.retry_count,
            "failed_at": fmt_dt(self.failed_at),
            "context": json.dumps(self.context),
            "resolved": self.resolved,
            "resolution_notes": self.resolution_notes,
        }

    @classmethod
    def from_db_row(cls, row: dict[str, Any]) -> DLQEntry:
        """Create DLQEntry from database row dictionary."""
        job_data = json.loads(row["job_payload"])
        job = Job.model_validate(job_data)

        # Parse context JSON, default to empty dict if null/missing
        context_data = {}
        if row.get("context"):
            try:
                context_data = json.loads(row["context"])
            except json.JSONDecodeError:
                context_data = {}

        return cls(
            id=row["id"],
            original_job_id=row.get("original_job_id"),
            job=job,
            failure_reason=row["failure_reason"],
            error_message=row["error_message"],
            stack_trace=row["stack_trace"],
            retry_count=row["retry_count"],
            failed_at=datetime.fromisoformat(row["failed_at"]),
            context=context_data,
            resolved=bool(row.get("resolved", False)),
            resolution_notes=row.get("resolution_notes"),
        )


class DLQStats(BaseModel):
    """Statistics about the dead letter queue."""

    total: int = Field(default=0, description="Total number of DLQ entries")
    by_error_type: dict[str, int] = Field(default_factory=dict, description="Count by error type")
    oldest: datetime | None = Field(default=None, description="Oldest entry timestamp")
    newest: datetime | None = Field(default=None, description="Newest entry timestamp")


class DeadLetterQueue:
    """Persistent dead letter queue for failed jobs.

    Features:
    - ACID-compliant SQLite storage
    - Store failed jobs with error details
    - Replay jobs back to the main queue
    - Statistics and metrics

    Example:
        >>> dlq = DeadLetterQueue("/path/to/queue.db")
        >>> entry = dlq.add(failed_job, exception)
        >>> entries = dlq.list()
        >>> replayed_job = dlq.replay(entry.id)
    """

    def __init__(self, db_path: str | Path = ":memory:") -> None:
        """Initialize the dead letter queue.

        Args:
            db_path: Path to SQLite database. Use ":memory:" for in-memory.
        """
        self.db_path = str(db_path)
        self._local = threading.local()
        self._lock = threading.RLock()
        self._init_db()

    def _get_connection(self) -> sqlite3.Connection:
        """Get thread-local database connection."""
        if not hasattr(self._local, "conn") or self._local.conn is None:
            self._local.conn = sqlite3.connect(
                self.db_path,
                check_same_thread=False,
            )
            self._local.conn.row_factory = sqlite3.Row
            # Enable foreign keys and WAL mode for better concurrency
            self._local.conn.execute("PRAGMA foreign_keys = ON")
            self._local.conn.execute("PRAGMA journal_mode = WAL")
        return self._local.conn

    @contextmanager
    def _transaction(self):
        """Context manager for database transactions."""
        conn = self._get_connection()
        cursor = conn.cursor()
        try:
            yield cursor
            conn.commit()
        except Exception:
            conn.rollback()
            raise

    def _init_db(self) -> None:
        """Initialize database schema."""
        conn = self._get_connection()

        # Create the dead_letter_queue table
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS dead_letter_queue (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                original_job_id INTEGER,
                job_payload TEXT NOT NULL,
                failure_reason TEXT NOT NULL,
                error_message TEXT,
                stack_trace TEXT,
                retry_count INTEGER NOT NULL DEFAULT 0,
                failed_at TEXT NOT NULL,
                context TEXT,
                resolved INTEGER NOT NULL DEFAULT 0,
                resolution_notes TEXT
            )
            """
        )

        # Create indexes
        conn.execute("CREATE INDEX IF NOT EXISTS idx_dlq_failed_at ON dead_letter_queue(failed_at)")
        conn.execute(
            "CREATE INDEX IF NOT EXISTS idx_dlq_failure_reason ON dead_letter_queue(failure_reason)"
        )
        conn.execute("CREATE INDEX IF NOT EXISTS idx_dlq_resolved ON dead_letter_queue(resolved)")

        conn.commit()

    def add(
        self,
        job: Job,
        error: Exception,
        stack_trace: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> int:
        """Add a failed job to the dead letter queue.

        Args:
            job: The job that failed
            error: The exception that caused the failure
            stack_trace: Optional stack trace string
            context: Optional context dict with additional failure info

        Returns:
            The ID of the created DLQ entry
        """
        with self._lock:
            # Extract error information
            failure_reason = error.__class__.__name__
            error_message = str(error)

            # For generic Exception, use the message as the failure reason
            # (e.g., Exception("NetworkError") -> failure_reason="NetworkError")
            if failure_reason == "Exception" and error_message:
                failure_reason = error_message

            # Capture stack trace if not provided
            if stack_trace is None:
                stack_trace = traceback.format_exc()

            # Use context["attempt"] as retry_count if provided, otherwise use job.retry_count
            retry_count = context.get("attempt", job.retry_count) if context else job.retry_count

            entry = DLQEntry(
                original_job_id=job.id,
                job=job,
                failure_reason=failure_reason,
                error_message=error_message,
                stack_trace=stack_trace,
                retry_count=retry_count,
                context=context or {},
            )

            data = entry.to_db_dict()
            data.pop("id")  # Remove id for auto-increment

            sql = """
                INSERT INTO dead_letter_queue (
                    original_job_id, job_payload, failure_reason, error_message, stack_trace,
                    retry_count, failed_at, context, resolved, resolution_notes
                ) VALUES (
                    :original_job_id, :job_payload, :failure_reason, :error_message, :stack_trace,
                    :retry_count, :failed_at, :context, :resolved, :resolution_notes
                )
            """

            try:
                with self._transaction() as cursor:
                    cursor.execute(sql, data)
                    entry_id = cursor.lastrowid
                    if entry_id is None:
                        raise DLQError("Failed to get lastrowid after insert")

                logger.info(f"Added job to DLQ (entry {entry_id}): {failure_reason}")
                return entry_id
            except sqlite3.Error as e:
                raise DLQError(f"Failed to add job to DLQ: {e}") from e

    def list(self, limit: int | None = None) -> list[DLQEntry]:
        """List entries in the dead letter queue.

        Args:
            limit: Maximum number of entries to return

        Returns:
            List of DLQ entries, ordered by failed_at (oldest first)
        """
        try:
            with self._transaction() as cursor:
                sql = "SELECT * FROM dead_letter_queue ORDER BY failed_at ASC"
                params: tuple[Any, ...] = ()

                if limit:
                    sql += " LIMIT ?"
                    params = (limit,)

                cursor.execute(sql, params)
                rows = cursor.fetchall()

            return [DLQEntry.from_db_row(dict(row)) for row in rows]
        except sqlite3.Error as e:
            raise DLQError(f"Failed to list DLQ entries: {e}") from e

    def list_entries(
        self, limit: int | None = None, resolved: bool | None = None
    ) -> builtins.list[DLQEntry]:
        """List entries in the dead letter queue.

        Args:
            limit: Maximum number of entries to return
            resolved: Filter by resolved status (None = all, True = resolved only, False = unresolved only)

        Returns:
            List of DLQ entries, ordered by failed_at (oldest first)
        """
        try:
            with self._transaction() as cursor:
                sql = "SELECT * FROM dead_letter_queue"
                params: list[Any] = []

                if resolved is not None:
                    sql += " WHERE resolved = ?"
                    params.append(1 if resolved else 0)

                sql += " ORDER BY failed_at ASC"

                if limit:
                    sql += " LIMIT ?"
                    params.append(limit)

                cursor.execute(sql, params)
                rows = cursor.fetchall()

            return [DLQEntry.from_db_row(dict(row)) for row in rows]
        except sqlite3.Error as e:
            raise DLQError(f"Failed to list DLQ entries: {e}") from e

    def get(self, entry_id: int) -> DLQEntry:
        """Get a DLQ entry by id.

        Args:
            entry_id: DLQ entry identifier

        Returns:
            The DLQ entry

        Raises:
            DLQNotFoundError: If entry not found
        """
        try:
            with self._transaction() as cursor:
                cursor.execute("SELECT * FROM dead_letter_queue WHERE id = ?", (entry_id,))
                row = cursor.fetchone()

            if row is None:
                raise DLQNotFoundError(f"DLQ entry {entry_id} not found")

            return DLQEntry.from_db_row(dict(row))
        except sqlite3.Error as e:
            raise DLQError(f"Failed to get DLQ entry {entry_id}: {e}") from e

    def get_entry(self, entry_id: int) -> DLQEntry:
        """Get a DLQ entry by id (alias for get()).

        Args:
            entry_id: DLQ entry identifier

        Returns:
            The DLQ entry

        Raises:
            DLQNotFoundError: If entry not found
        """
        return self.get(entry_id)

    def mark_resolved(self, entry_id: int, resolution_notes: str | None = None) -> bool:
        """Mark a DLQ entry as resolved.

        Args:
            entry_id: DLQ entry identifier
            resolution_notes: Optional notes about how the issue was resolved

        Returns:
            True if marked as resolved, False if not found
        """
        try:
            with self._transaction() as cursor:
                cursor.execute(
                    "UPDATE dead_letter_queue SET resolved = 1, resolution_notes = ? WHERE id = ?",
                    (resolution_notes, entry_id),
                )
                return cursor.rowcount > 0
        except sqlite3.Error as e:
            raise DLQError(f"Failed to mark DLQ entry {entry_id} as resolved: {e}") from e

    def cleanup_old_entries(self, max_age_days: int, resolved_only: bool = True) -> int:
        """Clean up old DLQ entries.

        Args:
            max_age_days: Delete entries older than or equal to this many days
            resolved_only: If True, only delete resolved entries

        Returns:
            Number of entries deleted
        """
        try:
            with self._transaction() as cursor:
                sql = f"DELETE FROM dead_letter_queue WHERE failed_at <= datetime('now', '-{max_age_days} days')"
                params: list[Any] = []

                if resolved_only:
                    sql += " AND resolved = 1"

                cursor.execute(sql, params)
                return cursor.rowcount
        except sqlite3.Error as e:
            raise DLQError(f"Failed to cleanup old DLQ entries: {e}") from e

    def delete(self, entry_id: int) -> bool:
        """Delete a DLQ entry by id.

        Args:
            entry_id: DLQ entry identifier

        Returns:
            True if deleted, False if not found
        """
        try:
            with self._transaction() as cursor:
                cursor.execute("DELETE FROM dead_letter_queue WHERE id = ?", (entry_id,))
                return cursor.rowcount > 0
        except sqlite3.Error as e:
            raise DLQError(f"Failed to delete DLQ entry {entry_id}: {e}") from e

    def replay(self, entry_id: int) -> Job:
        """Replay a job from the DLQ.

        Creates a new job based on the original failed job, with:
        - New job ID
        - Status set to PENDING
        - retry_count reset to 0
        - error cleared

        Args:
            entry_id: DLQ entry identifier

        Returns:
            A new Job ready to be queued

        Raises:
            DLQNotFoundError: If entry not found
        """
        with self._lock:
            entry = self.get(entry_id)

            # Create a new job based on the original
            new_job = Job(
                payload=entry.job.payload,
                status=JobStatus.PENDING,
                scheduled_at=None,
                retry_count=0,
                max_retries=entry.job.max_retries,
                priority=entry.job.priority,
                depends_on=entry.job.depends_on,
            )

            # Delete the DLQ entry
            self.delete(entry_id)

            logger.info(f"Replayed job from DLQ entry {entry_id}")
            return new_job

    def replay_all(self) -> builtins.list[Job]:
        """Replay all jobs from the DLQ.

        Returns:
            List of new Jobs ready to be queued
        """
        with self._lock:
            entries = self.list()
            replayed = []

            for entry in entries:
                new_job = Job(
                    payload=entry.job.payload,
                    status=JobStatus.PENDING,
                    scheduled_at=None,
                    retry_count=0,
                    max_retries=entry.job.max_retries,
                    priority=entry.job.priority,
                    depends_on=entry.job.depends_on,
                )
                replayed.append(new_job)

            # Delete all entries
            try:
                with self._transaction() as cursor:
                    cursor.execute("DELETE FROM dead_letter_queue")
            except sqlite3.Error as e:
                raise DLQError(f"Failed to clear DLQ after replay: {e}") from e

            logger.info(f"Replayed {len(replayed)} jobs from DLQ")
            return replayed

    def get_stats(self) -> dict[str, Any]:
        """Get DLQ statistics.

        Returns:
            Dictionary with total, unresolved, resolved, and by_error_type counts
        """
        try:
            with self._transaction() as cursor:
                # Get total count
                cursor.execute("SELECT COUNT(*) as total FROM dead_letter_queue")
                total = cursor.fetchone()["total"] or 0

                # Get resolved count
                cursor.execute(
                    "SELECT COUNT(*) as resolved FROM dead_letter_queue WHERE resolved = 1"
                )
                resolved = cursor.fetchone()["resolved"] or 0

                # Get error type breakdown
                cursor.execute(
                    "SELECT failure_reason, COUNT(*) as count FROM dead_letter_queue GROUP BY failure_reason"
                )
                by_error_type: dict[str, int] = {}
                for row in cursor.fetchall():
                    by_error_type[row["failure_reason"]] = row["count"]

            return {
                "total": total,
                "resolved": resolved,
                "unresolved": total - resolved,
                "by_error_type": by_error_type,
            }
        except sqlite3.Error as e:
            raise DLQError(f"Failed to get DLQ stats: {e}") from e

    def close(self) -> None:
        """Close the DLQ and release resources."""
        if hasattr(self._local, "conn") and self._local.conn:
            self._local.conn.close()
            self._local.conn = None

    def __enter__(self) -> DeadLetterQueue:
        """Context manager entry."""
        return self

    def __exit__(self, *args: Any) -> None:
        """Context manager exit."""
        self.close()

    def __len__(self) -> int:
        """Return total number of entries in DLQ."""
        return self.get_stats()["total"]
