from __future__ import annotations

import hashlib
import json
from typing import Any

from app.core.orchestration.spec import validate_schedule_plan

RECOVERY_KEYS = {
    "failure_kind",
    "business_domain",
    "environment",
    "candidate_id",
    "execution_id",
    "retry_count",
    "schedule_plan",
}
FAILURE_KINDS = {
    "canary_failed",
    "datasource_pool_degraded",
    "datasource_unavailable",
    "kestra_unavailable",
    "model_unavailable",
    "runner_unavailable",
    "transient_execution_failure",
}


def _required_string(value: Any, label: str, maximum: int = 255) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ValueError(f"{label} is required")
    normalized = value.strip()
    if len(normalized) > maximum:
        raise ValueError(f"{label} exceeds {maximum} characters")
    return normalized


def _hash(value):
    canonical = json.dumps(
        value,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


class RecoveryAgent:
    def __init__(self, *, scheduling_tools, audit, max_retries=2):
        if isinstance(max_retries, bool) or not isinstance(max_retries, int):
            raise ValueError("max_retries must be an integer")
        if max_retries < 1 or max_retries > 3:
            raise ValueError("max_retries must be between 1 and 3")
        self.scheduling_tools = scheduling_tools
        self.audit = audit
        self.max_retries = max_retries

    @staticmethod
    def _normalize(signal):
        if not isinstance(signal, dict):
            raise ValueError("recovery signal must be an object")
        unknown = sorted(set(signal) - RECOVERY_KEYS)
        if unknown:
            raise ValueError(
                f"recovery signal contains unsupported fields: {', '.join(unknown)}"
            )
        normalized = dict(signal)
        normalized["failure_kind"] = _required_string(
            signal.get("failure_kind"), "failure kind", 100
        )
        if normalized["failure_kind"] not in FAILURE_KINDS:
            raise ValueError("unsupported recovery failure kind")
        normalized["business_domain"] = _required_string(
            signal.get("business_domain"), "business domain", 200
        )
        normalized["environment"] = _required_string(
            signal.get("environment"), "environment", 20
        )
        if normalized["environment"] not in {
            "development",
            "test",
            "production",
        }:
            raise ValueError("unsupported environment")
        for key, label in (
            ("candidate_id", "candidate id"),
            ("execution_id", "execution id"),
        ):
            if key in normalized:
                normalized[key] = _required_string(normalized[key], label)
        retry_count = normalized.get("retry_count", 0)
        if (
            isinstance(retry_count, bool)
            or not isinstance(retry_count, int)
            or retry_count < 0
            or retry_count > 100
        ):
            raise ValueError("retry_count must be between 0 and 100")
        normalized["retry_count"] = retry_count
        return normalized

    def _event(self, identity, signal, decision, result):
        return {
            "subject": identity.subject,
            "roles": sorted(identity.roles),
            "business_domain": signal["business_domain"],
            "environment": signal["environment"],
            "correlation_id": identity.correlation_id,
            "action": "ai_recovery",
            "decision": decision,
            "schema_version": "v54",
            "context_hash": _hash(
                {
                    "failure_kind": signal["failure_kind"],
                    "candidate_id": signal.get("candidate_id"),
                    "execution_id": signal.get("execution_id"),
                    "retry_count": signal["retry_count"],
                }
            ),
            "detail": {
                "candidate_id": signal.get("candidate_id"),
                "failure_kind": signal["failure_kind"],
                "recovery_result": result,
            },
        }

    def _record(self, identity, signal, result, decision="allowed"):
        self.audit.record(self._event(identity, signal, decision, result))
        return result

    def _call(self, identity, signal, tool_name, arguments):
        try:
            response = self.scheduling_tools.call_tool(tool_name, arguments)
        except Exception:
            return self._record(
                identity,
                signal,
                {
                    "status": "degraded",
                    "action": "continue_published_schedule",
                    "reason_code": "recovery_tool_failure",
                    "formal_engine_changed": False,
                    "correlation_id": identity.correlation_id,
                },
                decision="failed",
            )
        return self._record(
            identity,
            signal,
            {
                "status": "recovery_executed",
                "action": tool_name,
                "tool_status": (
                    response.get("status")
                    if isinstance(response, dict)
                    else "completed"
                ),
                "formal_engine_changed": False,
                "correlation_id": identity.correlation_id,
            },
        )

    def recover(self, identity, signal):
        normalized = self._normalize(signal)
        identity.require_domain(normalized["business_domain"])
        identity.require_environment(normalized["environment"])
        failure = normalized["failure_kind"]
        scope = {
            "business_domain": normalized["business_domain"],
            "environment": normalized["environment"],
        }

        if failure in {"model_unavailable", "kestra_unavailable"}:
            return self._record(
                identity,
                normalized,
                {
                    "status": "degraded",
                    "action": "continue_published_schedule",
                    "reason_code": failure,
                    "formal_engine_changed": False,
                    "correlation_id": identity.correlation_id,
                },
            )

        if failure == "datasource_pool_degraded":
            plan = validate_schedule_plan(normalized.get("schedule_plan"))
            plan["max_concurrency"] = max(1, plan["max_concurrency"] // 2)
            return self._record(
                identity,
                normalized,
                {
                    "status": "recovery_planned",
                    "action": "reduce_concurrency",
                    "schedule_plan": plan,
                    "formal_engine_changed": False,
                    "correlation_id": identity.correlation_id,
                },
            )

        if failure in {"runner_unavailable", "datasource_unavailable"}:
            candidate_id = _required_string(
                normalized.get("candidate_id"), "candidate id"
            )
            return self._call(
                identity,
                normalized,
                "pause_schedule",
                {**scope, "candidate_id": candidate_id},
            )

        if failure == "transient_execution_failure":
            execution_id = _required_string(
                normalized.get("execution_id"), "execution id"
            )
            if normalized["retry_count"] < self.max_retries:
                return self._call(
                    identity,
                    normalized,
                    "retry_failed_execution",
                    {
                        **scope,
                        "execution_id": execution_id,
                        "max_retries": self.max_retries,
                    },
                )

        candidate_id = _required_string(normalized.get("candidate_id"), "candidate id")
        return self._call(
            identity,
            normalized,
            "rollback_to_previous_version",
            {
                **scope,
                "candidate_id": candidate_id,
                "idempotency_key": (
                    f"v54-recovery:{identity.correlation_id}:{candidate_id}"
                ),
            },
        )
