"""Application settings loaded from the environment.

Two environments exist (design D14): dev defaults to DRY_RUN=true so a local run can
never send real email to real guests. Production must set every secret explicitly.
"""

from functools import lru_cache
from typing import Literal

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=None, extra="ignore")

    environment: Literal["dev", "prod"] = "dev"
    app_base_url: str = "http://localhost"

    database_url: str = "postgresql+asyncpg://rsvp:rsvp@postgres:5432/rsvp"

    # Session cookie signing (design D7). Must be overridden in production.
    session_secret: str = "dev-only-insecure-session-secret"
    #: Two days (add-admin-access-control design D12). Four times the old 12 hours, and the
    #: only property that lengthened: role, status and scope are still re-read from the
    #: database on every request, so a token cannot outlive the authority behind it.
    session_max_age_seconds: int = 2 * 24 * 60 * 60

    #: Signs the session JWT. Separate from `session_secret` so the two rotate
    #: independently — rotating this one signs everybody out, which is sometimes exactly
    #: what an operator wants and should not require touching anything else.
    #: At least 32 bytes even in development, because a shorter HMAC key makes PyJWT warn on
    #: every encode and a wall of warnings is how a real one gets ignored.
    jwt_secret: str = "dev-only-insecure-jwt-secret-not-for-production-use"
    jwt_algorithm: str = "HS256"

    # --- Password accounts (add-admin-access-control design D13, D14, D15) -------------
    #: Length beats composition rules: "P@ssw0rd" satisfies every class requirement ever
    #: written and is on every wordlist.
    password_min_length: int = 12
    #: How long a temporary password issued by a super admin stays usable. An unused one is
    #: a standing credential known to two people, sitting in whatever channel carried it.
    temp_password_ttl_hours: int = 72
    #: Per-account lockout. Low enough to stop guessing, high enough to survive a typo.
    password_max_attempts: int = 5
    password_lockout_minutes: int = 15
    #: Per-IP ceiling, independent of the per-account lockout above, so one attacker cannot
    #: spread a few guesses across many accounts and stay under every account's threshold.
    password_signin_ip_limit: int = 20
    password_signin_ip_window_seconds: int = 900
    #: Pending accounts one address may create by signing in with Google (design D6).
    admin_signup_ip_limit: int = 5
    admin_signup_ip_window_seconds: int = 3600

    #: The account provisioned as an active Super Admin on every boot (design D8). Without
    #: it a fresh production database has an empty roster: the first Google sign-in creates
    #: a pending record and nobody holds MANAGE_ADMINS to approve it.
    seed_super_admin_email: str = ""

    # Token derivation for human-typeable short codes (design D11).
    token_pepper: str = "dev-only-insecure-pepper"

    # Google OAuth (design D7)
    google_client_id: str = ""
    google_client_secret: str = ""

    # Email provider (design D9) — unused while dry_run is true
    email_provider_api_key: str = ""
    email_from: str = "noreply@example.com"
    #: Signing secret for delivery webhooks. Separate from the send API key on purpose:
    #: they are rotated independently, and the webhook endpoint is the only unauthenticated
    #: write path in the application. Empty means every webhook is rejected — the safe
    #: default, since accepting unsigned ones would let anyone mark a guest hard-bounced.
    email_webhook_secret: str = ""
    #: Replay window. A captured webhook re-posted later must be refused; a signature alone
    #: is valid forever, so the timestamp is what bounds it.
    webhook_tolerance_seconds: int = 300

    # Cloudflare Turnstile — dev uses the documented always-passing test keys
    turnstile_site_key: str = "1x00000000000000000000AA"
    turnstile_secret_key: str = "1x0000000000000000000000000000000AA"

    # Messaging behaviour
    dry_run: bool = True
    quiet_hours_start: str = "22:00"
    quiet_hours_end: str = "08:00"
    timezone: str = "Asia/Dhaka"

    # Worker cadence (design D6)
    sender_poll_seconds: int = 30
    sender_batch_size: int = 50
    #: The planner runs on the hour. Health uses this to judge a stale heartbeat, so it
    #: must track the cron trigger in `app/worker/main.py`.
    planner_interval_seconds: int = 3600

    # Failure alerting (task 5.6, FR §7.7). A wave failing above this ratio in the last
    # hour emails the operator — a silent 40% bounce rate is indistinguishable from
    # success until guests start asking why they never heard anything.
    failure_alert_threshold: float = 0.05
    failure_alert_min_sample: int = 20
    alert_email_to: str = ""

    # Card artwork storage (design D4). Written by the API, served by Caddy off the same
    # volume — nothing streams media through FastAPI.
    media_root: str = "/srv/media"
    #: Public URL prefix Caddy maps to `media_root`. Kept separate from the filesystem path
    #: so moving to object storage later changes this and the storage module, nothing else.
    media_url_prefix: str = "/media"
    #: Per-file ceiling. The invitation page's whole budget is 800KB including the card, so
    #: a limit above this would let one upload break the page it is meant to decorate.
    media_max_bytes: int = 3 * 1024 * 1024
    #: Deliberately narrow. Everything here is something a card design actually needs; the
    #: browser sniffs nothing because Caddy serves with `X-Content-Type-Options: nosniff`.
    media_allowed_types: tuple[str, ...] = (
        "image/webp",
        "image/avif",
        "image/png",
        "image/jpeg",
        "image/svg+xml",
    )

    # Party-size ceiling for self-registered (open link / QR) guests. A host-issued
    # invitation carries its own max_guests; a walk-in has nobody to set one, and leaving
    # it at 1 would silently truncate "two of us are coming" down to one head.
    open_link_max_guests: int = 5

    # Dev-only: sign in as the seeded Super Admin without a real Google OAuth app, so the
    # admin area is workable before credentials exist. Rejected outright in production.
    auth_dev_bypass: bool = False

    log_level: str = Field(default="INFO")

    @property
    def is_prod(self) -> bool:
        return self.environment == "prod"

    def assert_production_ready(self) -> None:
        """Refuse to run production on development placeholders.

        This exists because of a real failure: `docker compose --env-file` only feeds
        ${...} interpolation in the compose file and does NOT put variables inside the
        container. A misconfigured deploy therefore starts silently on these defaults
        rather than failing, which would mean signing real session cookies with a secret
        published in this repository.
        """
        if not self.is_prod:
            return

        # This one is not a placeholder but an authentication bypass. It must never be
        # reachable in production, so it fails before anything else is even reported.
        if self.auth_dev_bypass:
            raise RuntimeError(
                "AUTH_DEV_BYPASS is enabled while ENVIRONMENT=prod. This would let anyone "
                "sign in as an administrator. Refusing to start."
            )

        placeholders = {
            "SESSION_SECRET": self.session_secret.startswith("dev-only"),
            # Signs every admin session. On the dev default, anyone holding this repository
            # can mint a valid super-admin token for a production deployment. The length
            # floor is HS256's: a shorter key than the digest weakens the signature.
            "JWT_SECRET": self.jwt_secret.startswith("dev-only") or len(self.jwt_secret) < 32,
            # Without it the roster boots empty, every Google sign-in lands in the pending
            # queue, and nobody holds the permission to approve them (design D8).
            "SEED_SUPER_ADMIN_EMAIL": not self.seed_super_admin_email,
            "TOKEN_PEPPER": self.token_pepper.startswith("dev-only"),
            "GOOGLE_CLIENT_ID": not self.google_client_id,
            "EMAIL_PROVIDER_API_KEY": not self.email_provider_api_key,
            # Empty here means every delivery receipt and bounce is rejected, so
            # hard-bounced addresses would keep being retried forever.
            "EMAIL_WEBHOOK_SECRET": not self.email_webhook_secret,
            # Cloudflare's documented always-pass test key: fine in dev, useless in prod.
            "TURNSTILE_SECRET_KEY": self.turnstile_secret_key.startswith("1x0000"),
            # Every link preview, invitation URL and unsubscribe link is built from this.
            # Left on the localhost default, they all point at the guest's own machine — and
            # nothing in development reveals it, because in development it is correct.
            "APP_BASE_URL": self.app_base_url.startswith("http://localhost")
            or not self.app_base_url.startswith("https://"),
        }
        unset = sorted(name for name, is_bad in placeholders.items() if is_bad)
        if unset:
            raise RuntimeError(
                "Refusing to start in production with development placeholders for: "
                + ", ".join(unset)
                + ". Check that .env.prod is populated and that the compose file "
                "references it via `env_file:` (not just --env-file)."
            )


@lru_cache
def get_settings() -> Settings:
    settings = Settings()
    settings.assert_production_ready()
    return settings
