"""SQLite storage backend for the job queue.

This module provides ACID-compliant SQLite operations for job queue persistence.
"""

from __future__ import annotations

import sqlite3
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Any

from oclawma.queue.models import Job, JobStatus

SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    payload TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    scheduled_at TEXT,
    started_at TEXT,
    completed_at TEXT,
    retry_count INTEGER NOT NULL DEFAULT 0,
    max_retries INTEGER NOT NULL DEFAULT 3,
    error TEXT,
    priority INTEGER NOT NULL DEFAULT 0,
    depends_on TEXT,
    job_type TEXT DEFAULT 'default',
    encrypted INTEGER DEFAULT 0,
    encryption_metadata TEXT,
    cron_expression TEXT,
    timezone TEXT DEFAULT 'UTC',
    next_run_at TEXT,
    timeout_seconds REAL,
    cancellation_reason TEXT,
    cancelled_at TEXT,
    cancelled_by TEXT,
    tenant_id TEXT,

    -- Constraints
    CHECK (status IN ('pending', 'running', 'paused', 'completed', 'failed', 'cancelled')),
    CHECK (retry_count >= 0),
    CHECK (max_retries >= 0),
    CHECK (encrypted IN (0, 1))
);

-- Indexes for efficient querying
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_status_priority ON jobs(status, priority DESC);
CREATE INDEX IF NOT EXISTS idx_jobs_scheduled ON jobs(scheduled_at);
CREATE INDEX IF NOT EXISTS idx_jobs_status_created ON jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_jobs_depends_on ON jobs(depends_on);
CREATE INDEX IF NOT EXISTS idx_jobs_job_type ON jobs(job_type);
CREATE INDEX IF NOT EXISTS idx_jobs_encrypted ON jobs(encrypted);
CREATE INDEX IF NOT EXISTS idx_jobs_next_run ON jobs(next_run_at);
CREATE INDEX IF NOT EXISTS idx_jobs_cancelled_at ON jobs(cancelled_at);
CREATE INDEX IF NOT EXISTS idx_jobs_tenant_id ON jobs(tenant_id);
"""


class JobStoreError(Exception):
    """Raised when there's an error with job store operations."""

    pass


class JobNotFoundError(JobStoreError):
    """Raised when a job is not found in the store."""

    pass


class JobStore:
    """SQLite-backed job store with ACID-compliant operations.

    This class provides thread-safe operations for persisting jobs to SQLite.
    All operations are atomic and durable.
    """

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

        Args:
            db_path: Path to SQLite database file. Use ":memory:" for in-memory DB.
        """
        self.db_path = str(db_path)
        self._local = threading.local()
        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:
            # Use default isolation level (DEFERRED) for proper transaction support
            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()
        conn.executescript(SCHEMA_SQL)
        conn.commit()

    def insert(self, job: Job) -> Job:
        """Insert a new job into the store.

        Args:
            job: Job to insert (id will be auto-generated)

        Returns:
            The inserted job with assigned id

        Raises:
            JobStoreError: If insertion fails
        """
        data = job.to_db_dict()
        data.pop("id")  # Remove id for auto-increment

        sql = """
            INSERT INTO jobs (
                payload, status, created_at, updated_at, scheduled_at,
                started_at, completed_at, retry_count, max_retries, error, priority, depends_on,
                job_type, encrypted, encryption_metadata, cron_expression, timezone, next_run_at,
                timeout_seconds, cancellation_reason, cancelled_at, cancelled_by, tenant_id
            ) VALUES (
                :payload, :status, :created_at, :updated_at, :scheduled_at,
                :started_at, :completed_at, :retry_count, :max_retries, :error, :priority, :depends_on,
                :job_type, :encrypted, :encryption_metadata, :cron_expression, :timezone, :next_run_at,
                :timeout_seconds, :cancellation_reason, :cancelled_at, :cancelled_by, :tenant_id
            )
        """

        try:
            with self._transaction() as cursor:
                cursor.execute(sql, data)
                job_id = cursor.lastrowid
                if job_id is None:
                    raise JobStoreError("Failed to get lastrowid after insert")
                job.id = job_id
                return job
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to insert job: {e}") from e

    def get(self, job_id: int) -> Job:
        """Get a job by id.

        Args:
            job_id: Job identifier

        Returns:
            The job with the given id

        Raises:
            JobNotFoundError: If job not found
            JobStoreError: If query fails
        """
        try:
            with self._transaction() as cursor:
                cursor.execute("SELECT * FROM jobs WHERE id = ?", (job_id,))
                row = cursor.fetchone()

            if row is None:
                raise JobNotFoundError(f"Job {job_id} not found")

            return Job.from_db_row(dict(row))
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get job {job_id}: {e}") from e

    def update(self, job: Job) -> Job:
        """Update an existing job.

        Args:
            job: Job to update (must have id set)

        Returns:
            The updated job

        Raises:
            JobNotFoundError: If job not found
            JobStoreError: If update fails
        """
        if job.id is None:
            raise JobStoreError("Cannot update job without id")

        data = job.to_db_dict()

        sql = """
            UPDATE jobs SET
                payload = :payload,
                status = :status,
                updated_at = :updated_at,
                scheduled_at = :scheduled_at,
                started_at = :started_at,
                completed_at = :completed_at,
                retry_count = :retry_count,
                max_retries = :max_retries,
                error = :error,
                priority = :priority,
                depends_on = :depends_on,
                job_type = :job_type,
                encrypted = :encrypted,
                encryption_metadata = :encryption_metadata,
                cron_expression = :cron_expression,
                timezone = :timezone,
                next_run_at = :next_run_at,
                timeout_seconds = :timeout_seconds,
                cancellation_reason = :cancellation_reason,
                cancelled_at = :cancelled_at,
                cancelled_by = :cancelled_by,
                tenant_id = :tenant_id
            WHERE id = :id
        """

        try:
            with self._transaction() as cursor:
                cursor.execute(sql, data)
                if cursor.rowcount == 0:
                    raise JobNotFoundError(f"Job {job.id} not found")
            return job
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to update job {job.id}: {e}") from e

    def delete(self, job_id: int) -> bool:
        """Delete a job by id.

        Args:
            job_id: Job identifier

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

    def list_all(self, status: JobStatus | None = None, limit: int | None = None) -> list[Job]:
        """List jobs, optionally filtered by status.

        Args:
            status: Filter by status (optional)
            limit: Maximum number of jobs to return

        Returns:
            List of jobs
        """
        try:
            with self._transaction() as cursor:
                if status:
                    sql = "SELECT * FROM jobs WHERE status = ? ORDER BY created_at DESC"
                    params: tuple[Any, ...] = (status.value,)
                else:
                    sql = "SELECT * FROM jobs ORDER BY created_at DESC"
                    params = ()

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

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

            return [Job.from_db_row(dict(row)) for row in rows]
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to list jobs: {e}") from e

    def get_next_pending(self, exclude_ids: set[int] | None = None) -> Job | None:
        """Get the next pending job that should run.

        Selects jobs that are:
        - Status = pending
        - scheduled_at is NULL or <= current time
        - next_run_at is NULL or <= current time
        - Ordered by priority DESC, created_at ASC

        Args:
            exclude_ids: Job ids to exclude (e.g., currently being processed)

        Returns:
            Next job to run, or None if no jobs available
        """
        try:
            with self._transaction() as cursor:
                # Build query to get next pending job
                sql = """
                    SELECT * FROM jobs
                    WHERE status = 'pending'
                    AND (scheduled_at IS NULL OR scheduled_at <= datetime('now'))
                    AND (next_run_at IS NULL OR next_run_at <= datetime('now'))
                """
                params: list[Any] = []

                if exclude_ids:
                    placeholders = ",".join("?" * len(exclude_ids))
                    sql += f" AND id NOT IN ({placeholders})"
                    params.extend(exclude_ids)

                sql += " ORDER BY priority DESC, created_at ASC LIMIT 1"

                cursor.execute(sql, params)
                row = cursor.fetchone()

            return Job.from_db_row(dict(row)) if row else None
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get next pending job: {e}") from e

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

        Returns:
            Dictionary with counts for each status
        """
        try:
            with self._transaction() as cursor:
                cursor.execute(
                    """
                    SELECT
                        COUNT(*) as total,
                        SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
                        SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running,
                        SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
                        SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
                        SUM(CASE WHEN status = 'failed' AND retry_count < max_retries THEN 1 ELSE 0 END) as retryable
                    FROM jobs
                """
                )
                row = cursor.fetchone()

            return {
                "total": row["total"] or 0,
                "pending": row["pending"] or 0,
                "running": row["running"] or 0,
                "completed": row["completed"] or 0,
                "failed": row["failed"] or 0,
                "retryable": row["retryable"] or 0,
            }
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get stats: {e}") from e

    def cleanup_old_jobs(self, max_age_days: int, status: JobStatus | None = None) -> int:
        """Delete old jobs.

        Args:
            max_age_days: Delete jobs older than this many days
            status: Only delete jobs with this status (optional)

        Returns:
            Number of jobs deleted
        """
        try:
            with self._transaction() as cursor:
                if status:
                    sql = """
                        DELETE FROM jobs
                        WHERE status = ?
                        AND created_at < datetime('now', ? || ' days')
                    """
                    params = (status.value, f"-{max_age_days}")
                else:
                    sql = """
                        DELETE FROM jobs
                        WHERE created_at < datetime('now', ? || ' days')
                    """
                    params = (f"-{max_age_days}",)

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

    def get_running_jobs(self) -> list[Job]:
        """Get all currently running jobs.

        Returns:
            List of running jobs ordered by priority
        """
        try:
            with self._transaction() as cursor:
                cursor.execute(
                    "SELECT * FROM jobs WHERE status = 'running' ORDER BY priority ASC, started_at ASC"
                )
                rows = cursor.fetchall()
            return [Job.from_db_row(dict(row)) for row in rows]
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get running jobs: {e}") from e

    def get_by_priority(self, status: JobStatus | None = None) -> dict[int, int]:
        """Get job counts grouped by priority.

        Args:
            status: Optional status filter

        Returns:
            Dictionary mapping priority value to count
        """
        try:
            with self._transaction() as cursor:
                if status:
                    cursor.execute(
                        "SELECT priority, COUNT(*) as count FROM jobs WHERE status = ? GROUP BY priority",
                        (status.value,),
                    )
                else:
                    cursor.execute("SELECT priority, COUNT(*) as count FROM jobs GROUP BY priority")
                rows = cursor.fetchall()
            return {row["priority"]: row["count"] for row in rows}
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get priority stats: {e}") from e

    def get_blocked_jobs(self) -> list[Job]:
        """Get jobs that have unmet dependencies.

        Returns:
            List of jobs with dependencies that are not completed
        """
        try:
            with self._transaction() as cursor:
                # Get all pending jobs with dependencies
                cursor.execute(
                    "SELECT * FROM jobs WHERE status = 'pending' AND depends_on IS NOT NULL"
                )
                rows = cursor.fetchall()

            jobs = [Job.from_db_row(dict(row)) for row in rows]

            # Filter to only those with unmet dependencies
            blocked = []
            for job in jobs:
                if job.depends_on:
                    # Check if any dependency is not completed
                    placeholders = ",".join("?" * len(job.depends_on))
                    cursor.execute(
                        f"SELECT id FROM jobs WHERE id IN ({placeholders}) AND status != 'completed'",
                        tuple(job.depends_on),
                    )
                    incomplete = cursor.fetchall()
                    if incomplete:
                        blocked.append(job)

            return blocked
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get blocked jobs: {e}") from e

    def get_due_jobs(self, limit: int | None = None) -> list[Job]:
        """Get recurring jobs that are due to run.

        Returns jobs that have:
        - A cron_expression set
        - next_run_at <= current time

        Args:
            limit: Maximum number of jobs to return

        Returns:
            List of due recurring jobs
        """
        try:
            with self._transaction() as cursor:
                sql = """
                    SELECT * FROM jobs
                    WHERE cron_expression IS NOT NULL
                    AND next_run_at IS NOT NULL
                    AND next_run_at <= datetime('now')
                    ORDER BY next_run_at ASC
                """
                params: list[Any] = []

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

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

            return [Job.from_db_row(dict(row)) for row in rows]
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get due jobs: {e}") from e

    def close(self) -> None:
        """Close database connection."""
        if hasattr(self._local, "conn") and self._local.conn:
            self._local.conn.close()
            self._local.conn = None

    # =============================================================================
    # Tenant-aware Methods
    # =============================================================================

    def list_by_tenant(
        self,
        tenant_id: str,
        status: JobStatus | None = None,
        limit: int | None = None,
    ) -> list[Job]:
        """List jobs for a specific tenant.

        Args:
            tenant_id: The tenant identifier to filter by
            status: Filter by status (optional)
            limit: Maximum number of jobs to return

        Returns:
            List of jobs belonging to the tenant
        """
        try:
            with self._transaction() as cursor:
                if status:
                    sql = """
                        SELECT * FROM jobs
                        WHERE tenant_id = ? AND status = ?
                        ORDER BY created_at DESC
                    """
                    params: tuple[Any, ...] = (tenant_id, status.value)
                else:
                    sql = """
                        SELECT * FROM jobs
                        WHERE tenant_id = ?
                        ORDER BY created_at DESC
                    """
                    params = (tenant_id,)

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

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

            return [Job.from_db_row(dict(row)) for row in rows]
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to list jobs for tenant: {e}") from e

    def get_next_pending_by_tenant(
        self,
        tenant_id: str,
        exclude_ids: set[int] | None = None,
    ) -> Job | None:
        """Get the next pending job for a specific tenant.

        Args:
            tenant_id: The tenant identifier to filter by
            exclude_ids: Job ids to exclude (e.g., currently being processed)

        Returns:
            Next job to run for the tenant, or None if no jobs available
        """
        try:
            with self._transaction() as cursor:
                sql = """
                    SELECT * FROM jobs
                    WHERE tenant_id = ?
                    AND status = 'pending'
                    AND (scheduled_at IS NULL OR scheduled_at <= datetime('now'))
                """
                params: list[Any] = [tenant_id]

                if exclude_ids:
                    placeholders = ",".join("?" * len(exclude_ids))
                    sql += f" AND id NOT IN ({placeholders})"
                    params.extend(exclude_ids)

                sql += " ORDER BY priority DESC, created_at ASC LIMIT 1"

                cursor.execute(sql, params)
                row = cursor.fetchone()

            return Job.from_db_row(dict(row)) if row else None
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get next pending job for tenant: {e}") from e

    def get_stats_by_tenant(self, tenant_id: str) -> dict[str, int]:
        """Get job statistics for a specific tenant.

        Args:
            tenant_id: The tenant identifier to filter by

        Returns:
            Dictionary with counts for each status
        """
        try:
            with self._transaction() as cursor:
                cursor.execute(
                    """
                    SELECT
                        COUNT(*) as total,
                        SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
                        SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running,
                        SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
                        SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
                        SUM(CASE WHEN status = 'failed' AND retry_count < max_retries THEN 1 ELSE 0 END) as retryable
                    FROM jobs
                    WHERE tenant_id = ?
                    """,
                    (tenant_id,),
                )
                row = cursor.fetchone()

            return {
                "total": row["total"] or 0,
                "pending": row["pending"] or 0,
                "running": row["running"] or 0,
                "completed": row["completed"] or 0,
                "failed": row["failed"] or 0,
                "retryable": row["retryable"] or 0,
            }
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get stats for tenant: {e}") from e

    def get_tenant_job_count(self, tenant_id: str, status: JobStatus | None = None) -> int:
        """Get the count of jobs for a tenant.

        Args:
            tenant_id: The tenant identifier
            status: Optional status filter

        Returns:
            Number of jobs
        """
        try:
            with self._transaction() as cursor:
                if status:
                    cursor.execute(
                        "SELECT COUNT(*) as count FROM jobs WHERE tenant_id = ? AND status = ?",
                        (tenant_id, status.value),
                    )
                else:
                    cursor.execute(
                        "SELECT COUNT(*) as count FROM jobs WHERE tenant_id = ?",
                        (tenant_id,),
                    )
                row = cursor.fetchone()
                return row["count"] or 0
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to get job count for tenant: {e}") from e

    def cleanup_old_jobs_by_tenant(
        self,
        tenant_id: str,
        max_age_days: int,
        status: JobStatus | None = None,
    ) -> int:
        """Delete old jobs for a specific tenant.

        Args:
            tenant_id: The tenant identifier
            max_age_days: Delete jobs older than this many days
            status: Only delete jobs with this status (optional)

        Returns:
            Number of jobs deleted
        """
        try:
            with self._transaction() as cursor:
                if status:
                    sql = """
                        DELETE FROM jobs
                        WHERE tenant_id = ?
                        AND status = ?
                        AND created_at < datetime('now', ? || ' days')
                    """
                    params = (tenant_id, status.value, f"-{max_age_days}")
                else:
                    sql = """
                        DELETE FROM jobs
                        WHERE tenant_id = ?
                        AND created_at < datetime('now', ? || ' days')
                    """
                    params = (tenant_id, f"-{max_age_days}")

                cursor.execute(sql, params)
                return cursor.rowcount
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to cleanup old jobs for tenant: {e}") from e

    def list_all_tenants(self) -> list[str]:
        """List all unique tenant IDs that have jobs.

        Returns:
            List of tenant IDs
        """
        try:
            with self._transaction() as cursor:
                cursor.execute(
                    "SELECT DISTINCT tenant_id FROM jobs WHERE tenant_id IS NOT NULL ORDER BY tenant_id"
                )
                rows = cursor.fetchall()
                return [row["tenant_id"] for row in rows if row["tenant_id"]]
        except sqlite3.Error as e:
            raise JobStoreError(f"Failed to list tenants: {e}") from e

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

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