"""Application configuration via environment variables."""

from __future__ import annotations

import json
import os

from pydantic import Field, field_validator
from pydantic_settings import BaseSettings

from server.core.webhook_events import parse_webhook_event_filter

# Which dotenv file to load, if any. Defaults to `.env` so normal local runs
# and deployments are unchanged. The test suite sets `STATEWAVE_ENV_FILE=""`
# (see tests/conftest.py) so a developer's real `.env` — which may carry a
# live LiteLLM key — never bleeds into tests and diverges them from CI, which
# has no `.env`. An empty value disables dotenv loading entirely.
_ENV_FILE = os.getenv("STATEWAVE_ENV_FILE", ".env") or None


class Settings(BaseSettings):
    """Central configuration — populated from env vars or .env file."""

    app_name: str = "statewave"
    debug: bool = False

    # Postgres
    database_url: str = "postgresql+asyncpg://statewave:statewave@localhost:5432/statewave"
    database_echo: bool = False

    # Server
    host: str = "0.0.0.0"
    port: int = 8100

    # CORS
    cors_origins: list[str] = ["*"]

    # Token estimation model
    tiktoken_model: str = "cl100k_base"

    # Context assembly defaults
    default_max_context_tokens: int = 4000

    # Compiler
    compiler_type: str = "heuristic"  # "heuristic" | "llm"

    @field_validator("compiler_type")
    @classmethod
    def _validate_compiler_type(cls, value: str) -> str:
        allowed = {"heuristic", "llm"}
        if value not in allowed:
            raise ValueError(
                f"STATEWAVE_COMPILER_TYPE must be one of {sorted(allowed)}, got {value!r}"
            )
        return value

    # Embeddings.
    #
    # The default `stub` produces deterministic hash vectors so a fresh
    # boot works without LLM credentials, BUT stub vectors are not
    # semantic — semantic search will not work usefully under the stub.
    # Set STATEWAVE_EMBEDDING_PROVIDER=litellm + STATEWAVE_LITELLM_API_KEY +
    # STATEWAVE_LITELLM_EMBEDDING_MODEL for real semantic similarity.
    # `none` disables embeddings entirely (no vector column writes).
    embedding_provider: str = "stub"  # "stub" | "litellm" | "none"
    embedding_dimensions: int = 1536

    @field_validator("embedding_provider")
    @classmethod
    def _validate_embedding_provider(cls, value: str) -> str:
        allowed = {"stub", "litellm", "none"}
        if value not in allowed:
            raise ValueError(
                f"STATEWAVE_EMBEDDING_PROVIDER must be one of {sorted(allowed)}, got {value!r}"
            )
        return value

    # LiteLLM — single provider abstraction. See server/services/llm.py for
    # the provider-neutral env-var contract. LiteLLM dispatches to the
    # underlying SDK (OpenAI, Anthropic, Azure, Bedrock, Ollama, …) by
    # model identifier.
    litellm_api_key: str | None = None
    litellm_model: str = "gpt-4o-mini"  # any LiteLLM model identifier
    # Dedicated EXTRACTION/compile-pipeline model. Empty = fall back to
    # `litellm_model`. This is the one knob that controls memory quality: bench
    # shows extraction-model strength is the dominant lever (gpt-4o-mini 0.792 ->
    # gpt-4o 0.820 -> gpt-4.1 ~0.90 LoCoMo, reaching top-tier hosted parity). Set
    # it (STATEWAVE_LITELLM_COMPILE_MODEL) to a stronger model for best-in-class
    # memory without forcing every other LLM role onto the pricier model. Used by
    # the compiler + the compile-time entity/reconcile/rerank passes, which all
    # already resolve `litellm_compile_model or litellm_model`.
    litellm_compile_model: str = ""
    litellm_embedding_model: str = "text-embedding-3-small"
    litellm_api_base: str | None = None
    # Dedicated embedding endpoint base_url — overrides ``litellm_api_base`` for
    # the embedder ONLY, so a custom embedding server (e.g. a local Qwen TEI) can
    # back retrieval while chat/extraction stays on the default provider. Empty
    # falls back to ``litellm_api_base``.
    litellm_embedding_api_base: str | None = None
    litellm_timeout_seconds: float = 60.0
    litellm_max_retries: int = 2
    litellm_temperature: float = 0.1
    # reasoning_effort for gpt-5 / o-series reasoning models (e.g. "minimal",
    # "low", "medium", "high"). Empty = provider default. "minimal" keeps the
    # response budget from being consumed by reasoning tokens (which starves the
    # content → empty JSON on complex compile/reconcile prompts) and is much
    # faster + cheaper. Ignored for non-reasoning models.
    litellm_reasoning_effort: str = ""
    # Output-token ceiling for the LLM compiler. This is a CAP, not a target
    # (only generated tokens are billed), so it is generous by default: reasoning
    # models (o-series, gpt-5.x, ...) spend part of the budget on hidden reasoning
    # tokens before the JSON, and a small cap truncates their output into invalid
    # JSON. 16000 is safe for gpt-4o-mini's 16K output limit — lower it for legacy
    # models with smaller limits (gpt-4: 8K), raise it for heavy reasoning batches.
    litellm_compile_max_tokens: int = 16000

    # Authentication (empty = disabled / open access)
    api_key: str | None = None

    # Rate limiting (0 = disabled)
    rate_limit_rpm: int = 0
    rate_limit_strategy: str = "memory"  # "memory" (default) | "distributed"

    @field_validator("rate_limit_strategy")
    @classmethod
    def _validate_rate_limit_strategy(cls, value: str) -> str:
        allowed = {"memory", "distributed"}
        if value not in allowed:
            raise ValueError(
                f"STATEWAVE_RATE_LIMIT_STRATEGY must be one of {sorted(allowed)}, got {value!r}"
            )
        return value

    # Subject Snapshots (advanced bootstrap — disabled by default)
    enable_snapshots: bool = False

    # Compile job retention (hours, 0 = no cleanup)
    compile_job_retention_hours: int = 168  # 7 days

    # Compile pagination & drain (issue #134)
    # Each call to `POST /v1/memories/compile` processes at most
    # `compile_batch_size` uncompiled episodes — bounded per-call latency
    # keeps the sync route from timing out on large backlogs. The response
    # carries `has_more` and `remaining_episodes` so sync clients can loop.
    # Async mode (`async: true`) drains the subject by looping internally,
    # up to `compile_max_iterations` batches per job (defensive cap
    # against a runaway compiler that fails to advance `last_compiled_at`).
    compile_batch_size: int = 500
    compile_max_iterations: int = 2000

    # ── Context-aware compile reconcile (Phase 4 + 5b) ──
    # After the compiler extracts candidate facts from an episode batch, run a
    # single LLM "memory-update" call that decides, per candidate, whether it is
    # new (ADD), already stored (DUPLICATE → dropped), a newer value of an
    # existing memory (UPDATE → supersede the old one), or a contradiction
    # (DELETE → supersede the old one). This is the core fix for additive-only
    # drift (stale values staying retrievable). Fail-open:
    # any error falls back to the prior additive behaviour. Toggle for ablation
    # / bench via STATEWAVE_RECONCILE_COMPILE_ENABLED.
    reconcile_compile_enabled: bool = True
    # Cumulative/latest-value merge on UPDATE (opt-in, ships dark). When an UPDATE
    # is two readings of the SAME running quantity (page 200 -> 220; "worn 5
    # times" -> "worn 6 times"), carry the CURRENT absolute value forward into the
    # surviving memory instead of leaving both readings as peers. Count-neutral
    # (overwrites the survivor, no new rows -> no retrieval dilution). Moves the
    # knowledge-update arithmetic to compile-time so the fixed answer model just
    # reads off the current value. Diagnosed on LongMemEval: fixes the
    # latest-value/cumulative-counter failure class (the answer model can't track
    # supersession across a 200-memory haystack). See reconcile.py.
    reconcile_cumulative_merge: bool = False
    # Candidates are reconciled in chronological CHUNKS of this size — each chunk
    # is one LLM call, judged against existing memory + candidates accepted by
    # earlier chunks. Bounds prompt size while still covering an arbitrarily
    # large batch (full-conversation compiles produce hundreds of candidates).
    reconcile_chunk_size: int = 40
    # Absolute safety ceiling: skip reconcile only for a pathologically huge
    # batch (the conflict resolver + next drain still apply).
    reconcile_max_candidates: int = 4000
    # Cap on EXISTING memories carried as reconcile context. Committed existing
    # memories are always included (up to this cap); the remainder of the budget
    # is filled with the most-recent candidates accepted earlier in this batch.
    reconcile_max_existing: int = 60
    # Per-candidate top-k used when narrowing a large committed-existing set.
    reconcile_top_k_per_candidate: int = 5

    # ── Compile-time near-duplicate dedup ───────────────────────────────
    # Full-conversation windowing extracts the whole haystack but produces
    # 450-700 memories/conversation (window-overlap re-extraction + facts
    # restated across sessions). That dilutes retrieval (precise answers rank
    # below top_k) and slows compile. Dedup collapses near-duplicate candidates
    # BEFORE the LLM reconcile: an exact normalized-text pass (always on,
    # stub-safe) plus a semantic pass that merges two candidates ONLY when all
    # three gates pass — identical number/date set, identical significant-content
    # words (a differing name/place/noun/verb blocks the merge), and high cosine
    # (final meaning check). Knowledge-update pairs (same words, different value)
    # are never merged — that stays reconcile's job. Merge is non-destructive
    # (provenance unioned). Fail-open. See server/services/dedup.py.
    dedup_compile_enabled: bool = True
    # Semantic pass needs a real embedding provider; the exact pass runs even
    # without it. Disable to ablate to exact-only dedup.
    dedup_semantic_enabled: bool = True
    dedup_sim_threshold: float = 0.95        # cosine floor for a semantic merge
    dedup_max_candidates: int = 4000         # skip dedup above this (pathological)

    # ── LLM reranker (retrieval) ────────────────────────────────────────
    # Optional reranking on /v1/memories/search?rerank=true: retrieve a generous
    # candidate pool by hybrid similarity, then an LLM scores each candidate's
    # relevance to the question and keeps the best `limit`. Fixes single-fact
    # precision (the answer memory ranking mid-pack among distractors) that
    # entity-boost + top_k tuning don't. GPT-only (compile model). Fail-open to
    # the hybrid order. Off by default; opt in per request.
    rerank_max_pool: int = 100               # hard cap on candidates sent to the reranker
    # Model for the LLM reranker. Reranking is a relevance-JUDGMENT task (not
    # extraction), where a stronger model helps and the cost is one call/query.
    # Empty = use the compile model. Set to gpt-4o for best ranking quality.
    rerank_model: str = ""

    # ── Full-conversation compile (windowing) ───────────────────────────
    # The compiler used to truncate every episode to 4000 chars before
    # extraction — catastrophic for long-context benchmarks (BEAM/LongMemEval
    # episodes are 100K-460K chars per session), where ~97% of the haystack was
    # discarded and most facts were never extracted. Strong long-context memory
    # compiles the FULL conversation. We now split each episode's text into
    # overlapping windows and compile every window, up to a per-episode ceiling
    # (cost bound). Raise the ceiling for maximum recall; lower it to cap compile
    # spend.
    compile_max_episode_chars: int = 60000
    compile_window_chars: int = 6000
    compile_window_overlap: int = 200
    # Parallel LLM extraction calls per compile batch. Full-conversation
    # windowing produces many windows; the old hardcoded 4 made large compiles
    # slow enough to hit client timeouts. Raise for throughput (bounded by
    # provider rate limits + the adapter's retry budget).
    compile_max_concurrency: int = 4
    # Message-level extraction (opt-in): extract atomic facts from small coherent
    # message groups instead of fixed char-windows. Char-windows split
    # mid-utterance and, when large, let the model abstract specifics away
    # ("pink sky sunset" -> "a landscape"); extracting from small message
    # units with the same model keeps the specifics. See compilers/llm.py
    # `_message_chunks`. Falls back to char-windowing for non-chat payloads.
    compile_message_level: bool = False
    compile_messages_per_chunk: int = 6
    compile_message_overlap: int = 1
    # Recall sweep (opt-in): after the first extraction pass, run a SECOND pass
    # over the same window that is shown the already-extracted facts and asked to
    # catch specific details the first pass dropped — named objects/titles,
    # dates, places, counts, quotes — emitting each as a COMPLETE atomic memory
    # (not a fragment, so it avoids the message-level fragmentation failure).
    # Diagnosis: ~54% of the LoCoMo gap to the leading hosted system is
    # first-pass extraction MISS (the fact is simply never compiled). Doubles
    # compile LLM calls; gate on for high-recall workloads. See compilers/llm.py
    # `_RECALL_SWEEP_PROMPT`.
    compile_recall_sweep: bool = False
    # Compose-unit extraction (opt-in): change the extraction UNIT from "one
    # atomic fact" to "one self-contained, interaction-complete statement" while
    # holding memory COUNT flat. Diagnosis: vs the leading hosted system,
    # Statewave memories are ~50% terser (80-89 vs 120-135 chars) and fragment
    # events across rows so the answering memory ranks low; composing them into
    # fuller statements raises per-memory completeness (the one axis the leading
    # system differs on at equal count) WITHOUT adding rows (so it cannot dilute
    # retrieval like the recall sweep).
    # See compilers/llm.py `_SYSTEM_PROMPT_COMPOSE`.
    compile_compose_unit: bool = False
    # Compile-time entity-store population (Phase 2, powers entity-boost
    # retrieval). One LLM call per new memory — set False to skip it when
    # entity-boost retrieval is not in use (e.g. semantic-only bench runs),
    # which materially speeds up large compiles.
    entity_population_enabled: bool = True

    # ── Memory TTL / expiry policies ────────────────────────────────
    # Per-kind expiry windows. Keys are MemoryKind values
    # ("profile_fact", "episode_summary", "procedure", "artifact_ref");
    # values are positive integers (days). Memories of a configured kind
    # get `valid_to = valid_from + days` stamped on insert; the cleanup
    # loop tombstones any active memory whose `valid_to` has passed,
    # and `/v1/context` retrieval filters out unexpired-but-not-yet-
    # cleaned-up rows so the bound is enforced even between cleanup runs.
    #
    # Empty dict (default) = no expiry for any kind (backwards compatible).
    # Missing kind = no expiry for that kind (a kind not listed is forever).
    # Per-subject / per-tenant TTL policies are out of scope for v0.7
    # — they belong to the policy layer (issue #50). v0.7 ships per-kind
    # globals only so the simple primitive lands first.
    #
    # Set via env: STATEWAVE_KIND_TTL_DAYS='{"episode_summary":30,"artifact_ref":7}'
    kind_ttl_days: dict[str, int] = Field(default_factory=dict)

    @field_validator("kind_ttl_days", mode="before")
    @classmethod
    def _parse_kind_ttl_days(cls, value):
        """Accept either a real dict (in-process construction / test fixtures)
        or a JSON-encoded string (the env-var path). Reject anything else
        eagerly so misconfiguration surfaces at startup, not at first
        memory insert."""
        if value is None or value == "":
            return {}
        if isinstance(value, dict):
            parsed = value
        elif isinstance(value, str):
            try:
                parsed = json.loads(value)
            except json.JSONDecodeError as exc:
                raise ValueError(f"STATEWAVE_KIND_TTL_DAYS is not valid JSON: {exc.msg}") from exc
            if not isinstance(parsed, dict):
                raise ValueError(
                    "STATEWAVE_KIND_TTL_DAYS must decode to a JSON object "
                    f"(got {type(parsed).__name__})"
                )
        else:
            raise ValueError(
                f"STATEWAVE_KIND_TTL_DAYS must be a dict or JSON string, got {type(value).__name__}"
            )
        # Normalise + validate values. Reject zero / negative explicitly
        # — a zero-day TTL is almost certainly an operator mistake; the
        # right way to disable expiry for a kind is to leave it out of
        # the dict entirely.
        clean: dict[str, int] = {}
        for kind, days in parsed.items():
            if not isinstance(days, int) or isinstance(days, bool):
                raise ValueError(
                    f"STATEWAVE_KIND_TTL_DAYS[{kind!r}] must be an integer, got {type(days).__name__}"
                )
            if days <= 0:
                raise ValueError(
                    f"STATEWAVE_KIND_TTL_DAYS[{kind!r}] must be > 0; "
                    "remove the kind from the dict to disable expiry for it."
                )
            clean[str(kind)] = days
        return clean

    # Webhooks (empty = disabled)
    webhook_url: str | None = None
    webhook_timeout: float = 5.0
    # Event-type allowlist for webhook delivery, comma-separated. Empty =
    # deliver every event (the backward-compatible default). Example:
    #   STATEWAVE_WEBHOOK_EVENTS=memories.compiled,subject.deleted
    # Kept as the raw string so the env var is plain comma-separated;
    # read the parsed, validated list via the `webhook_event_filter`
    # property.
    webhook_events: str = ""

    @field_validator("webhook_events")
    @classmethod
    def _validate_webhook_events(cls, value: str) -> str:
        """Validate the event-type allowlist at construction.

        An unknown event type fails fast at startup instead of silently
        dropping every webhook weeks later. The raw string is returned
        unchanged; `parse_webhook_event_filter` does the checking."""
        parse_webhook_event_filter(value)
        return value

    @property
    def webhook_event_filter(self) -> list[str]:
        """Parsed + validated webhook event-type allowlist.

        An empty list means no filter is configured — every event is
        delivered. See `parse_webhook_event_filter`."""
        return parse_webhook_event_filter(self.webhook_events)

    # Receipt HMAC signing (v0.9, issue #157) — operator-provided keys
    # per key_id. Set via env (JSON object):
    #
    #     STATEWAVE_RECEIPT_SIGNING_KEYS='{"key-2026-01":"<base64-32B>",...}'
    #
    # Per-tenant active key_id is in tenant_configs.config.receipt_signing_key_id.
    # The server NEVER persists raw signing keys to the database — they
    # live only in this process's in-memory config, sourced from
    # env / secret-manager mount. The field is excluded from __repr__ so
    # an accidental settings dump can't leak the keys.
    receipt_signing_keys: dict[str, bytes] = Field(default_factory=dict, repr=False)

    @field_validator("receipt_signing_keys", mode="before")
    @classmethod
    def _parse_receipt_signing_keys(cls, value):
        """Parse + validate the signing-key map at startup. Failures here
        fail the server boot — better than a silent fallback to unsigned
        receipts because a base64 typo went unnoticed.

        Accepts (a) a real dict (in-process tests / env-decoded), (b) a
        JSON-encoded string (defensive — pydantic-settings does the
        JSON decode itself for complex env fields, but this branch
        keeps programmatic construction symmetrical with the env path),
        or (c) None/"" for no signing. Per-key value can be either
        base64 (env path) or raw `bytes` (in-process tests)."""
        import base64
        import json as _json

        if value is None or value == "":
            return {}
        if isinstance(value, dict):
            parsed = value
        elif isinstance(value, str):
            try:
                parsed = _json.loads(value)
            except _json.JSONDecodeError as exc:
                raise ValueError(
                    f"STATEWAVE_RECEIPT_SIGNING_KEYS is not valid JSON: {exc.msg}"
                ) from exc
            if not isinstance(parsed, dict):
                raise ValueError(
                    "STATEWAVE_RECEIPT_SIGNING_KEYS must decode to a JSON "
                    f"object of key_id -> base64 key (got {type(parsed).__name__})"
                )
        else:
            raise ValueError(
                "STATEWAVE_RECEIPT_SIGNING_KEYS must be a JSON object or dict, "
                f"got {type(value).__name__}"
            )

        clean: dict[str, bytes] = {}
        for key_id, raw in parsed.items():
            if not isinstance(key_id, str) or not key_id:
                raise ValueError(
                    "STATEWAVE_RECEIPT_SIGNING_KEYS: every key_id must be a "
                    f"non-empty string (got {key_id!r})"
                )
            if isinstance(raw, (bytes, bytearray)):
                key_bytes = bytes(raw)
            elif isinstance(raw, str):
                try:
                    # validate=True rejects non-base64 chars; raises on bad input.
                    key_bytes = base64.b64decode(raw, validate=True)
                except Exception as exc:
                    raise ValueError(
                        f"STATEWAVE_RECEIPT_SIGNING_KEYS[{key_id!r}] is not "
                        "valid base64 (operator config should base64-encode "
                        "the raw secret bytes)"
                    ) from exc
            else:
                raise ValueError(
                    f"STATEWAVE_RECEIPT_SIGNING_KEYS[{key_id!r}] must be a "
                    "base64 string or raw bytes, got "
                    f"{type(raw).__name__}"
                )
            if len(key_bytes) < 32:
                # HMAC-SHA256 best practice: key length ≥ output length.
                # Refuse weaker keys at config-load rather than allow
                # weak signatures into production.
                raise ValueError(
                    f"STATEWAVE_RECEIPT_SIGNING_KEYS[{key_id!r}] is too "
                    f"short: {len(key_bytes)} bytes (minimum 32 for "
                    "HMAC-SHA256)."
                )
            clean[key_id] = key_bytes
        return clean

    # Auto-labeling (v0.9, issue #158) — heuristic detectors that stamp
    # advisory `suggested_labels` on memories at compile time. OFF by default
    # so the v0.9 → existing-tenant upgrade is a no-op until an operator opts
    # in. When enabled, the pipeline runs after MemoryRow construction and
    # writes to `memories.suggested_labels` only. The policy evaluator does
    # not read this column; promotion into authoritative `sensitivity_labels`
    # is a deliberate, audited operator action (admin UI / SDK call).
    #
    # `auto_labeling_provider` is forward-looking: in v0.9 the only supported
    # value is `heuristic` (regex + Luhn detectors). Future LLM-based
    # classifiers land as new provider strings without an API break.
    auto_labeling_enabled: bool = False
    auto_labeling_provider: str = "heuristic"  # "heuristic" only in v0.9

    @field_validator("auto_labeling_provider")
    @classmethod
    def _validate_auto_labeling_provider(cls, value: str) -> str:
        allowed = {"heuristic"}
        if value not in allowed:
            raise ValueError(
                f"STATEWAVE_AUTO_LABELING_PROVIDER must be one of {sorted(allowed)}, got {value!r}"
            )
        return value

    # Multi-tenant (empty = single-tenant mode)
    tenant_header: str = "X-Tenant-ID"
    require_tenant: bool = False

    # Data residency (v0.9, issue #161) — per-region deployment model.
    #
    # `region` declares which region THIS server process is running in
    # (e.g. "eu", "us", "ap"). The residency enforcement layer hard-
    # checks every tenant-scoped request against the tenant's pinned
    # region (`tenant_configs.config.region`): a request for a tenant
    # pinned to "us" arriving at a process with STATEWAVE_REGION="eu"
    # is rejected with HTTP 403 / `residency.mismatch`.
    #
    # Default `None` is single-region mode: no checks, no stamping.
    # Set this whenever you operate >1 region so the application
    # enforces residency at the request boundary instead of relying
    # on DNS / load-balancer hints (which can be misrouted).
    #
    # Naming is up to the operator; we recommend short lowercase
    # identifiers ("eu", "us-east", "ap-south-1") so the value is
    # grep-friendly across logs.
    region: str | None = None

    @field_validator("region")
    @classmethod
    def _validate_region(cls, value: str | None) -> str | None:
        if value is None or value == "":
            return None
        s = value.strip()
        if not s:
            return None
        # Keep the value tight so a leading-space typo doesn't silently
        # produce two distinct regions in audit logs.
        if s != value:
            raise ValueError("STATEWAVE_REGION must not have leading/trailing whitespace")
        if len(s) > 64:
            raise ValueError("STATEWAVE_REGION must be ≤64 characters")
        return s

    # Migration safety
    strict_schema: bool = False  # if True, refuse to start on schema mismatch

    # Statewave Support shared docs subject. Rebuilt by the vendor-neutral
    # `POST /admin/memory/support/reseed` endpoint, which imports the bundled
    # `statewave-support-agent` starter pack from `server/starter_packs/`.
    # No GitHub Actions / Fly / Vercel dependency.
    support_subject_id: str = "statewave-support-docs"
    support_starter_pack_id: str = "statewave-support-agent"

    # Memory import/export hard limits — defence against pathological payloads.
    # Apply uniformly across starter-pack imports, clone, and bulk import.
    memory_import_max_bytes: int = 50 * 1024 * 1024  # 50 MiB
    memory_import_max_episodes: int = 50_000
    memory_import_max_memories: int = 50_000
    memory_import_max_subjects: int = 100

    model_config = {"env_prefix": "STATEWAVE_", "env_file": _ENV_FILE, "extra": "ignore"}


settings = Settings()
