"""Forms request anti-abuse gate. Not field validation and not FormDefinition.

Honeypot, challenge verification, and attempt rate limiting run before 7F/7C/7D.
Challenge tokens and honeypot values stay on the request context. Execution
domain is P2HS-owned [P], not a customer connector or provider binding.
"""
from __future__ import annotations

from collections.abc import Callable, Mapping
from datetime import UTC, datetime, timedelta
from typing import Any, Literal, Protocol

from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr

from src.domain.forms.canonical import FormDefinition
from src.domain.forms.files import (
    FormFileDirectory,
    InMemoryFormFileDirectory,
    accept_form_submission_with_files,
)
from src.domain.forms.runtime import (
    FormSubmissionRuntimeResult,
    accept_form_submission,
    platform_now,
)

CHALLENGE_MISSING = "challenge_missing"
CHALLENGE_FAILED = "challenge_failed"
CHALLENGE_UNAVAILABLE = "challenge_unavailable"
HONEYPOT_TRIGGERED = "honeypot_triggered"
RATE_LIMITED = "rate_limited"

ANTI_ABUSE_CODES: tuple[str, ...] = (
    HONEYPOT_TRIGGERED,
    CHALLENGE_MISSING,
    CHALLENGE_FAILED,
    CHALLENGE_UNAVAILABLE,
    RATE_LIMITED,
)

CHALLENGE_TOKEN_TRANSPORT_KEYS: frozenset[str] = frozenset(
    {
        "turnstile_token",
        "cf-turnstile-response",
        "cf_turnstile_response",
    }
)

# Adapter default matching /api/contact. Not a FormField.
DEFAULT_HONEYPOT_TRANSPORT_KEY = "website"

CHALLENGE_REQUIRED_MEANS = "policy_not_inferred_from_missing_token"
CHALLENGE_OUTAGE = "fail_closed"
RATE_LIMIT_OUTAGE = "fail_closed"
RATE_LIMIT_COUNTS = "attempts"
RATE_LIMIT_BUCKET = "project_form_client"
HONEYPOT_INTERNAL = "reject"
HONEYPOT_TRANSPORT = "may_fake_success"

FORMS_ANTI_ABUSE: dict[str, Any] = {
    "slice_id": "7g_anti_abuse",
    "status": "frozen",
    "execution_domain": "P",
    "not_form_definition": True,
    "not_field_validation": True,
    "challenge_required_means": CHALLENGE_REQUIRED_MEANS,
    "challenge_outage": CHALLENGE_OUTAGE,
    "rate_limit_outage": RATE_LIMIT_OUTAGE,
    "rate_limit_counts": RATE_LIMIT_COUNTS,
    "rate_limit_bucket": RATE_LIMIT_BUCKET,
    "honeypot_internal": HONEYPOT_INTERNAL,
    "honeypot_transport": HONEYPOT_TRANSPORT,
    "persist_tokens": False,
    "delete_orphan_files": False,
    "submit_form_entry_migrated": False,
    "contact_adapter_migrated": False,
    "public_bff": False,
    "form_submit_capability": False,
    "codegen": False,
    "docs": "docs/audits/FORMS_ANTI_ABUSE_7G.md",
}


class FormAntiAbusePolicy(BaseModel):
    """Request admission policy. Not captured-data schema."""

    model_config = ConfigDict(extra="forbid", frozen=True)
    challenge_required: StrictBool = False
    honeypot_enabled: StrictBool = True
    honeypot_transport_key: StrictStr = DEFAULT_HONEYPOT_TRANSPORT_KEY
    rate_limit_max_attempts: StrictInt = Field(default=20, ge=0)
    rate_limit_window_seconds: StrictInt = Field(default=60, ge=1)


class FormAntiAbuseContext(BaseModel):
    """Request/security metadata. Never copied into FormSubmission.values."""

    model_config = ConfigDict(extra="forbid", frozen=True)
    project_id: StrictStr = Field(min_length=1, max_length=64)
    form_key: StrictStr = Field(min_length=1, max_length=64)
    client_identity: StrictStr = Field(min_length=1, max_length=256)
    challenge_token: StrictStr | None = None
    honeypot_value: StrictStr | None = None


class FormAntiAbuseResult(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    allowed: StrictBool
    code: Literal[
        "honeypot_triggered",
        "challenge_missing",
        "challenge_failed",
        "challenge_unavailable",
        "rate_limited",
    ] | None = None
    retry_after_seconds: StrictInt | None = None


class ChallengeVerificationResult(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    passed: StrictBool
    failure_code: (
        Literal["challenge_missing", "challenge_failed", "challenge_unavailable"] | None
    ) = None


class RateLimitDecision(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    allowed: StrictBool
    retry_after_seconds: StrictInt | None = None


class FormChallengeVerifier(Protocol):
    def verify(self, token: str | None) -> ChallengeVerificationResult: ...


class FormAttemptLimiter(Protocol):
    def check_and_count(
        self,
        bucket_key: str,
        *,
        now: datetime,
        max_attempts: int,
        window_seconds: int,
    ) -> RateLimitDecision: ...


class StaticChallengeVerifier:
    def __init__(self, result: ChallengeVerificationResult) -> None:
        self._result = result

    def verify(self, token: str | None) -> ChallengeVerificationResult:
        if self._result.failure_code == CHALLENGE_UNAVAILABLE:
            return self._result
        if not token:
            return ChallengeVerificationResult(passed=False, failure_code=CHALLENGE_MISSING)
        return self._result


class UnavailableChallengeVerifier:
    def verify(self, token: str | None) -> ChallengeVerificationResult:
        raise RuntimeError("challenge_backend_unavailable")


class InMemoryAttemptLimiter:
    def __init__(self) -> None:
        self._hits: dict[str, list[datetime]] = {}

    def check_and_count(
        self,
        bucket_key: str,
        *,
        now: datetime,
        max_attempts: int,
        window_seconds: int,
    ) -> RateLimitDecision:
        if now.tzinfo is None:
            now = now.replace(tzinfo=UTC)
        window_start = now - timedelta(seconds=window_seconds)
        hits = [stamp for stamp in self._hits.get(bucket_key, []) if stamp > window_start]
        if len(hits) >= max_attempts:
            oldest = min(hits)
            retry = window_seconds - int((now - oldest).total_seconds())
            return RateLimitDecision(allowed=False, retry_after_seconds=max(retry, 1))
        hits.append(now)
        self._hits[bucket_key] = hits
        return RateLimitDecision(allowed=True)


class UnavailableAttemptLimiter:
    def check_and_count(
        self,
        bucket_key: str,
        *,
        now: datetime,
        max_attempts: int,
        window_seconds: int,
    ) -> RateLimitDecision:
        raise RuntimeError("limiter_backend_unavailable")


def rate_limit_bucket(context: FormAntiAbuseContext) -> str:
    return f"{context.project_id}:{context.form_key}:{context.client_identity}"


def extract_anti_abuse_context(
    raw: Mapping[str, Any],
    *,
    project_id: str,
    form_key: str,
    client_identity: str,
    policy: FormAntiAbusePolicy,
) -> tuple[FormAntiAbuseContext, dict[str, Any]]:
    """Pull challenge/honeypot transport keys out of the payload copy for 7C."""
    values = dict(raw)
    honeypot = None
    key = policy.honeypot_transport_key
    if key in values:
        honeypot = values.pop(key)
    token = None
    for token_key in CHALLENGE_TOKEN_TRANSPORT_KEYS:
        if token_key in values:
            extracted = values.pop(token_key)
            if token is None:
                token = extracted
    token_text = token.strip() if isinstance(token, str) else None
    honeypot_text = honeypot if isinstance(honeypot, str) else None
    context = FormAntiAbuseContext(
        project_id=project_id,
        form_key=form_key,
        client_identity=client_identity,
        challenge_token=token_text or None,
        honeypot_value=honeypot_text,
    )
    return context, values


def evaluate_form_anti_abuse(
    policy: FormAntiAbusePolicy,
    context: FormAntiAbuseContext,
    *,
    verifier: FormChallengeVerifier,
    limiter: FormAttemptLimiter,
    clock: Callable[[], datetime] = platform_now,
) -> FormAntiAbuseResult:
    if policy.honeypot_enabled and (context.honeypot_value or "").strip():
        return FormAntiAbuseResult(allowed=False, code=HONEYPOT_TRIGGERED)
    if policy.rate_limit_max_attempts > 0:
        try:
            decision = limiter.check_and_count(
                rate_limit_bucket(context),
                now=clock(),
                max_attempts=policy.rate_limit_max_attempts,
                window_seconds=policy.rate_limit_window_seconds,
            )
        except Exception:
            return FormAntiAbuseResult(allowed=False, code=RATE_LIMITED)
        if not decision.allowed:
            return FormAntiAbuseResult(
                allowed=False,
                code=RATE_LIMITED,
                retry_after_seconds=decision.retry_after_seconds,
            )
    if policy.challenge_required:
        try:
            verified = verifier.verify(context.challenge_token)
        except Exception:
            return FormAntiAbuseResult(allowed=False, code=CHALLENGE_UNAVAILABLE)
        if verified.passed:
            return FormAntiAbuseResult(allowed=True)
        code = verified.failure_code or CHALLENGE_FAILED
        if context.challenge_token in (None, "") and code == CHALLENGE_FAILED:
            code = CHALLENGE_MISSING
        return FormAntiAbuseResult(allowed=False, code=code)
    return FormAntiAbuseResult(allowed=True)


class FormRequestProcessingResult(BaseModel):
    """Anti-abuse gate plus optional 7D/7F runtime. No persistence."""

    model_config = ConfigDict(extra="forbid", frozen=True)
    anti_abuse: FormAntiAbuseResult
    runtime: FormSubmissionRuntimeResult | None = None

    @property
    def accepted(self) -> bool:
        return bool(self.anti_abuse.allowed and self.runtime and self.runtime.accepted)


def process_form_submission_request(
    definition: FormDefinition,
    raw_values: Mapping[str, Any],
    *,
    policy: FormAntiAbusePolicy,
    project_id: str,
    client_identity: str,
    verifier: FormChallengeVerifier,
    limiter: FormAttemptLimiter,
    files: FormFileDirectory | None = None,
    conditions=None,
    clock: Callable[[], datetime] = platform_now,
    submission_id_factory: Callable[[], str] | None = None,
) -> FormRequestProcessingResult:
    """Honeypot → rate limit → challenge, then 7F/7D. Does not persist."""
    context, canonical_values = extract_anti_abuse_context(
        raw_values,
        project_id=project_id,
        form_key=definition.key,
        client_identity=client_identity,
        policy=policy,
    )
    gate = evaluate_form_anti_abuse(
        policy, context, verifier=verifier, limiter=limiter, clock=clock
    )
    if not gate.allowed:
        return FormRequestProcessingResult(anti_abuse=gate, runtime=None)
    kwargs: dict[str, Any] = {"conditions": conditions}
    if submission_id_factory is not None:
        kwargs["submission_id_factory"] = submission_id_factory
    if clock is not None:
        kwargs["clock"] = clock
    has_file_fields = any(field.type == "file" for field in definition.fields)
    if has_file_fields:
        runtime = accept_form_submission_with_files(
            definition,
            canonical_values,
            project_id=project_id,
            files=files or InMemoryFormFileDirectory(),
            **kwargs,
        )
    else:
        runtime = accept_form_submission(definition, canonical_values, **kwargs)
    return FormRequestProcessingResult(anti_abuse=gate, runtime=runtime)
