"""The scheduler for ScheduledJob rows (app/models/scheduling.py) — no
external dependency (no APScheduler/Celery): scheduler_loop() is started as
a plain asyncio background task from main.py's lifespan and ticks every
_TICK_SECONDS. A tick doesn't run anything itself: it locks each due
ScheduledJob (FOR UPDATE SKIP LOCKED — two API instances never pick the
same one), queues a "scheduled_job.run" job (app/core/jobs.py) and moves
its next_run_at on, all in one transaction. The job queue runs it on
whichever worker is free and records the result on the ScheduledJob.

A run_task job isn't retried (max_attempts 1): a Task can send emails or
call out, and running it twice is worse than reporting that it failed. An
SLA scan is safe to repeat, so it gets a few attempts.

Two job types today:
  - run_task: fires a Task's node graph (app/crud/tasks.py::run_task) with
    the job's own configured starting context — e.g. a nightly digest, a
    cleanup sweep, anything already expressible as a Task.
  - sla_overdue_scan: tenant-wide, not tied to one Task — walks every
    in_progress WorkflowInstance with an SLA clock running (not paused) and
    writes one Notification the first time it's found past its effective
    deadline (deduplicated by an existing source="sla_scan" notification
    already on that instance, so a job that ticks hourly doesn't spam the
    same overdue case every hour).
"""

import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone

from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession

from app.core import jobs
from app.core.database import AsyncSessionLocal
from app.crud import licensing, task_history, workflow_engine
from app.crud.task_history import RunTrigger
from app.crud.tasks import run_task
from app.models.automation import Task
from app.models.identity import Tenant
from app.models.notification import Notification
from app.models.scheduling import ScheduledJob
from app.models.workflow import Workflow, WorkflowInstance

logger = logging.getLogger("flowfabric.scheduler")

_TICK_SECONDS = 60
_INTERVAL_SECONDS = {"hourly": 3600, "daily": 86400, "weekly": 604800}


def _advance(frequency: str) -> datetime:
    return datetime.now(timezone.utc) + timedelta(seconds=_INTERVAL_SECONDS[frequency])


async def _run_sla_scan(db: AsyncSession, tenant_id: uuid.UUID) -> str:
    now = datetime.now(timezone.utc)
    stmt = select(WorkflowInstance).where(
        WorkflowInstance.tenant_id == tenant_id,
        WorkflowInstance.status == "in_progress",
        WorkflowInstance.sla_due_at.isnot(None),
        WorkflowInstance.sla_paused_at.is_(None),
    )
    instances = (await db.execute(stmt)).scalars().all()
    notified = 0
    for inst in instances:
        effective_due = inst.sla_due_at + timedelta(seconds=inst.sla_paused_seconds)
        if effective_due >= now:
            continue
        already = (
            await db.execute(
                select(Notification.id).where(
                    Notification.tenant_id == tenant_id,
                    Notification.source == "sla_scan",
                    Notification.workflow_instance_id == inst.id,
                )
            )
        ).first()
        if already:
            continue
        workflow = (
            await db.execute(
                select(Workflow).where(
                    Workflow.tenant_id == tenant_id,
                    Workflow.workflow_id == inst.workflow_id,
                    Workflow.version == inst.workflow_version,
                )
            )
        ).scalar_one_or_none()
        step = next((s for s in workflow.step_definitions if s.get("key") == inst.current_step), None) if workflow else None
        step_label = step.get("label") if step else (inst.current_step or "?")
        db.add(
            Notification(
                tenant_id=tenant_id,
                title=f'{workflow.name if workflow else "Workflow"} — SLA overdue',
                message=f'Instance stuck at "{step_label}" passed its SLA deadline ({effective_due.isoformat()}).',
                source="sla_scan",
                workflow_instance_id=inst.id,
            )
        )
        notified += 1
    return f"scanned {len(instances)} in-progress instance(s), notified {notified} newly-overdue"


async def _execute(db: AsyncSession, tenant_id: uuid.UUID, job: ScheduledJob) -> str:
    if job.job_type == "run_task":
        if job.task_id is None:
            return "skipped — no task configured (was it deleted?)"
        task = (
            await db.execute(select(Task).where(Task.tenant_id == tenant_id, Task.id == job.task_id))
        ).scalar_one_or_none()
        if task is None:
            return "skipped — referenced task not found"
        if task.status != "published":
            return f'skipped — "{task.name}" is not published'
        ok, trace, _ = await run_task(db, tenant_id, task, dict(job.context), trigger=RunTrigger("schedule", ref=str(job.id), name=job.name))
        return f"{'ok' if ok else 'failed'} — {len(trace)} node(s) ran"
    if job.job_type == "sla_overdue_scan":
        return await _run_sla_scan(db, tenant_id)
    return f"unknown job_type {job.job_type!r}"


async def run_job_now(db: AsyncSession, tenant_id: uuid.UUID, job: ScheduledJob) -> None:
    """Runs `job` immediately, within the caller's own request-scoped
    session/transaction — used by POST /scheduled-jobs/{id}/run-now."""
    result = await _execute(db, tenant_id, job)
    job.last_run_at = datetime.now(timezone.utc)
    job.last_run_result = result
    job.next_run_at = _advance(job.frequency)
    await db.flush()


RUN_JOB = "scheduled_job.run"
# Attempts per job_type — see the module docstring.
_ATTEMPTS = {"run_task": 1, "sla_overdue_scan": 3}


async def tick() -> int:
    """One pass across every tenant: queues each due job. `tenants` carries
    no RLS policy (it's the root table everything else's tenant_id points
    at), so this first SELECT works with no app.current_tenant_id GUC set;
    every scheduled_jobs query after it sets that GUC first, the same way
    get_db() does per HTTP request. Returns how many were queued."""
    async with AsyncSessionLocal() as session:
        tenants = (await session.execute(select(Tenant))).scalars().all()
    # Unlicensed, or outside its license dates, a tenant's jobs wait (app/crud/licensing.py).
    tenant_ids = [t.id for t in tenants if not licensing.tenant_message(t)]
    await task_history.queue_cleanup()

    queued = 0
    for tenant_id in tenant_ids:
        async with jobs.tenant_session(tenant_id) as session:
            now = datetime.now(timezone.utc)
            stmt = (
                select(ScheduledJob)
                .where(ScheduledJob.tenant_id == tenant_id, ScheduledJob.status == "active", ScheduledJob.next_run_at <= now)
                .with_for_update(skip_locked=True)
            )
            for job in (await session.execute(stmt)).scalars().all():
                await jobs.enqueue(
                    session, RUN_JOB, {"scheduled_job_id": str(job.id)}, tenant_id=tenant_id,
                    # A run still waiting isn't queued a second time.
                    dedupe_key=f"{RUN_JOB}:{job.id}", max_attempts=_ATTEMPTS.get(job.job_type, 1),
                )
                job.next_run_at = _advance(job.frequency)
                queued += 1
        # Workflow timers, reminders and SLA escalations (app/crud/workflow_engine.py).
        if await workflow_engine.queue_due(tenant_id):
            queued += 1
    return queued


@jobs.handler(RUN_JOB, max_attempts=1)
async def _run_scheduled(job: jobs.Job) -> str:
    async with jobs.tenant_session(job.tenant_id) as session:
        scheduled = (
            await session.execute(select(ScheduledJob).where(ScheduledJob.tenant_id == job.tenant_id, ScheduledJob.id == uuid.UUID(job.payload["scheduled_job_id"])))
        ).scalar_one_or_none()
        if scheduled is None:
            return "skipped — the scheduled job was deleted"
        result = await _execute(session, job.tenant_id, scheduled)
        scheduled.last_run_at = datetime.now(timezone.utc)
        scheduled.last_run_result = result[:500]
    return result


@jobs.on_failure(RUN_JOB)
async def _run_scheduled_failed(job: jobs.Job, exc: BaseException) -> None:
    async with jobs.tenant_session(job.tenant_id) as session:
        scheduled = await session.get(ScheduledJob, uuid.UUID(job.payload["scheduled_job_id"]))
        if scheduled is not None:
            scheduled.last_run_at = datetime.now(timezone.utc)
            scheduled.last_run_result = f"error: {exc}"[:500]


async def scheduler_loop() -> None:
    logger.info("scheduler loop started (tick every %ss)", _TICK_SECONDS)
    while True:
        try:
            await tick()
        except Exception:
            logger.exception("scheduler tick failed")
        await asyncio.sleep(_TICK_SECONDS)
