"""Durable DB-backed job queue driven by APScheduler.

APScheduler is only a ticker. All work items live in ``scheduled_jobs`` so a
restart does not lose pending publications or deletions. Claiming is atomic;
a crash mid-send becomes UNKNOWN and is never auto-resent blindly.
"""

from __future__ import annotations

import asyncio
import logging
from datetime import timedelta

from apscheduler.schedulers.asyncio import AsyncIOScheduler

from app.config import Settings
from app.database.base import utcnow
from app.database.models import JobStatus, JobType, Post, PostStatus, ScheduledJob
from app.database.repositories import Repositories
from app.errors import RetryableJobError
from app.logging_config import redact_text
from app.services.delivery_service import DeliveryService
from app.services.publishing_service import PublishingService
from app.utils.retry import backoff_delay

logger = logging.getLogger(__name__)


class SchedulerService:
    def __init__(
        self,
        repos: Repositories,
        settings: Settings,
        publishing: PublishingService,
        delivery: DeliveryService,
    ) -> None:
        self.repos = repos
        self.settings = settings
        self.publishing = publishing
        self.delivery = delivery
        self.scheduler = AsyncIOScheduler(timezone="UTC")
        self._lock = asyncio.Lock()
        self._started = False
        self._stopping = False

    async def start(self) -> None:
        if self._started:
            return

        self._stopping = False
        recovered_jobs = await self.repos.jobs.recover_running()
        recovered_destinations = await self.repos.posts.recover_sending()
        if recovered_jobs or recovered_destinations:
            logger.warning(
                "recovered %s job(s) and marked %s in-flight destination(s) UNKNOWN",
                recovered_jobs,
                recovered_destinations,
            )
        self.scheduler.add_job(
            self._tick,
            "interval",
            seconds=self.settings.scheduler_poll_seconds,
            id="durable-job-tick",
            max_instances=1,
            coalesce=True,
            next_run_time=utcnow(),
        )
        self.scheduler.start()
        self._started = True
        logger.info("durable scheduler started (poll=%ss)", self.settings.scheduler_poll_seconds)

    async def stop(self) -> None:
        self._stopping = True

        if self._started:
            self.scheduler.shutdown(wait=False)
            self._started = False

        # Wait for an already-running tick before the database is disposed.
        async with self._lock:
            pass

    async def _tick(self) -> None:
        if self._stopping or self._lock.locked():
            return
        async with self._lock:
            if not self._stopping:
                await self.run_once()

    async def run_once(self, limit: int = 10) -> int:
        processed = 0
        for _ in range(limit):
            job = await self.repos.jobs.claim_due(utcnow())
            if job is None:
                break
            processed += 1
            await self._execute(job)
        return processed

    async def _execute(self, job: ScheduledJob) -> None:
        correlation_id = job.correlation_id or f"job-{job.id}"
        try:
            if job.job_type == JobType.PUBLISH_POST:
                await self._execute_publish(job, correlation_id)
            elif job.job_type == JobType.DELETE_USER_MESSAGE:
                delivery_id = int(job.payload.get("delivery_id", 0))
                if delivery_id <= 0:
                    raise ValueError("delivery_id missing")
                await self.delivery.delete_user_message(delivery_id)
            elif job.job_type == JobType.DELETE_CHANNEL_MESSAGE:
                destination_id = int(job.payload.get("destination_id", 0))
                if destination_id <= 0:
                    raise ValueError("destination_id missing")
                await self.publishing.delete_channel_message(destination_id)
            else:
                raise ValueError(f"unknown job type: {job.job_type}")
        except RetryableJobError as exc:
            retry_at = utcnow() + timedelta(seconds=max(1, exc.delay_seconds))
            status = await self.repos.jobs.fail(job.id, error=str(exc), retry_at=retry_at)
            logger.warning("job %s retryable failure -> %s: %s", job.id, status, exc)
        except Exception as exc:
            error = redact_text(f"{type(exc).__name__}: {exc}")[:2000]
            retry_at = utcnow() + timedelta(
                seconds=min(3600, backoff_delay(job.attempts, 30.0, 3600.0))
            )
            status = await self.repos.jobs.fail(job.id, error=error, retry_at=retry_at)
            logger.error("job %s failed -> %s: %s", job.id, status, type(exc).__name__)
            if status == JobStatus.FAILED:
                await self.repos.audit.log(
                    None,
                    "job.failed",
                    entity_type="scheduled_job",
                    entity_id=job.id,
                    details={"type": job.job_type.value, "error": error[:300]},
                    correlation_id=correlation_id,
                )
        else:
            await self.repos.jobs.complete(job.id)
            logger.info("job %s (%s) completed", job.id, job.job_type.value)

    async def _execute_publish(self, job: ScheduledJob, correlation_id: str) -> None:
        post_id = int(job.payload.get("post_id", job.post_id or 0))
        if post_id <= 0:
            raise ValueError("post_id missing")
        post: Post | None = await self.repos.posts.get(post_id)
        if post is None:
            await self.repos.jobs.complete(job.id, error="پست حذف شده است.")
            return
        if post.status == PostStatus.PUBLISHED:
            await self.repos.jobs.complete(job.id, error="پست قبلاً منتشر شده بود.")
            return
        if post.status in {PostStatus.ARCHIVED, PostStatus.PAUSED}:
            await self.repos.jobs.complete(job.id, error="پست آرشیو یا متوقف شده است.")
            return
        result = await self.publishing.publish(
            post_id, actor_id=None, correlation_id=correlation_id
        )
        if result.status != PostStatus.PUBLISHED:
            # Do not auto-retry publication: some destinations may already have
            # succeeded. The admin sees per-destination errors and can retry FAILED
            # destinations explicitly. UNKNOWN destinations are never retried.
            await self.repos.jobs.complete(job.id, error=result.message)
        else:
            await self.repos.jobs.complete(job.id)
