"""``audit_abandon_stuck_runs`` management command."""

from __future__ import annotations

import re
from datetime import timedelta
from typing import TYPE_CHECKING, Final, cast

from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone

from ..._log import get_logger
from ...models import AuditRun, AuditRunState, RunTrigger
from ...notifications import fan_out_for_run
from ...persistence import transition_run_state

if TYPE_CHECKING:  # pragma: no cover
    import argparse

logger = get_logger(__name__)

_DURATION_RE: Final[re.Pattern[str]] = re.compile(r"^(\d+)([mh])$")


def _parse_duration(value: str) -> timedelta:
    """Parse ``30m`` / ``2h`` into a ``timedelta``."""
    match = _DURATION_RE.match(value)
    if match is None:
        raise CommandError(
            f"--older-than: expected a duration like '30m' or '2h', "
            f"got {value!r}."
        )
    qty, unit = int(match.group(1)), match.group(2)
    if qty == 0:
        raise CommandError(
            "--older-than: duration must be positive (e.g. '1m', '1h')."
        )
    if unit == "m":
        return timedelta(minutes=qty)
    return timedelta(hours=qty)


class Command(BaseCommand):
    help = (
        "Flip PENDING AuditRun rows older than --older-than to "
        "ABANDONED. Race-safe via CAS on PENDING (a row that "
        "transitioned to RUNNING between query and flip stays "
        "RUNNING). --dry-run reports the targets without writing."
    )

    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
        parser.add_argument(
            "--older-than",
            required=True,
            help="Duration: 30m / 2h. Required.",
        )
        parser.add_argument(
            "--dry-run",
            action="store_true",
            help="Report which rows would be flipped without writing.",
        )

    def handle(self, *args: object, **options: object) -> None:
        threshold = _parse_duration(cast("str", options["older_than"]))
        cutoff = timezone.now() - threshold
        candidates = list(
            AuditRun.objects.filter(
                state=AuditRunState.PENDING,
                started_at__lt=cutoff,
            )
        )
        if not candidates:
            self.stdout.write("No stuck PENDING runs found.")
            return

        for run in candidates:
            self.stdout.write(
                f"AuditRun id={run.pk} pending since {run.started_at}"
            )
            if options["dry_run"]:
                continue
            flipped = transition_run_state(
                run,
                AuditRunState.ABANDONED,
                expected=AuditRunState.PENDING,
                error_message="abandoned by audit_abandon_stuck_runs",
            )
            if flipped:
                self.stdout.write(
                    f"AuditRun id={run.pk} flipped to ABANDONED."
                )
                logger.info(
                    "audit_abandon_stuck_runs: flipped run %s to ABANDONED.",
                    run.pk,
                )
                # WEB-initiated runs are operator-visible: the launcher
                # is waiting on the run-detail page, and AA-notify /
                # webhook are the only channels that close the loop
                # for them. CLI / BEAT triggers have their own
                # feedback paths (stdout, beat logs) and would
                # double-notify if fanned out here.
                if run.triggered_by == RunTrigger.WEB:
                    fan_out_for_run(run.pk, source="abandoned stuck run")
            else:
                self.stdout.write(
                    f"AuditRun id={run.pk} race-lost (now in another "
                    f"state); not abandoned."
                )
