from __future__ import annotations

import base64
import json
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

from cryptography.fernet import Fernet
from sqlalchemy.engine import make_url


ConfigIssueLevel = Literal["error", "warning"]


@dataclass(frozen=True, slots=True)
class ConfigIssue:
    level: ConfigIssueLevel
    key: str
    message: str
    action: str

    def to_dict(self) -> dict[str, str]:
        return {
            "level": self.level,
            "key": self.key,
            "message": self.message,
            "action": self.action,
        }


@dataclass(frozen=True, slots=True)
class ConfigValidationResult:
    profile: str
    issues: tuple[ConfigIssue, ...]

    @property
    def errors(self) -> tuple[ConfigIssue, ...]:
        return tuple(issue for issue in self.issues if issue.level == "error")

    @property
    def warnings(self) -> tuple[ConfigIssue, ...]:
        return tuple(issue for issue in self.issues if issue.level == "warning")

    @property
    def ok(self) -> bool:
        return not self.errors

    def to_dict(self) -> dict[str, object]:
        return {
            "profile": self.profile,
            "ok": self.ok,
            "errors": [issue.to_dict() for issue in self.errors],
            "warnings": [issue.to_dict() for issue in self.warnings],
        }

    def to_json(self) -> str:
        return json.dumps(self.to_dict(), indent=2, sort_keys=True)

    def to_text(self) -> str:
        lines = [
            f"GovOPlaN configuration validation: {'OK' if self.ok else 'FAILED'}",
            f"Profile: {self.profile}",
        ]
        if not self.issues:
            lines.append("No configuration issues found.")
            return "\n".join(lines)
        for issue in self.issues:
            lines.append(f"- {issue.level.upper()} {issue.key}: {issue.message}")
            lines.append(f"  Action: {issue.action}")
        return "\n".join(lines)


@dataclass(frozen=True, slots=True)
class _RuntimeProfile:
    name: str
    production: bool
    production_like: bool
    local: bool


@dataclass(slots=True)
class _ConfigIssueCollector:
    strict: bool
    issues: list[ConfigIssue]

    def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
        if self.strict and level == "warning":
            level = "error"
        self.issues.append(
            ConfigIssue(level=level, key=key, message=message, action=action)
        )


_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
_DEFAULT_LOCAL_CORS = {
    "http://localhost:5173",
    "http://127.0.0.1:5173",
    "http://localhost:8080",
}


def normalize_install_profile(value: str | None) -> str:
    profile = (value or "").strip().lower().replace("_", "-")
    if profile in {"prod", "production"}:
        return "production"
    if profile in {"selfhosted", "self-hosted"}:
        return "self-hosted"
    if profile in {"productionlike", "production-like"}:
        return "production-like"
    if profile in {"local", "local-dev"}:
        return "local-dev"
    return profile or "local-dev"


def generate_master_key() -> str:
    return Fernet.generate_key().decode("ascii")


def env_template(
    *, profile: str = "self-hosted", generate_secrets: bool = False
) -> str:
    clean_profile = normalize_install_profile(profile)
    master_key = (
        generate_master_key()
        if generate_secrets
        else "<generate-with-govoplan-config-env-template-generate-secrets>"
    )
    if clean_profile == "production-like":
        return _production_like_env_template(master_key)
    return _self_hosted_env_template(master_key)


def validate_runtime_configuration(
    environ: Mapping[str, str] | None = None,
    *,
    profile: str | None = None,
    strict: bool = False,
) -> ConfigValidationResult:
    env = dict(os.environ if environ is None else environ)
    runtime = _runtime_profile(env, profile=profile)
    collector = _ConfigIssueCollector(strict=strict, issues=[])
    _validate_app_env(env, runtime, collector)
    _validate_database_settings(env, runtime, collector)
    _validate_master_key(env, runtime, collector)
    _validate_enabled_modules(env, runtime, collector)
    _validate_async_and_auth_settings(env, runtime, collector)
    _validate_cors_settings(env, runtime, collector)
    _validate_file_storage_settings(env, runtime, collector)
    _validate_shared_state_settings(env, collector)
    _validate_outbound_connector_policy(env, runtime, collector)
    _validate_module_catalog_trust(env, runtime, collector)
    return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))


def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
    clean_profile = normalize_install_profile(
        profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")
    )
    production = clean_profile in _PRODUCTION_PROFILES or env.get(
        "APP_ENV", ""
    ).strip().lower() in {"prod", "production"}
    production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
    return _RuntimeProfile(
        name=clean_profile,
        production=production,
        production_like=production_like,
        local=clean_profile in _LOCAL_PROFILES and not production_like,
    )


def _validate_app_env(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    app_env = _clean(env.get("APP_ENV"))
    if not app_env and runtime.production_like:
        collector.add(
            "error",
            "APP_ENV",
            "APP_ENV is missing for a production-like install.",
            "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.",
        )
    elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
        collector.add(
            "error",
            "APP_ENV",
            f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.",
            "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.",
        )


def _validate_database_settings(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    database_url = _clean(env.get("DATABASE_URL"))
    if not database_url:
        collector.add(
            "error",
            "DATABASE_URL",
            "DATABASE_URL is missing.",
            "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.",
        )
        return
    backend = _database_backend(database_url)
    if backend is None:
        collector.add(
            "error",
            "DATABASE_URL",
            "DATABASE_URL is not a valid SQLAlchemy URL.",
            "Use a value like postgresql+psycopg://user:password@host:5432/database.",
        )
        return
    if backend == "sqlite" and runtime.production_like:
        collector.add(
            "error",
            "DATABASE_URL",
            "SQLite is only supported for disposable local development.",
            "Use PostgreSQL for production-like and self-hosted installs.",
        )
    elif backend != "postgresql" and runtime.production:
        collector.add(
            "warning",
            "DATABASE_URL",
            f"Database backend {backend!r} is not the preferred production target.",
            "Use PostgreSQL unless this deployment has an explicit support decision.",
        )
    if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
        collector.add(
            "warning",
            "GOVOPLAN_DATABASE_URL_PGTOOLS",
            "PostgreSQL backup/restore tools URL is missing.",
            "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.",
        )


def _validate_master_key(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    master_key = _clean(env.get("MASTER_KEY_B64"))
    if not master_key and not runtime.local:
        collector.add(
            "error",
            "MASTER_KEY_B64",
            "MASTER_KEY_B64 is required outside local dev/test.",
            "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.",
        )
        return
    if not master_key:
        return
    error = _master_key_error(master_key)
    if error:
        collector.add(
            "error",
            "MASTER_KEY_B64",
            error,
            "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.",
        )
    elif "change-me" in master_key.lower() or "generate" in master_key.lower():
        collector.add(
            "error",
            "MASTER_KEY_B64",
            "MASTER_KEY_B64 still looks like a placeholder.",
            "Generate a real deployment key and store it outside git.",
        )


def _validate_enabled_modules(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    enabled_modules = _csv(env.get("ENABLED_MODULES"))
    if not enabled_modules and runtime.production_like:
        collector.add(
            "error",
            "ENABLED_MODULES",
            "ENABLED_MODULES is missing.",
            "Set ENABLED_MODULES explicitly so startup module composition is intentional.",
        )
    elif "access" not in enabled_modules and runtime.production_like:
        collector.add(
            "error",
            "ENABLED_MODULES",
            "The access module is not enabled.",
            "Include `access` unless this deployment has a replacement auth/principal provider.",
        )
    elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
        collector.add(
            "warning",
            "ENABLED_MODULES",
            "The admin module is not enabled.",
            "Keep `admin` enabled for operator UI unless this is a deliberately headless install.",
        )


def _validate_async_and_auth_settings(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
        collector.add(
            "error",
            "REDIS_URL",
            "CELERY_ENABLED=true but REDIS_URL is missing.",
            "Set REDIS_URL to the Redis broker/result backend used by workers.",
        )
    if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
        collector.add(
            "error",
            "DEV_BOOTSTRAP_ENABLED",
            "Development bootstrap is enabled in production.",
            "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.",
        )
    if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
        collector.add(
            "error",
            "AUTH_COOKIE_SECURE",
            "Secure auth cookies are disabled for production.",
            "Set AUTH_COOKIE_SECURE=true behind HTTPS.",
        )


def _validate_cors_settings(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    cors_origins = _csv(env.get("CORS_ORIGINS"))
    if runtime.production_like and not cors_origins:
        collector.add(
            "error",
            "CORS_ORIGINS",
            "CORS_ORIGINS is missing.",
            "Set CORS_ORIGINS to the exact WebUI origin or origins.",
        )
    elif "*" in cors_origins and runtime.production_like:
        collector.add(
            "error",
            "CORS_ORIGINS",
            "Wildcard CORS is not allowed for production-like installs.",
            "Replace `*` with exact HTTPS/WebUI origins.",
        )
    elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
        collector.add(
            "warning",
            "CORS_ORIGINS",
            "CORS_ORIGINS still contains only local development origins.",
            "Set CORS_ORIGINS to the deployed WebUI origin.",
        )
    trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
    if runtime.production_like and not trusted_hosts:
        collector.add(
            "error",
            "GOVOPLAN_TRUSTED_HOSTS",
            "Trusted HTTP hosts are not configured.",
            "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.",
        )
    elif "*" in trusted_hosts and runtime.production_like:
        collector.add(
            "error",
            "GOVOPLAN_TRUSTED_HOSTS",
            "Wildcard trusted hosts are not allowed for production-like installs.",
            "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.",
        )
    forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
    if runtime.production_like and "*" in forwarded_allow_ips:
        collector.add(
            "error",
            "FORWARDED_ALLOW_IPS",
            "Proxy headers must not be trusted from every address.",
            "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.",
        )


def _validate_file_storage_settings(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
    deployment_managed_raw = _clean(
        env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")
    ).lower()
    endpoint_trusted_raw = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_TRUSTED")).lower()
    if deployment_managed_raw and deployment_managed_raw not in {
        "true",
        "false",
        "1",
        "0",
        "yes",
        "no",
        "on",
        "off",
    }:
        collector.add(
            "error",
            "FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
            "Managed S3 trust must be an explicit boolean.",
            "Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false, or let the supported installer manage Garage.",
        )
    deployment_managed = _truthy(deployment_managed_raw)
    if endpoint_trusted_raw and endpoint_trusted_raw not in {
        "true",
        "false",
        "1",
        "0",
        "yes",
        "no",
        "on",
        "off",
    }:
        collector.add(
            "error",
            "FILE_STORAGE_S3_ENDPOINT_TRUSTED",
            "External S3 endpoint trust must be an explicit boolean.",
            "Set it only for a deployment-controlled HTTPS storage origin.",
        )
    endpoint_trusted = _truthy(endpoint_trusted_raw)
    if storage_backend == "local":
        if deployment_managed or endpoint_trusted:
            collector.add(
                "error",
                "FILE_STORAGE_S3_ENDPOINT_TRUSTED",
                "S3 endpoint trust cannot be enabled for local file storage.",
                "Disable both S3 trust settings while FILE_STORAGE_BACKEND=local.",
            )
        _validate_local_file_storage(env, runtime, collector)
    elif storage_backend == "s3":
        _validate_s3_file_storage(
            env,
            collector,
            deployment_managed=deployment_managed,
            endpoint_trusted=endpoint_trusted,
        )
    else:
        collector.add(
            "error",
            "FILE_STORAGE_BACKEND",
            f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.",
            "Use `local` or `s3`.",
        )


def _validate_local_file_storage(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
        collector.add(
            "error",
            "FILE_STORAGE_LOCAL_ROOT",
            "Local file storage root is missing.",
            "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.",
        )
    elif runtime.production:
        collector.add(
            "warning",
            "FILE_STORAGE_BACKEND",
            "Production is configured for local file storage.",
            "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.",
        )


def _validate_s3_file_storage(
    env: Mapping[str, str],
    collector: _ConfigIssueCollector,
    *,
    deployment_managed: bool,
    endpoint_trusted: bool,
) -> None:
    for key in (
        "FILE_STORAGE_S3_ENDPOINT_URL",
        "FILE_STORAGE_S3_REGION",
        "FILE_STORAGE_S3_ACCESS_KEY_ID",
        "FILE_STORAGE_S3_SECRET_ACCESS_KEY",
        "FILE_STORAGE_S3_BUCKET",
    ):
        if not _clean(env.get(key)):
            collector.add(
                "error",
                key,
                f"{key} is required when FILE_STORAGE_BACKEND=s3.",
                "Configure all FILE_STORAGE_S3_* settings through deployment secrets.",
            )
    if (
        deployment_managed
        and _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL")) != "http://garage:3900"
    ):
        collector.add(
            "error",
            "FILE_STORAGE_S3_ENDPOINT_URL",
            "Installer-managed S3 trust is restricted to http://garage:3900.",
            "Use the exact managed Garage endpoint or disable FILE_STORAGE_S3_DEPLOYMENT_MANAGED.",
        )
    if deployment_managed and endpoint_trusted:
        collector.add(
            "error",
            "FILE_STORAGE_S3_ENDPOINT_TRUSTED",
            "Managed Garage trust and external endpoint trust are mutually exclusive.",
            "Use installer-managed Garage trust or one explicit external endpoint.",
        )
    if not deployment_managed and not endpoint_trusted:
        collector.add(
            "error",
            "FILE_STORAGE_S3_ENDPOINT_TRUSTED",
            "External S3 storage requires an explicit deployment trust decision.",
            "Set FILE_STORAGE_S3_ENDPOINT_TRUSTED=true only for a deployment-controlled HTTPS origin.",
        )
    endpoint = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL"))
    if endpoint_trusted and not endpoint.lower().startswith("https://"):
        collector.add(
            "error",
            "FILE_STORAGE_S3_ENDPOINT_URL",
            "Deployment-trusted external S3 storage must use HTTPS.",
            "Use an HTTPS storage origin with certificate verification.",
        )


def _validate_shared_state_settings(
    env: Mapping[str, str],
    collector: _ConfigIssueCollector,
) -> None:
    state_profile = (_clean(env.get("GOVOPLAN_STATE_PROFILE")) or "local").lower()
    if state_profile not in {"local", "host-shared", "shared"}:
        collector.add(
            "error",
            "GOVOPLAN_STATE_PROFILE",
            f"Unsupported state profile {state_profile!r}.",
            "Use `local` for one process per role, `host-shared` for one Compose host, or `shared` for a multi-host stateless tier.",
        )
        return
    try:
        api_replicas = int(_clean(env.get("GOVOPLAN_EXPECTED_API_REPLICAS")) or "1")
        worker_replicas = int(
            _clean(env.get("GOVOPLAN_EXPECTED_WORKER_REPLICAS")) or "0"
        )
    except ValueError:
        collector.add(
            "error",
            "GOVOPLAN_EXPECTED_API_REPLICAS",
            "Expected replica counts must be integers.",
            "Set GOVOPLAN_EXPECTED_API_REPLICAS and GOVOPLAN_EXPECTED_WORKER_REPLICAS to non-negative integers.",
        )
        return
    if api_replicas < 1 or worker_replicas < 0:
        collector.add(
            "error",
            "GOVOPLAN_EXPECTED_API_REPLICAS",
            "Expected replica counts are outside their supported range.",
            "Configure at least one API replica and zero or more worker replicas.",
        )
    if state_profile == "local":
        if api_replicas > 1 or worker_replicas > 1:
            collector.add(
                "error",
                "GOVOPLAN_STATE_PROFILE",
                "A local-state profile cannot safely run replicated API or worker nodes.",
                "Use `host-shared` with one shared host volume, or `shared` with PostgreSQL, Redis, and S3-compatible object storage.",
            )
        return
    installation_id = _clean(env.get("GOVOPLAN_INSTALLATION_ID"))
    if not installation_id or (
        state_profile == "shared" and installation_id == "govoplan-local"
    ):
        collector.add(
            "error",
            "GOVOPLAN_INSTALLATION_ID",
            "Shared-state deployments require a stable installation identifier.",
            "Set one immutable deployment-wide GOVOPLAN_INSTALLATION_ID on every node.",
        )
    if _database_backend(_clean(env.get("DATABASE_URL"))) != "postgresql":
        collector.add(
            "error",
            "DATABASE_URL",
            "Shared-state deployments require PostgreSQL.",
            "Point every API, scheduler, and worker node at the same logical PostgreSQL service.",
        )
    if not _clean(env.get("REDIS_URL")):
        collector.add(
            "error",
            "REDIS_URL",
            "Shared-state deployments require a common Redis service.",
            "Configure the same Redis endpoint for all API and worker nodes.",
        )
    if (
        state_profile == "shared"
        and (_clean(env.get("FILE_STORAGE_BACKEND")) or "local").lower() != "s3"
    ):
        collector.add(
            "error",
            "FILE_STORAGE_BACKEND",
            "Shared-state deployments cannot use node-local object storage.",
            "Set FILE_STORAGE_BACKEND=s3 and configure one shared S3-compatible bucket.",
        )
    try:
        heartbeat = int(_clean(env.get("GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS")) or "15")
        stale_after = int(
            _clean(env.get("GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS")) or "60"
        )
    except ValueError:
        collector.add(
            "error",
            "GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS",
            "Runtime heartbeat and stale intervals must be integers.",
            "Use a heartbeat interval shorter than one third of the stale interval.",
        )
    else:
        if heartbeat < 2 or stale_after < max(10, heartbeat * 3):
            collector.add(
                "error",
                "GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS",
                "Runtime stale detection leaves insufficient room for missed heartbeats.",
                "Set stale-after to at least three heartbeat intervals and at least ten seconds.",
            )


def _validate_outbound_connector_policy(
    env: Mapping[str, str],
    runtime: _RuntimeProfile,
    collector: _ConfigIssueCollector,
) -> None:
    private_networks = _clean(
        env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
    ).lower()
    if runtime.production_like and private_networks not in {
        "true",
        "false",
        "1",
        "0",
        "yes",
        "no",
        "on",
        "off",
    }:
        collector.add(
            "error",
            "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
            "Private-network connector access must be an explicit deployment decision.",
            "Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
        )
    for key in (
        "GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
        "GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
        "GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
    ):
        value = _clean(env.get(key))
        if not value:
            continue
        try:
            parsed = int(value)
        except ValueError:
            parsed = 0
        if parsed <= 0:
            collector.add(
                "error",
                key,
                f"{key} must be a positive byte count.",
                "Use a positive integer byte limit.",
            )
    secret_env_names = [
        item.strip()
        for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
        if item.strip()
    ]
    if any(
        re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None
        for item in secret_env_names
    ):
        collector.add(
            "error",
            "GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
            "Connector secret environment allowlist contains an invalid variable name.",
            "Use a comma-separated list of exact environment variable names, or leave the setting empty.",
        )
    ca_bundle_paths = [
        item.strip()
        for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
        if item.strip()
    ]
    if any(not Path(item).is_absolute() for item in ca_bundle_paths):
        collector.add(
            "error",
            "GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
            "Connector CA bundle allowlist contains a non-absolute path.",
            "Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
        )


def _validate_module_catalog_trust(
    env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
) -> None:
    catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(
        env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")
    )
    if not runtime.production or not catalog_source:
        return
    if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
        collector.add(
            "error",
            "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE",
            "A module catalog source is configured without a trusted keyring file.",
            "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.",
        )
    if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
        collector.add(
            "error",
            "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL",
            "A module catalog source is configured without an approved release channel.",
            "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.",
        )


def _self_hosted_env_template(master_key: str) -> str:
    return f"""# GovOPlaN self-hosted install configuration.
# Copy to a deployment-local .env or secret store. Do not commit populated secrets.

APP_ENV=production
GOVOPLAN_INSTALL_PROFILE=self-hosted
GOVOPLAN_INSTALLATION_ID=govoplan-production
GOVOPLAN_STATE_PROFILE=local
GOVOPLAN_RUNTIME_ROLE=api
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
GOVOPLAN_EXPECTED_API_REPLICAS=1
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
MASTER_KEY_B64={master_key}

DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:change-me@127.0.0.1:5432/govoplan

ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,files,mail,campaigns,calendar,docs,ops

CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30

# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=31536000

AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30

CORS_ORIGINS=https://govoplan.example.org
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
# restricted to the actual reverse proxy address or network.
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=lax
AUTH_COOKIE_DOMAIN=

FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800

DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=false
DEV_MAILBOX_API_ENABLED=false

GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable
"""


def _production_like_env_template(master_key: str) -> str:
    return f"""# GovOPlaN production-like development profile.
# Copy to dev/production-like/.env for local overrides.

APP_ENV=staging
GOVOPLAN_INSTALL_PROFILE=production-like
GOVOPLAN_INSTALLATION_ID=govoplan-production-like
GOVOPLAN_STATE_PROFILE=local
GOVOPLAN_RUNTIME_ROLE=api
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
GOVOPLAN_EXPECTED_API_REPLICAS=1
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
MASTER_KEY_B64={master_key}

GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_USER=govoplan
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PASSWORD=govoplan-dev
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PORT=55433
GOVOPLAN_PRODUCTION_LIKE_REDIS_PORT=56379

GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_PRODUCTION_LIKE_REDIS_URL=redis://127.0.0.1:56379/0

DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30

GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=0

AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30

ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=true
"""


def _clean(value: str | None) -> str:
    return str(value or "").strip()


def _csv(value: str | None) -> tuple[str, ...]:
    return tuple(
        dict.fromkeys(
            item.strip() for item in str(value or "").split(",") if item.strip()
        )
    )


def _truthy(value: str | None) -> bool:
    return _clean(value).lower() in {"1", "true", "yes", "on"}


def _database_backend(database_url: str) -> str | None:
    try:
        return make_url(database_url).get_backend_name()
    except Exception:
        return None


def _master_key_error(value: str) -> str | None:
    candidate = value.strip().encode("utf-8")
    try:
        Fernet(candidate)
        return None
    except (TypeError, ValueError):
        raw = None
    try:
        raw = base64.b64decode(candidate)
    except Exception:
        return "MASTER_KEY_B64 must be a Fernet key or base64-encoded 32-byte key."
    if len(raw) != 32:
        return "MASTER_KEY_B64 must decode to exactly 32 bytes."
    try:
        Fernet(base64.urlsafe_b64encode(raw))
    except (TypeError, ValueError):
        return "MASTER_KEY_B64 is not usable as a Fernet key."
    return None
