"""Phase-gating validation for decision recording.

Ensures that decision types are only recorded during the plan phases
where they are permitted.  Extracted from ``DecisionService`` to keep
the service module focused on recording and snapshot concerns.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import TYPE_CHECKING

import structlog
from sqlalchemy.exc import OperationalError

from cleveragents.core.exceptions import (
    DatabaseError,
    DecisionPhaseViolationError,
    ValidationError,
)
from cleveragents.domain.models.core.decision import (
    EXECUTE_TYPES,
    STRATEGIZE_TYPES,
    DecisionType,
)
from cleveragents.domain.models.core.plan import PlanPhase

if TYPE_CHECKING:
    from cleveragents.infrastructure.database.unit_of_work import UnitOfWork

#: Mapping from plan phase to allowed decision types.
#:
#: Only ``STRATEGIZE`` and ``EXECUTE`` are gated; the ``ACTION`` and
#: ``APPLY`` phases do not record decisions.  When the plan phase is
#: not in this mapping, phase-gating is skipped.
PHASE_ALLOWED_TYPES: Mapping[PlanPhase, frozenset[DecisionType]] = {
    PlanPhase.STRATEGIZE: STRATEGIZE_TYPES,
    PlanPhase.EXECUTE: EXECUTE_TYPES,
}


def resolve_plan_phase(
    plan_id: str,
    explicit_phase: PlanPhase | str | None,
    *,
    persisted: bool,
    unit_of_work: UnitOfWork | None,
    logger: structlog.stdlib.BoundLogger,
) -> PlanPhase | None:
    """Resolve the plan phase for phase-gating validation.

    Resolution order:
    1. *explicit_phase* — the caller already knows the phase.
    2. Database lookup via *unit_of_work* → ``lifecycle_plans``.
    3. ``None`` — phase-gating is skipped.

    Args:
        plan_id: ULID of the plan.
        explicit_phase: Caller-supplied phase, if any.
        persisted: Whether the service is in persisted mode.
        unit_of_work: Optional database access layer.
        logger: Logger for warning on DB failures.

    Returns:
        The resolved :class:`PlanPhase`, or ``None`` when the phase
        cannot be determined.
    """
    if explicit_phase is not None:
        if isinstance(explicit_phase, str):
            try:
                return PlanPhase(explicit_phase)
            except ValueError:
                raise ValidationError(
                    f"Invalid plan_phase: '{explicit_phase}'"
                ) from None
        return explicit_phase

    if persisted and unit_of_work is not None:
        # TODO(pg-migration): TOCTOU — The phase is read here but the
        # decision is written later in _store_decision(), in a separate
        # transaction.  This is acceptable for SQLite (single-writer
        # serialisation) but must be revisited if the persistence layer
        # is migrated to PostgreSQL or another multi-writer database.
        # At minimum, a single-writer assertion or advisory lock should
        # guard this section under multi-writer engines.
        #
        # NOTE: Uncached — This opens a DB transaction on every
        # record_decision() call when plan_phase is None.  A
        # per-plan phase cache could avoid this, but the cost is
        # negligible for SQLite and correctness is more important
        # while plan phases can change mid-lifecycle.
        try:
            with unit_of_work.transaction() as ctx:
                plan = ctx.lifecycle_plans.get(plan_id)
                if plan is not None:
                    return (
                        PlanPhase(plan.phase)
                        if isinstance(plan.phase, str)
                        else plan.phase
                    )
        except (DatabaseError, OperationalError, OSError):
            # Infrastructure errors must not break the opt-in
            # phase-gating contract.  If the DB is temporarily
            # unavailable we fall through and skip gating rather
            # than propagating an infrastructure error to the caller.
            logger.warning(
                "decision.phase_resolve_db_error",
                plan_id=plan_id,
                exc_info=True,
            )

    return None


def validate_phase_gating(
    decision_type: DecisionType,
    phase: PlanPhase,
) -> None:
    """Raise if *decision_type* is not permitted in *phase*.

    Only ``STRATEGIZE`` and ``EXECUTE`` are gated.  Other phases
    (``ACTION``, ``APPLY``) do not restrict decision types.

    Args:
        decision_type: The decision type to validate.
        phase: The plan's current phase.

    Raises:
        DecisionPhaseViolationError: If the type is not in the
            allowed set for the given phase.
    """
    allowed = PHASE_ALLOWED_TYPES.get(phase)
    if allowed is None:
        # Phase is not gated (e.g. ACTION, APPLY).
        return
    if decision_type not in allowed:
        raise DecisionPhaseViolationError(
            decision_type=str(decision_type),
            plan_phase=str(phase),
            allowed_types=frozenset(str(t) for t in allowed),
        )


__all__ = [
    "PHASE_ALLOWED_TYPES",
    "resolve_plan_phase",
    "validate_phase_gating",
]
