from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Literal
import os
import re

import yaml


Provider = Literal["openai", "anthropic"]
ExtractionBackend = Literal["llm", "spacy"]

# Standard env var name for each provider's API key.
# Used as fallback when api_key is not in YAML or AURORA_LENS_UPSTREAM_API_KEY.
_PROVIDER_KEY_ENVS: Dict[str, str] = {
    "openai": "OPENAI_API_KEY",
    "anthropic": "ANTHROPIC_API_KEY",
}


_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")


def _env_expand(value: Any) -> Any:
    """
    Recursively expand ${VARNAME} in strings using os.environ.

    - If env var is missing, leaves the token intact (so failures are obvious).
    - Only expands on strings.
    """
    if isinstance(value, str):
        def repl(match: re.Match[str]) -> str:
            key = match.group(1)
            return os.environ.get(key, match.group(0))
        return _ENV_VAR_PATTERN.sub(repl, value)

    if isinstance(value, list):
        return [_env_expand(v) for v in value]

    if isinstance(value, dict):
        return {k: _env_expand(v) for k, v in value.items()}

    return value


def _read_yaml(path: Path) -> Dict[str, Any]:
    raw = path.read_text(encoding="utf-8")
    data = yaml.safe_load(raw) or {}
    if not isinstance(data, dict):
        raise ValueError(f"Config YAML must be a mapping/object. Got: {type(data)}")
    return _env_expand(data)


def _require(mapping: Dict[str, Any], key: str, ctx: str) -> Any:
    if key not in mapping:
        raise ValueError(f"Missing required config field: {ctx}.{key}")
    return mapping[key]


def _as_str(value: Any, ctx: str) -> str:
    if value is None:
        raise ValueError(f"Missing required string: {ctx}")
    if not isinstance(value, str):
        raise ValueError(f"Expected string for {ctx}, got {type(value)}")
    v = value.strip()
    if not v:
        raise ValueError(f"Empty string is not allowed for {ctx}")
    return v


def _as_int(value: Any, ctx: str) -> int:
    if value is None:
        raise ValueError(f"Missing required int: {ctx}")
    if isinstance(value, bool):
        raise ValueError(f"Expected int for {ctx}, got bool")
    if isinstance(value, int):
        return value
    if isinstance(value, str) and value.strip().isdigit():
        return int(value.strip())
    raise ValueError(f"Expected int for {ctx}, got {type(value)}: {value!r}")


def _as_provider(value: Any, ctx: str) -> Provider:
    s = _as_str(value, ctx).lower()
    if s not in ("openai", "anthropic"):
        raise ValueError(f"Invalid provider for {ctx}: {s!r} (allowed: openai | anthropic)")
    return s  # type: ignore[return-value]


@dataclass(frozen=True)
class UpstreamConfig:
    provider: Provider
    api_key: str
    model: str
    base_url: Optional[str] = None  # optional (adapters may have defaults)
    timeout_s: float = 120.0  # upstream request timeout (Ollama/local models often need more)


@dataclass(frozen=True)
class ListenConfig:
    host: str = "0.0.0.0"
    port: int = 8080


@dataclass(frozen=True)
class GovernanceConfig:
    default_policy: str = "strict"  # strict | moderate — deployment default; per-tenant override via AuthKeyConfig.policy
    mode: str = "public"          # public | enterprise | open — set per-deployment, never inferred from text
    audit_log: Optional[str] = "./audit.jsonl"
    policy_version: str = "1.0"
    audit_backend: str = "ledger"  # ledger (default when gov available) | jsonl (force BuiltinBridge)
    audit_signing_key: Optional[str] = None   # Phase D: HMAC key for JSONL; env AURORA_LENS_AUDIT_SIGNING_KEY
    audit_signing_keys: tuple[str, ...] = ()  # D4: additional historical keys for multi-key verification; env AURORA_LENS_AUDIT_SIGNING_KEYS (comma-separated)
    audit_log_max_mb: int = 100   # Phase D: rotate when exceeded; 0 = no rotation
    audit_checkpoint_interval: int = 0   # D3 Option 2: checkpoint every N entries; 0 = disabled
    max_revision_attempts: int = 1  # hard limit on revision loop (governor)
    include_operator_detail: bool = False  # Two-plane output: when False (default) flags/rationale/note stay in audit log only
    enable_mock_hard_stop: bool = False  # Demo only: enable X-Aurora-Mock-Hard-Stop header. Never set in production.
    # Canonical Governor corridor context.
    # authority_class: deployment-level property — what kind of system is this endpoint.
    #   GP = General Purpose (default), DA = Domain Authorized, HS = Human Supervised.
    #   Set once per deployment; never inferred from request text.
    # user_class_header: optional HTTP header name from which per-request UserClass is read.
    #   E.g. "X-Aurora-User-Class". Empty string = always UserClass.GENERAL.
    authority_class: str = "GP"        # GP | DA | HS
    user_class_header: str = ""        # HTTP header name; empty = always GENERAL
    # P.3 Anomaly alerts: 0 = disabled. E.g. 0.05 = 5% threshold.
    threshold_intervention_rate: float = 0.0
    threshold_extraction_failure_rate: float = 0.0
    anomaly_webhook_url: Optional[str] = None  # POST alert payload when threshold exceeded
    # Operator-supplied policy matrix path. When set, overrides the bundled policy_matrix.json.
    # The chosen matrix path is recorded in every forensic audit entry.
    policy_matrix_path: Optional[str] = None


@dataclass(frozen=True)
class CorsConfig:
    enabled: bool = False
    allow_origins: tuple[str, ...] = ("*",)


@dataclass(frozen=True)
class ExtractionConfig:
    """Extraction backend for claim extraction from user input.

    spacy (default): Deterministic NLP extraction. Fast, no API cost, no LLM
        dependency for verification. Appropriate for production governance substrates.
    llm: Higher-fidelity claim extraction using the upstream adapter. Opt-in for
        deployments that accept the added latency and cost.
    """
    backend: str = "spacy"  # "spacy" (default) | "llm"
    spacy_model: str = "en_core_web_sm"  # spaCy model name
    history_window: int = 10  # max conversation turns to send to LLM


@dataclass(frozen=True)
class AuthKeyConfig:
    """Single API key with label and optional policy override."""
    key: str
    label: str
    policy: Optional[str] = None  # strict | moderate | None = use deployment default


@dataclass(frozen=True)
class AuthConfig:
    """Phase B: Inbound authentication. enabled=False bypasses auth (dev mode)."""
    enabled: bool = False
    keys: tuple[AuthKeyConfig, ...] = ()


@dataclass(frozen=True)
class SessionConfig:
    """Phase C: Session persistence. memory=in-memory, redis=Redis-backed."""
    backend: str = "memory"       # memory | redis
    ttl_seconds: int = 3600
    redis_url: str = ""           # required if backend: redis
    lock_acquire_timeout_seconds: float = 10.0   # bounded wait; fail deterministically
    lock_lease_seconds: float = 240.0             # 2× upstream timeout; prevents deadlock


@dataclass(frozen=True)
class HardeningConfig:
    """Phase 5: Input validation and rate limits. 0 = disabled."""
    max_payload_bytes: int = 1_048_576       # 1 MiB
    max_messages: int = 100
    max_content_chars: int = 100_000        # per message
    rate_limit_global: int = 0               # requests/min, 0=disabled
    rate_limit_per_session: int = 0          # requests/min per session, 0=disabled
    rate_limit_per_ip: int = 0              # Phase B.4: requests/min per IP, 0=disabled
    trusted_proxy_ips: tuple[str, ...] = ()  # IPs allowed to set X-Forwarded-For
    stream_max_kb: int = 512                 # stream accumulator cap (KiB); 0 = no cap


@dataclass(frozen=True)
class ProxyConfig:
    upstream: UpstreamConfig
    listen: ListenConfig = ListenConfig()
    governance: GovernanceConfig = GovernanceConfig()
    extraction: ExtractionConfig = ExtractionConfig()
    cors: CorsConfig = CorsConfig()
    hardening: HardeningConfig = HardeningConfig()
    auth: AuthConfig = AuthConfig()
    session: SessionConfig = SessionConfig()

    @staticmethod
    def from_yaml(path: str | Path) -> "ProxyConfig":
        p = Path(path)
        data = _read_yaml(p)
        return ProxyConfig.from_mapping(data)

    @staticmethod
    def from_env() -> "ProxyConfig":
        """Build a ProxyConfig entirely from AURORA_LENS_* environment variables.

        No YAML file required.  AURORA_LENS_UPSTREAM_PROVIDER and
        AURORA_LENS_UPSTREAM_MODEL must be set (or their provider-specific
        equivalents).  All other settings use defaults and env overrides.
        """
        import os
        provider = os.environ.get("AURORA_LENS_UPSTREAM_PROVIDER", "openai")
        model = os.environ.get("AURORA_LENS_UPSTREAM_MODEL", "")
        if not model:
            raise ValueError(
                "AURORA_LENS_UPSTREAM_MODEL must be set when running without a config file"
            )
        base: dict = {
            "upstream": {
                "provider": provider,
                "api_key": "",   # apply_env_overrides fills this in
                "model": model,
            },
            "governance": {
                "audit_log": None,  # disable file audit by default; AURORA_LENS_GOV_AUDIT_LOG overrides
            },
        }
        return ProxyConfig.from_mapping(base)

    @staticmethod
    def from_mapping(data: Dict[str, Any]) -> "ProxyConfig":
        upstream_map = data.get("upstream") or {}
        if not isinstance(upstream_map, dict):
            raise ValueError("Config field 'upstream' must be a mapping/object.")

        listen_map = data.get("listen") or {}
        if not isinstance(listen_map, dict):
            raise ValueError("Config field 'listen' must be a mapping/object.")

        gov_map = data.get("governance") or {}
        if not isinstance(gov_map, dict):
            raise ValueError("Config field 'governance' must be a mapping/object.")

        cors_map = data.get("cors") or {}
        if not isinstance(cors_map, dict):
            cors_map = {}

        extraction_map = data.get("extraction") or {}
        if not isinstance(extraction_map, dict):
            extraction_map = {}
        ext_backend = (extraction_map.get("backend") or "spacy").strip().lower()
        if ext_backend not in ("llm", "spacy"):
            ext_backend = "spacy"
        spacy_model = str(extraction_map.get("spacy_model", "en_core_web_sm")).strip() or "en_core_web_sm"
        history_window = int(extraction_map.get("history_window", 10))
        extraction = ExtractionConfig(
            backend=ext_backend,
            spacy_model=spacy_model,
            history_window=max(1, min(100, history_window)),
        )

        timeout_val = upstream_map.get("timeout_s", 120)
        if isinstance(timeout_val, (int, float)) and timeout_val > 0:
            timeout_s = float(timeout_val)
        else:
            timeout_s = 120.0

        upstream = UpstreamConfig(
            provider=_as_provider(_require(upstream_map, "provider", "upstream"), "upstream.provider"),
            api_key=str(upstream_map.get("api_key", "")).strip(),
            model=_as_str(_require(upstream_map, "model", "upstream"), "upstream.model"),
            base_url=_as_str(upstream_map.get("base_url"), "upstream.base_url") if upstream_map.get("base_url") else None,
            timeout_s=timeout_s,
        )

        listen = ListenConfig(
            host=_as_str(listen_map.get("host", "0.0.0.0"), "listen.host"),
            port=_as_int(listen_map.get("port", 8080), "listen.port"),
        )

        gov_mode_raw = (gov_map.get("mode") or "public").strip().lower()
        gov_mode = gov_mode_raw if gov_mode_raw in ("public", "enterprise", "open") else "public"

        audit_signing = (gov_map.get("audit_signing_key") or "").strip() or None
        _raw_extra_keys = gov_map.get("audit_signing_keys", [])
        if isinstance(_raw_extra_keys, str):
            audit_extra_keys = tuple(k.strip() for k in _raw_extra_keys.split(",") if k.strip())
        elif isinstance(_raw_extra_keys, list):
            audit_extra_keys = tuple(str(k).strip() for k in _raw_extra_keys if str(k).strip())
        else:
            audit_extra_keys = ()
        audit_max_mb = int(gov_map.get("audit_log_max_mb", 100)) if gov_map.get("audit_log_max_mb") is not None else 100
        audit_checkpoint = int(gov_map.get("audit_checkpoint_interval", 0)) if gov_map.get("audit_checkpoint_interval") is not None else 0
        audit_backend_raw = (gov_map.get("audit_backend") or "ledger").strip().lower()
        audit_backend = audit_backend_raw if audit_backend_raw in ("ledger", "jsonl") else "ledger"
        max_revision = int(gov_map.get("max_revision_attempts", 1))
        include_op_detail = bool(gov_map.get("include_operator_detail", False))
        enable_mock = bool(
            gov_map.get("enable_mock_hard_stop", False)
            or os.environ.get("AURORA_LENS_ENABLE_MOCK_HARD_STOP", "").strip().lower() in ("1", "true", "yes")
        )
        thr_interv = float(gov_map.get("threshold_intervention_rate", 0) or 0)
        thr_extr = float(gov_map.get("threshold_extraction_failure_rate", 0) or 0)
        anomaly_webhook = (gov_map.get("anomaly_webhook_url") or "").strip() or None
        policy_matrix_path_raw = (gov_map.get("policy_matrix_path") or "").strip() or None
        governance = GovernanceConfig(
            default_policy=_as_str(gov_map.get("default_policy") or gov_map.get("policy", "strict"), "governance.default_policy"),
            mode=gov_mode,
            audit_log=_as_str(gov_map.get("audit_log", "./audit.jsonl"), "governance.audit_log")
            if gov_map.get("audit_log", "./audit.jsonl") is not None
            else None,
            policy_version=_as_str(gov_map.get("policy_version", "1.0"), "governance.policy_version"),
            policy_matrix_path=policy_matrix_path_raw,
            audit_backend=audit_backend,
            audit_signing_key=audit_signing,
            audit_signing_keys=audit_extra_keys,
            audit_log_max_mb=max(0, audit_max_mb),
            audit_checkpoint_interval=max(0, audit_checkpoint),
            max_revision_attempts=max(1, min(10, max_revision)),
            include_operator_detail=include_op_detail,
            enable_mock_hard_stop=enable_mock,
            threshold_intervention_rate=max(0.0, min(1.0, thr_interv)),
            threshold_extraction_failure_rate=max(0.0, min(1.0, thr_extr)),
            anomaly_webhook_url=anomaly_webhook,
        )

        cors_enabled = bool(cors_map.get("enabled", False))
        cors_origins_raw = cors_map.get("allow_origins", ["*"])
        if isinstance(cors_origins_raw, str):
            cors_origins = tuple(o.strip() for o in cors_origins_raw.split(",") if o.strip()) or ("*",)
        elif isinstance(cors_origins_raw, list):
            cors_origins = tuple(str(o).strip() for o in cors_origins_raw if str(o).strip()) or ("*",)
        else:
            cors_origins = ("*",)
        cors = CorsConfig(enabled=cors_enabled, allow_origins=cors_origins)

        hardening_map = data.get("hardening") or {}
        if not isinstance(hardening_map, dict):
            hardening_map = {}
        trusted_proxies_raw = hardening_map.get("trusted_proxy_ips", [])
        if isinstance(trusted_proxies_raw, str):
            trusted_proxies = tuple(p.strip() for p in trusted_proxies_raw.split(",") if p.strip())
        elif isinstance(trusted_proxies_raw, list):
            trusted_proxies = tuple(str(p).strip() for p in trusted_proxies_raw if str(p).strip())
        else:
            trusted_proxies = ()
        stream_max_kb = int(hardening_map.get("stream_max_kb", 512))
        hardening = HardeningConfig(
            max_payload_bytes=int(hardening_map.get("max_payload_bytes", 1_048_576)),
            max_messages=int(hardening_map.get("max_messages", 100)),
            max_content_chars=int(hardening_map.get("max_content_chars", 100_000)),
            rate_limit_global=int(hardening_map.get("rate_limit_global", 0)),
            rate_limit_per_session=int(hardening_map.get("rate_limit_per_session", 0)),
            rate_limit_per_ip=int(hardening_map.get("rate_limit_per_ip", 0)),
            trusted_proxy_ips=trusted_proxies,
            stream_max_kb=max(0, stream_max_kb),
        )

        auth_map = data.get("auth") or {}
        if not isinstance(auth_map, dict):
            auth_map = {}
        auth_enabled = bool(auth_map.get("enabled", False))
        auth_keys_raw = auth_map.get("keys") or []
        auth_keys: list[AuthKeyConfig] = []
        if isinstance(auth_keys_raw, list):
            for item in auth_keys_raw:
                if isinstance(item, dict):
                    k = str(item.get("key", "")).strip()
                    label = str(item.get("label", "unnamed")).strip() or "unnamed"
                    policy = (item.get("policy") or "").strip().lower() or None
                    if policy and policy not in ("strict", "moderate"):
                        policy = None
                    if k:
                        auth_keys.append(AuthKeyConfig(key=k, label=label, policy=policy))
        auth = AuthConfig(enabled=auth_enabled, keys=tuple(auth_keys))

        session_map = data.get("session") or {}
        if not isinstance(session_map, dict):
            session_map = {}
        session_backend = (session_map.get("backend") or "memory").strip().lower()
        if session_backend not in ("memory", "redis"):
            session_backend = "memory"
        session_ttl = int(session_map.get("ttl_seconds", 3600))
        session_redis_url = str(session_map.get("redis_url", "")).strip()
        lock_acquire = float(session_map.get("lock_acquire_timeout_seconds", 10))
        lock_lease = float(session_map.get("lock_lease_seconds", 240))
        session = SessionConfig(
            backend=session_backend,
            ttl_seconds=max(60, session_ttl),
            redis_url=session_redis_url,
            lock_acquire_timeout_seconds=max(1.0, lock_acquire),
            lock_lease_seconds=max(30.0, lock_lease),
        )

        cfg = ProxyConfig(
            upstream=upstream,
            listen=listen,
            governance=governance,
            extraction=extraction,
            cors=cors,
            hardening=hardening,
            auth=auth,
            session=session,
        )
        return cfg.apply_env_overrides()

    def validate(self) -> None:
        """Check that the resolved config is usable.  Call after env overrides."""
        key = self.upstream.api_key
        base_url = (self.upstream.base_url or "").strip()
        # Empty api_key is allowed when using a custom base_url (e.g. Ollama at localhost:11434)
        if not key or "${" in key:
            if not base_url:
                hint = _PROVIDER_KEY_ENVS.get(
                    self.upstream.provider, "AURORA_LENS_UPSTREAM_API_KEY",
                )
                raise ValueError(
                    f"{hint} missing — set it in your environment or in upstream.api_key"
                )

    def apply_env_overrides(self) -> "ProxyConfig":
        """
        Environment overrides (optional) — intended for deployment.

        Supported:
          AURORA_LENS_UPSTREAM_PROVIDER
          AURORA_LENS_UPSTREAM_API_KEY
          AURORA_LENS_UPSTREAM_MODEL
          AURORA_LENS_UPSTREAM_BASE_URL

          AURORA_LENS_LISTEN_HOST
          AURORA_LENS_LISTEN_PORT

          AURORA_LENS_GOV_POLICY
          AURORA_LENS_GOV_AUDIT_LOG
        """
        up = self.upstream
        ln = self.listen
        gv = self.governance

        provider_raw = os.environ.get("AURORA_LENS_UPSTREAM_PROVIDER", up.provider)
        provider_norm = _as_provider(provider_raw, "AURORA_LENS_UPSTREAM_PROVIDER")

        api_key = os.environ.get("AURORA_LENS_UPSTREAM_API_KEY", up.api_key)
        model = os.environ.get("AURORA_LENS_UPSTREAM_MODEL", up.model)
        base_url = os.environ.get("AURORA_LENS_UPSTREAM_BASE_URL", up.base_url or "")

        # Provider-specific env var fallback (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY)
        if not api_key.strip() or "${" in api_key:
            prov_env = _PROVIDER_KEY_ENVS.get(provider_norm)
            if prov_env:
                api_key = os.environ.get(prov_env, api_key)

        host = os.environ.get("AURORA_LENS_LISTEN_HOST", ln.host)
        port_raw = os.environ.get("AURORA_LENS_LISTEN_PORT", str(ln.port))

        policy = (os.environ.get("AURORA_LENS_GOV_DEFAULT_POLICY")
                  or os.environ.get("AURORA_LENS_GOV_POLICY")
                  or gv.default_policy)
        gov_mode_env = os.environ.get("AURORA_LENS_GOV_MODE", gv.mode).strip().lower()
        gov_mode = gov_mode_env if gov_mode_env in ("public", "enterprise", "open") else gv.mode
        audit_log = os.environ.get("AURORA_LENS_GOV_AUDIT_LOG", gv.audit_log or "")
        policy_version = os.environ.get("AURORA_LENS_GOV_POLICY_VERSION", gv.policy_version)
        audit_signing = os.environ.get("AURORA_LENS_AUDIT_SIGNING_KEY", gv.audit_signing_key or "").strip() or None
        _extra_keys_env = os.environ.get("AURORA_LENS_AUDIT_SIGNING_KEYS", "").strip()
        audit_extra_keys = tuple(k.strip() for k in _extra_keys_env.split(",") if k.strip()) if _extra_keys_env else gv.audit_signing_keys
        audit_max_mb = int(os.environ.get("AURORA_LENS_AUDIT_LOG_MAX_MB", gv.audit_log_max_mb))
        audit_checkpoint = int(os.environ.get("AURORA_LENS_AUDIT_CHECKPOINT_INTERVAL", gv.audit_checkpoint_interval))
        audit_backend_env = os.environ.get("AURORA_LENS_AUDIT_BACKEND", gv.audit_backend).strip().lower()
        audit_backend = audit_backend_env if audit_backend_env in ("ledger", "jsonl") else gv.audit_backend

        timeout_s = up.timeout_s
        if "AURORA_LENS_UPSTREAM_TIMEOUT_S" in os.environ:
            try:
                timeout_s = float(os.environ["AURORA_LENS_UPSTREAM_TIMEOUT_S"])
            except (ValueError, TypeError):
                pass

        new_up = UpstreamConfig(
            provider=provider_norm,
            api_key=api_key.strip(),
            model=_as_str(model, "AURORA_LENS_UPSTREAM_MODEL"),
            base_url=_as_str(base_url, "AURORA_LENS_UPSTREAM_BASE_URL") if base_url.strip() else None,
            timeout_s=timeout_s,
        )

        new_ln = ListenConfig(
            host=_as_str(host, "AURORA_LENS_LISTEN_HOST"),
            port=_as_int(port_raw, "AURORA_LENS_LISTEN_PORT"),
        )

        max_revision = int(os.environ.get("AURORA_LENS_MAX_REVISION_ATTEMPTS", gv.max_revision_attempts))
        include_op_detail_env = os.environ.get("AURORA_LENS_INCLUDE_OPERATOR_DETAIL", "").strip().lower()
        include_op_detail = (
            include_op_detail_env in ("1", "true", "yes", "on")
            if include_op_detail_env
            else gv.include_operator_detail
        )
        enable_mock = gv.enable_mock_hard_stop
        thr_interv = float(os.environ.get("AURORA_LENS_THRESHOLD_INTERVENTION_RATE", gv.threshold_intervention_rate))
        thr_extr = float(os.environ.get("AURORA_LENS_THRESHOLD_EXTRACTION_FAILURE_RATE", gv.threshold_extraction_failure_rate))
        anomaly_webhook = os.environ.get("AURORA_LENS_ANOMALY_WEBHOOK_URL", gv.anomaly_webhook_url or "").strip() or None
        new_gv = GovernanceConfig(
            default_policy=_as_str(policy, "AURORA_LENS_GOV_POLICY"),
            mode=gov_mode,
            audit_log=_as_str(audit_log, "AURORA_LENS_GOV_AUDIT_LOG") if audit_log.strip() else None,
            policy_version=_as_str(policy_version, "AURORA_LENS_GOV_POLICY_VERSION"),
            audit_backend=audit_backend,
            audit_signing_key=audit_signing,
            audit_signing_keys=audit_extra_keys,
            audit_log_max_mb=max(0, audit_max_mb),
            audit_checkpoint_interval=max(0, audit_checkpoint),
            max_revision_attempts=max(1, min(10, max_revision)),
            include_operator_detail=include_op_detail,
            enable_mock_hard_stop=enable_mock,
            threshold_intervention_rate=max(0.0, min(1.0, thr_interv)),
            threshold_extraction_failure_rate=max(0.0, min(1.0, thr_extr)),
            anomaly_webhook_url=anomaly_webhook,
        )

        cors_cfg = self.cors
        cors_enabled_raw = os.environ.get("AURORA_LENS_CORS_ENABLED", str(cors_cfg.enabled)).strip().lower()
        cors_enabled = cors_enabled_raw in ("1", "true", "yes", "on")
        cors_origins_raw = os.environ.get("AURORA_LENS_CORS_ORIGINS", "")
        if cors_origins_raw.strip():
            cors_origins = tuple(o.strip() for o in cors_origins_raw.split(",") if o.strip()) or ("*",)
        else:
            cors_origins = cors_cfg.allow_origins
        new_cors = CorsConfig(enabled=cors_enabled, allow_origins=cors_origins)

        ext = self.extraction
        ext_backend = os.environ.get("AURORA_LENS_EXTRACTION_BACKEND", ext.backend).strip().lower()
        if ext_backend not in ("llm", "spacy"):
            ext_backend = ext.backend
        spacy_model = os.environ.get("AURORA_LENS_SPACY_MODEL", ext.spacy_model).strip() or "en_core_web_sm"
        history_window = int(os.environ.get("AURORA_LENS_HISTORY_WINDOW", ext.history_window))
        new_ext = ExtractionConfig(
            backend=ext_backend,
            spacy_model=spacy_model,
            history_window=max(1, min(100, history_window)),
        )

        h = self.hardening
        h_max_bytes = int(os.environ.get("AURORA_LENS_MAX_PAYLOAD_BYTES", h.max_payload_bytes))
        h_max_msg = int(os.environ.get("AURORA_LENS_MAX_MESSAGES", h.max_messages))
        h_max_chars = int(os.environ.get("AURORA_LENS_MAX_CONTENT_CHARS", h.max_content_chars))
        h_rl_global = int(os.environ.get("AURORA_LENS_RATE_LIMIT_GLOBAL", h.rate_limit_global))
        h_rl_session = int(os.environ.get("AURORA_LENS_RATE_LIMIT_PER_SESSION", h.rate_limit_per_session))
        h_rl_ip = int(os.environ.get("AURORA_LENS_RATE_LIMIT_PER_IP", h.rate_limit_per_ip))
        trusted_proxies_env = os.environ.get("AURORA_LENS_TRUSTED_PROXY_IPS", "")
        trusted_proxies = tuple(p.strip() for p in trusted_proxies_env.split(",") if p.strip()) if trusted_proxies_env.strip() else h.trusted_proxy_ips
        h_stream_kb = int(os.environ.get("AURORA_LENS_STREAM_MAX_KB", h.stream_max_kb))
        new_h = HardeningConfig(
            max_payload_bytes=max(0, h_max_bytes),
            max_messages=max(0, h_max_msg),
            max_content_chars=max(0, h_max_chars),
            rate_limit_global=max(0, h_rl_global),
            rate_limit_per_session=max(0, h_rl_session),
            rate_limit_per_ip=max(0, h_rl_ip),
            trusted_proxy_ips=trusted_proxies,
            stream_max_kb=max(0, h_stream_kb),
        )

        auth_cfg = self.auth
        auth_enabled_raw = os.environ.get("AURORA_LENS_AUTH_ENABLED", str(auth_cfg.enabled)).strip().lower()
        auth_enabled = auth_enabled_raw in ("1", "true", "yes", "on")
        new_auth = AuthConfig(enabled=auth_enabled, keys=auth_cfg.keys)

        sess_cfg = self.session
        sess_backend = os.environ.get("AURORA_LENS_SESSION_BACKEND", sess_cfg.backend).strip().lower()
        if sess_backend not in ("memory", "redis"):
            sess_backend = sess_cfg.backend
        sess_ttl = int(os.environ.get("AURORA_LENS_SESSION_TTL_SECONDS", sess_cfg.ttl_seconds))
        sess_redis = os.environ.get("AURORA_LENS_REDIS_URL", sess_cfg.redis_url or "").strip()
        lock_acquire = float(os.environ.get("AURORA_LENS_LOCK_ACQUIRE_TIMEOUT", sess_cfg.lock_acquire_timeout_seconds))
        lock_lease = float(os.environ.get("AURORA_LENS_LOCK_LEASE_SECONDS", sess_cfg.lock_lease_seconds))
        new_session = SessionConfig(
            backend=sess_backend,
            ttl_seconds=max(60, sess_ttl),
            redis_url=sess_redis,
            lock_acquire_timeout_seconds=max(1.0, lock_acquire),
            lock_lease_seconds=max(30.0, lock_lease),
        )

        return ProxyConfig(
            upstream=new_up,
            listen=new_ln,
            governance=new_gv,
            extraction=new_ext,
            cors=new_cors,
            hardening=new_h,
            auth=new_auth,
            session=new_session,
        )

def load_config(path: str | Path) -> ProxyConfig:
    """
    Backwards-compatible entrypoint expected by proxy/__main__.py.
    """
    return ProxyConfig.from_yaml(path)
