"""
Scheduler Service with DB locking to prevent double-fire.
Designed to run in multiple workers without conflicts.
"""

import asyncio
import hashlib
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from croniter import croniter
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
import random

log = logging.getLogger(__name__)


class SchedulerService:
    """
    Background scheduler that picks up due jobs with DB locking.
    Only one worker will execute each job (no double-fire).
    """

    def __init__(self):
        self.scheduler = AsyncIOScheduler(timezone="UTC")
        self.is_leader = False
        self.lock_acquired = False

    async def start(self, db: AsyncSession):
        """Start the scheduler and become leader if possible."""
        try:
            # Try to become leader (simple lock using a config flag)
            await self._try_acquire_leader_lock(db)

            if self.is_leader:
                log.info("This worker is the scheduler leader")
                # Schedule recurring job picker
                self.scheduler.add_job(
                    self._pick_and_enqueue_due_jobs,
                    trigger="interval",
                    seconds=30,  # Check every 30s for due jobs
                    args=[db],
                    id="job_picker",
                    max_instances=1,
                    replace_existing=True,
                )

                # Catch up on missed runs at startup
                await self._catchup_missed_runs(db)

                self.scheduler.start()
                log.info("Scheduler started successfully")
            else:
                log.info("Another worker is the scheduler leader, staying passive")

        except Exception as e:
            log.error(f"Failed to start scheduler: {e}", exc_info=True)

    async def _try_acquire_leader_lock(self, db: AsyncSession):
        """Simple leader election using DB flag."""
        from ..models import SystemConfig

        try:
            # Try to set ourselves as leader
            worker_id = f"worker-{random.randint(1000, 9999)}"
            now = datetime.utcnow()
            expires_at = now + timedelta(minutes=5)  # Leader lock expires in 5 min

            # Check if there's a current leader
            result = await db.execute(
                select(SystemConfig).where(SystemConfig.key == "scheduler_leader")
            )
            leader_config = result.scalar_one_or_none()

            if leader_config:
                # Check if leader lock is expired
                if leader_config.expires_at and leader_config.expires_at < now:
                    # Take over leadership
                    leader_config.value = worker_id
                    leader_config.expires_at = expires_at
                    leader_config.updated_at = now
                    await db.commit()
                    self.is_leader = True
                    log.info("Acquired leader lock (took over from expired leader)")
                else:
                    # Another leader is active
                    self.is_leader = False
            else:
                # No leader exists, claim it
                new_leader = SystemConfig(
                    key="scheduler_leader",
                    value=worker_id,
                    expires_at=expires_at,
                    created_at=now,
                    updated_at=now,
                )
                db.add(new_leader)
                await db.commit()
                self.is_leader = True
                log.info("Acquired leader lock (first leader)")

            # Refresh lock every 2 minutes
            if self.is_leader:
                self.scheduler.add_job(
                    self._refresh_leader_lock,
                    trigger="interval",
                    seconds=120,
                    args=[db, worker_id],
                    id="leader_lock_refresh",
                )

        except Exception as e:
            log.error(f"Failed to acquire leader lock: {e}")
            self.is_leader = False

    async def _refresh_leader_lock(self, db: AsyncSession, worker_id: str):
        """Refresh leader lock to keep leadership."""
        from ..models import SystemConfig

        try:
            result = await db.execute(
                select(SystemConfig).where(SystemConfig.key == "scheduler_leader")
            )
            leader_config = result.scalar_one_or_none()

            if leader_config and leader_config.value == worker_id:
                leader_config.expires_at = datetime.utcnow() + timedelta(minutes=5)
                leader_config.updated_at = datetime.utcnow()
                await db.commit()
                log.debug("Refreshed leader lock")

        except Exception as e:
            log.error(f"Failed to refresh leader lock: {e}")

    async def _pick_and_enqueue_due_jobs(self, db: AsyncSession):
        """
        Pick due jobs and enqueue them (with DB locking to prevent double-fire).
        Uses UPDATE...WHERE next_run_at<=now to atomically claim jobs.
        """
        from ..models import ScheduledJob

        try:
            now = datetime.utcnow()

            # Find due jobs (atomic update to claim them)
            # Only this worker will succeed in updating next_run_at
            result = await db.execute(
                select(ScheduledJob)
                .where(
                    and_(
                        ScheduledJob.is_active == True,
                        ScheduledJob.paused_by_system == False,
                        ScheduledJob.next_run_at <= now,
                    )
                )
                .with_for_update(skip_locked=True)  # Skip locked rows (other workers)
            )

            due_jobs = result.scalars().all()

            if not due_jobs:
                return

            log.info(f"Found {len(due_jobs)} due jobs to execute")

            for job in due_jobs:
                try:
                    # Calculate next run time
                    next_run = self._calculate_next_run(job.schedule, now)

                    # Update next_run_at to prevent other workers from picking this job
                    job.next_run_at = next_run
                    job.last_run_at = now

                    await db.commit()

                    # Enqueue for execution
                    await self._execute_job(job, db)

                except Exception as e:
                    log.error(f"Failed to enqueue job {job.id}: {e}", exc_info=True)
                    job.error_count = (job.error_count or 0) + 1

                    # Check if error is auth-related
                    error_str = str(e).lower()
                    is_google_auth_error = "google_auth_missing" in error_str or (
                        "google" in error_str
                        and any(
                            x in error_str
                            for x in [
                                "oauth",
                                "auth",
                                "token",
                                "credential",
                                "invalid_grant",
                            ]
                        )
                    )
                    is_auth_error = is_google_auth_error or any(
                        x in error_str
                        for x in [
                            "refresh_token",
                            "expired",
                            "unauthorized",
                            "authentication",
                            "invalid_grant",
                            "token",
                            "credentials",
                        ]
                    )

                    # Pause job after too many errors
                    if job.error_count >= 5:
                        job.paused_by_system = True

                        if is_auth_error:
                            if is_google_auth_error:
                                job.pause_reason = (
                                    "Google Sheets authorization expired. Please reconnect."
                                )
                                await self._notify_auth_failure(
                                    job, db, provider="Google Sheets"
                                )
                            else:
                                job.pause_reason = (
                                    "Salesforce credentials expired. Please reconnect."
                                )
                                await self._notify_auth_failure(
                                    job, db, provider="Salesforce"
                                )
                        else:
                            job.pause_reason = f"Too many errors: {str(e)[:200]}"
                            # Send general failure notification
                            await self._notify_schedule_paused(job, str(e), db)

                        log.warning(
                            f"Paused job {job.id} after 5 errors (auth_error={is_auth_error})"
                        )

                    elif job.error_count == 3 and is_auth_error:
                        # Early warning for auth issues
                        await self._notify_auth_failure(
                            job,
                            db,
                            provider="Google Sheets" if is_google_auth_error else "Salesforce",
                        )

                    await db.commit()

        except Exception as e:
            log.error(f"Error in job picker: {e}", exc_info=True)

    def _calculate_next_run(self, schedule: str, from_time: datetime) -> datetime:
        """Calculate next run time from schedule (cron or preset)."""
        # Handle presets
        if schedule == "daily":
            return from_time + timedelta(days=1)
        elif schedule == "hourly":
            return from_time + timedelta(hours=1)
        else:
            # Parse as cron
            try:
                cron = croniter(schedule, from_time)
                return cron.get_next(datetime)
            except Exception as e:
                log.error(f"Invalid cron schedule '{schedule}': {e}")
                # Default to daily
                return from_time + timedelta(days=1)

    async def _execute_job(self, job, db: AsyncSession):
        """
        Execute a scheduled job with idempotency.
        Creates job_run record and delegates to worker.
        """
        from ..models import JobRun
        import json
        from sqlalchemy import func

        try:
            config_data = (
                json.loads(job.config)
                if isinstance(job.config, str)
                else (job.config or {})
            )
            config_hash_source = json.dumps(config_data, sort_keys=True)
            window_basis = job.last_run_at or datetime.utcnow()
            window_start = window_basis.replace(second=0, microsecond=0)
            idempotency_key = hashlib.sha256(
                f"{job.account_id}:{job.sheet_id}:{config_hash_source}:{window_start.isoformat()}".encode()
            ).hexdigest()

            # Check if already executed
            result = await db.execute(
                select(JobRun).where(JobRun.idempotency_key == idempotency_key)
            )
            existing_run = result.scalar_one_or_none()

            if existing_run:
                log.info(
                    f"Job {job.id} already executed (idempotency key: {idempotency_key[:8]}...)"
                )
                return

            # Enforce per-job rate limits if configured
            rate_limit = config_data.get("rate_limit_per_hour")
            if rate_limit:
                window_cutoff = datetime.utcnow() - timedelta(hours=1)
                count_result = await db.execute(
                    select(func.count(JobRun.id)).where(
                        JobRun.job_id == job.id,
                        JobRun.started_at >= window_cutoff,
                        JobRun.status.in_(("running", "success", "failed")),
                    )
                )
                executions_last_hour = count_result.scalar() or 0

                if executions_last_hour >= rate_limit:
                    skip_run = JobRun(
                        id=f"run_{datetime.utcnow().timestamp()}",
                        job_id=job.id,
                        idempotency_key=f"{idempotency_key}_skip",
                        status="skipped",
                        started_at=datetime.utcnow(),
                        completed_at=datetime.utcnow(),
                        duration_ms=0,
                        rows_returned=0,
                        error_message=f"Skipped due to rate limit ({rate_limit} per hour)",
                    )
                    db.add(skip_run)
                    await db.commit()
                    await self._record_job_metric(
                        job, "skipped", 0, 0, "rate_limit_exceeded"
                    )
                    log.info(
                        f"Job {job.id} skipped: rate limit {executions_last_hour}/{rate_limit} reached"
                    )
                    return

            # Create job run record
            run = JobRun(
                id=f"run_{datetime.utcnow().timestamp()}",
                job_id=job.id,
                idempotency_key=idempotency_key,
                status="pending",
                started_at=datetime.utcnow(),
            )
            db.add(run)
            await db.commit()

            # Enqueue to worker (RQ or direct execution)
            if job.job_type == "soql_query":
                await self._execute_soql_job(job, run, db, config_override=config_data)
            elif job.job_type == "report_export":
                await self._execute_report_job(
                    job, run, db, config_override=config_data
                )
            else:
                raise ValueError(f"Unknown job type: {job.job_type}")

        except Exception as e:
            log.error(f"Failed to execute job {job.id}: {e}", exc_info=True)
            # Record in DLQ
            await self._record_to_dlq(job.id, str(e), db)

    async def _execute_soql_job(self, job, run, db: AsyncSession, config_override=None):
        """Execute SOQL query job."""
        from ...services.salesforce_gateway import SalesforceGateway
        from ...services.direct_sheets_writer import (
            DirectSheetsWriter,
            DirectSheetsWriterError,
            RateLimitedError,
        )
        from ...services.google_sheets_oauth import ensure_google_access_token
        from ..security.crypto import decrypt_str
        from .. import settings as saas_settings
        import json

        try:
            run.status = "running"
            await db.commit()

            access_token, integration = await ensure_google_access_token(
                job.account_id, db
            )
            if not access_token:
                raise RuntimeError("google_auth_missing")

            refresh_token = None
            if integration and integration.refresh_token:
                refresh_token = (
                    decrypt_str(integration.refresh_token)
                    if saas_settings.ENCRYPTION_KEY
                    else integration.refresh_token
                )

            config = config_override or (
                json.loads(job.config) if isinstance(job.config, str) else job.config
            )
            soql = config.get("soql")

            # Get gateway
            gateway = await SalesforceGateway.for_tenant(job.account_id, db)

            # Execute query
            records = []
            async for batch in gateway.paginate_soql(soql):
                records.extend(batch)

                # Enforce row limit based on tier
                from ..middleware.quota_checker import get_quota

                quota = await get_quota(job.account_id, db)
                row_limit = quota.get_limits()["rows_per_run"]

                if len(records) >= row_limit:
                    records = records[:row_limit]
                    log.warning(
                        f"Truncated query results to {row_limit} rows for job {job.id}"
                    )
                    break

            if not records:
                log.info(f"Job {job.id} returned no records")
                run.status = "success"
                run.rows_returned = 0
                run.completed_at = datetime.utcnow()
                run.duration_ms = int(
                    (run.completed_at - run.started_at).total_seconds() * 1000
                )
                job.last_write_status = "success"
                job.last_write_at = datetime.utcnow()
                job.last_write_error = None
                job.updated_at = datetime.utcnow()
                await db.commit()
                return

            sheet_name = (
                config.get("target_sheet")
                or config.get("sheet_name")
                or "Scheduled Data"
            )

            # Extract headers and convert to rows
            headers = list(records[0].keys()) if records else []
            rows = [[record.get(h, "") for h in headers] for record in records]

            sheet_gid = (
                job.sheet_gid
                or config.get("sheet_gid")
                or config.get("sheetGid")
                or config.get("target_sheet_gid")
            )
            cached_title = None
            if hasattr(job, "cached_sheet_title"):
                cached_title = job.cached_sheet_title
            if isinstance(config, dict):
                cached_title = cached_title or config.get("cached_sheet_title")
                cached_title = cached_title or config.get("target_sheet")
                cached_title = cached_title or config.get("sheet_name")
            writer = DirectSheetsWriter(
                access_token=access_token,
                refresh_token=refresh_token,
                expires_at=integration.token_expires_at if integration else None,
                account_id=job.account_id,
            )
            try:
                write_result = await asyncio.to_thread(
                    writer.write_to_sheet,
                    spreadsheet_id=job.sheet_id,
                    sheet_name=sheet_name,
                    headers=headers,
                    rows=rows,
                    sheet_gid=sheet_gid,
                    cached_title=cached_title,
                )
            except RateLimitedError as rate_err:
                raise rate_err
            except DirectSheetsWriterError as write_err:
                raise RuntimeError(f"Google Sheets write failed: {write_err}") from write_err
            except Exception as write_err:
                raise RuntimeError(f"Google Sheets write failed: {write_err}") from write_err

            log.info(
                f"Job {job.id} completed: {len(records)} rows, write_result: {write_result}"
            )

            run.status = "success"
            run.rows_returned = len(records)
            run.completed_at = datetime.utcnow()
            run.duration_ms = int(
                (run.completed_at - run.started_at).total_seconds() * 1000
            )
            run.output_location = None
            job.last_write_status = "success"
            job.last_write_at = datetime.utcnow()
            job.last_write_error = None
            job.updated_at = datetime.utcnow()

            await db.commit()

            await self._record_job_metric(
                job, "success", len(records), run.duration_ms, None
            )

        except Exception as e:
            log.error(f"SOQL job {job.id} failed: {e}", exc_info=True)
            run.status = "failed"
            run.completed_at = datetime.utcnow()
            run.duration_ms = int(
                (run.completed_at - run.started_at).total_seconds() * 1000
            )
            if not run.rows_returned:
                run.rows_returned = 0
            error_str = str(e).lower()
            rate_limited = isinstance(e, RateLimitedError) or "rate limit" in error_str
            auth_missing = not rate_limited and (
                "google_auth_missing" in error_str
                or (
                    "google" in error_str
                    and any(
                        x in error_str
                        for x in ["oauth", "auth", "token", "credential", "invalid_grant"]
                    )
                )
            )
            error_message = str(e)[:500]
            if auth_missing and "google_auth_missing" in error_message.lower():
                error_message = (
                    "Google Sheets authorization required. Reconnect in Schedule Manager."
                )
            if rate_limited:
                jitter_seconds = random.randint(60, 300)
                job.next_run_at = datetime.utcnow() + timedelta(seconds=jitter_seconds)
                error_message = "Google Sheets rate limited. Retrying soon."
            run.error_message = error_message
            if rate_limited:
                job.last_write_status = "rate_limited"
            else:
                job.last_write_status = "auth_missing" if auth_missing else "failed"
            job.last_write_at = datetime.utcnow()
            job.last_write_error = error_message
            job.updated_at = datetime.utcnow()
            await db.commit()
            await self._record_job_metric(
                job, "failed", run.rows_returned or 0, run.duration_ms, str(e)
            )
            raise

    async def _execute_report_job(
        self, job, run, db: AsyncSession, config_override=None
    ):
        """Execute report export job."""
        from ...services.salesforce_gateway import SalesforceGateway
        from ...services.direct_sheets_writer import (
            DirectSheetsWriter,
            DirectSheetsWriterError,
            RateLimitedError,
        )
        from ...services.google_sheets_oauth import ensure_google_access_token
        from ..middleware.quota_checker import get_quota
        from ..security.crypto import decrypt_str
        from .. import settings as saas_settings
        import json
        import csv
        import io

        try:
            run.status = "running"
            await db.commit()

            access_token, integration = await ensure_google_access_token(
                job.account_id, db
            )
            if not access_token:
                raise RuntimeError("google_auth_missing")

            refresh_token = None
            if integration and integration.refresh_token:
                refresh_token = (
                    decrypt_str(integration.refresh_token)
                    if saas_settings.ENCRYPTION_KEY
                    else integration.refresh_token
                )

            config = config_override or (
                json.loads(job.config)
                if isinstance(job.config, str)
                else (job.config or {})
            )
            report_id = config.get("report_id")
            if not report_id:
                raise ValueError("Report ID is required for report export jobs")

            target_sheet = (
                config.get("target_sheet")
                or config.get("sheet_name")
                or config.get("name")
                or "Scheduled Report"
            )

            gateway = await SalesforceGateway.for_tenant(job.account_id, db)
            export_result = await gateway.export_report(report_id)

            if not export_result or not export_result.get("success"):
                error_message = (
                    export_result.get("error") if export_result else "Unknown error"
                )
                raise ValueError(f"Report export failed: {error_message}")

            csv_content = export_result.get("csv") or ""
            headers: List[str] = []
            rows: List[List[str]] = []

            if csv_content:
                reader = csv.reader(io.StringIO(csv_content))
                for index, row_values in enumerate(reader):
                    if index == 0:
                        headers = row_values
                    else:
                        rows.append(row_values)

            quota = await get_quota(job.account_id, db)
            limits = quota.get_limits() if quota else {}
            row_limit = limits.get("rows_per_run")
            if row_limit and len(rows) > row_limit:
                rows = rows[:row_limit]
                log.warning(
                    f"Truncated report results to {row_limit} rows for job {job.id}"
                )

            if headers:
                normalized_rows = []
                header_count = len(headers)
                for row_values in rows:
                    if len(row_values) > header_count:
                        normalized_rows.append(row_values[:header_count])
                    elif len(row_values) < header_count:
                        normalized_rows.append(
                            row_values + [""] * (header_count - len(row_values))
                        )
                    else:
                        normalized_rows.append(row_values)
                rows = normalized_rows

            sheet_gid = (
                job.sheet_gid
                or config.get("sheet_gid")
                or config.get("sheetGid")
                or config.get("target_sheet_gid")
            )
            cached_title = None
            if hasattr(job, "cached_sheet_title"):
                cached_title = job.cached_sheet_title
            if isinstance(config, dict):
                cached_title = cached_title or config.get("cached_sheet_title")
                cached_title = cached_title or config.get("target_sheet")
                cached_title = cached_title or config.get("sheet_name")
            if headers and rows:
                writer = DirectSheetsWriter(
                    access_token=access_token,
                    refresh_token=refresh_token,
                    expires_at=integration.token_expires_at if integration else None,
                    account_id=job.account_id,
                )
                try:
                    write_result = await asyncio.to_thread(
                        writer.write_to_sheet,
                        spreadsheet_id=job.sheet_id,
                        sheet_name=target_sheet,
                        headers=headers,
                        rows=rows,
                        sheet_gid=sheet_gid,
                        cached_title=cached_title,
                    )
                except RateLimitedError as rate_err:
                    raise rate_err
                except DirectSheetsWriterError as write_err:
                    raise RuntimeError(
                        f"Google Sheets write failed: {write_err}"
                    ) from write_err
                except Exception as write_err:
                    raise RuntimeError(
                        f"Google Sheets write failed: {write_err}"
                    ) from write_err
            else:
                write_result = {"ok": True, "message": "No rows returned"}

            run.status = "success"
            run.rows_returned = len(rows)
            run.completed_at = datetime.utcnow()
            run.duration_ms = int(
                (run.completed_at - run.started_at).total_seconds() * 1000
            )
            run.output_location = (
                write_result.get("storage_key")
                if isinstance(write_result, dict)
                else None
            )
            job.last_write_status = "success"
            job.last_write_at = datetime.utcnow()
            job.last_write_error = None
            job.updated_at = datetime.utcnow()

            await db.commit()

            await self._record_job_metric(
                job, "success", len(rows), run.duration_ms, None
            )

        except Exception as e:
            log.error(f"Report job {job.id} failed: {e}", exc_info=True)
            run.status = "failed"
            run.completed_at = datetime.utcnow()
            run.duration_ms = int(
                (run.completed_at - run.started_at).total_seconds() * 1000
            )
            if not run.rows_returned:
                run.rows_returned = len(rows) if "rows" in locals() else 0
            error_str = str(e).lower()
            rate_limited = isinstance(e, RateLimitedError) or "rate limit" in error_str
            auth_missing = not rate_limited and (
                "google_auth_missing" in error_str
                or (
                    "google" in error_str
                    and any(
                        x in error_str
                        for x in ["oauth", "auth", "token", "credential", "invalid_grant"]
                    )
                )
            )
            error_message = str(e)[:500]
            if auth_missing and "google_auth_missing" in error_message.lower():
                error_message = (
                    "Google Sheets authorization required. Reconnect in Schedule Manager."
                )
            if rate_limited:
                jitter_seconds = random.randint(60, 300)
                job.next_run_at = datetime.utcnow() + timedelta(seconds=jitter_seconds)
                error_message = "Google Sheets rate limited. Retrying soon."
            run.error_message = error_message
            if rate_limited:
                job.last_write_status = "rate_limited"
            else:
                job.last_write_status = "auth_missing" if auth_missing else "failed"
            job.last_write_at = datetime.utcnow()
            job.last_write_error = error_message
            job.updated_at = datetime.utcnow()
            await db.commit()
            await self._record_job_metric(
                job, "failed", run.rows_returned or 0, run.duration_ms, str(e)
            )
            raise

    async def _record_job_metric(
        self, job, status: str, rows: int, duration_ms: int, error: str = None
    ):
        """Record job execution metrics for monitoring dashboards."""
        from ..monitoring.scheduler_metrics import record_job_run

        try:
            await record_job_run(
                account_id=job.account_id,
                job_id=job.id,
                status=status,
                rows=rows,
                duration_ms=duration_ms or 0,
                error=error[:200] if error else None,
            )
        except Exception as exc:
            log.error(f"Failed to record scheduler metric for {job.id}: {exc}")

    async def _notify_auth_failure(
        self, job, db: AsyncSession, provider: str = "Salesforce"
    ):
        """Notify user that auth credentials have expired."""
        from ..services.email_notifications import send_auth_failure_notification
        from ..models import User
        from sqlalchemy import select as sa_select

        try:
            # Get user email
            result = await db.execute(
                sa_select(User).where(User.account_id == job.account_id).limit(1)
            )
            user = result.scalar_one_or_none()

            if user and user.email:
                await send_auth_failure_notification(
                    user_email=user.email,
                    integration_provider=provider,
                    account_id=job.account_id,
                    failure_reason=job.pause_reason,
                )
        except Exception as e:
            log.error(f"Failed to send auth failure notification: {e}")

    async def _notify_schedule_paused(self, job, error_message: str, db: AsyncSession):
        """Notify user that a schedule has been paused due to errors."""
        from ..services.email_notifications import send_schedule_paused_notification
        from ..models import User
        from sqlalchemy import select as sa_select

        try:
            # Get user email
            result = await db.execute(
                sa_select(User).where(User.account_id == job.account_id).limit(1)
            )
            user = result.scalar_one_or_none()

            if user and user.email:
                schedule_name = (
                    job.config.get("name", job.job_type)
                    if isinstance(job.config, dict)
                    else job.job_type
                )

                await send_schedule_paused_notification(
                    user_email=user.email,
                    schedule_name=schedule_name,
                    job_id=job.id,
                    error_message=error_message,
                    account_id=job.account_id,
                )
        except Exception as e:
            log.error(f"Failed to send schedule paused notification: {e}")

    async def _catchup_missed_runs(self, db: AsyncSession):
        """Catch up on jobs that were missed during downtime."""
        from ..models import ScheduledJob

        try:
            now = datetime.utcnow()
            tolerance = timedelta(hours=1)  # Only catch up runs missed within 1 hour

            result = await db.execute(
                select(ScheduledJob).where(
                    and_(
                        ScheduledJob.is_active == True,
                        ScheduledJob.paused_by_system == False,
                        ScheduledJob.last_run_at < now - tolerance,
                        ScheduledJob.next_run_at < now,
                    )
                )
            )

            missed_jobs = result.scalars().all()

            if missed_jobs:
                log.info(f"Catching up on {len(missed_jobs)} missed jobs")
                for job in missed_jobs:
                    await self._execute_job(job, db)

        except Exception as e:
            log.error(f"Failed to catch up on missed runs: {e}", exc_info=True)

    async def _record_to_dlq(self, job_id: str, error: str, db: AsyncSession):
        """Record failed job to Dead Letter Queue."""
        from ..models import JobRunDLQ

        try:
            dlq_entry = JobRunDLQ(
                id=f"dlq_{datetime.utcnow().timestamp()}",
                job_id=job_id,
                run_id="",  # May not have run_id if job creation failed
                error_count=1,
                last_error=error[:500],
                last_error_at=datetime.utcnow(),
                created_at=datetime.utcnow(),
            )
            db.add(dlq_entry)
            await db.commit()
            log.info(f"Recorded job {job_id} to DLQ")

        except Exception as e:
            log.error(f"Failed to record to DLQ: {e}")

    async def stop(self):
        """Stop the scheduler gracefully."""
        if self.scheduler.running:
            self.scheduler.shutdown(wait=True)
            log.info("Scheduler stopped")


# Singleton instance
_scheduler_instance: Optional[SchedulerService] = None


async def get_scheduler() -> SchedulerService:
    """Get or create scheduler singleton."""
    global _scheduler_instance
    if _scheduler_instance is None:
        _scheduler_instance = SchedulerService()
    return _scheduler_instance
