#!/usr/bin/env python3
"""Validate public deployment config separately from runtime secrets."""
from __future__ import annotations

import os
import re
import stat
import sys
from pathlib import Path
from urllib.parse import urlparse

REQUIRED_CONFIG = {
    "SECRETS_SOURCE", "APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "HAN_PG_PORT",
    "HAN_PG_DATABASE", "PG_CA_HOST_PATH", "KEYCLOAK_DB_URL", "KEYCLOAK_DB_USERNAME",
    "PUBLIC_HOST", "PUBLIC_WEB_URL",
    "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL",
    "KEYCLOAK_INTERNAL_URL", "KEYCLOAK_REALM", "KEYCLOAK_SMS_SERVICE_URL",
    "IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
}
REQUIRED_RUNTIME = {
    "DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
    "SMS_DATABASE_URL", "KEYCLOAK_DB_PASSWORD", "REDIS_URL",
    "REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL", "REDIS_API_PASSWORD",
    "REDIS_SAFETY_PASSWORD", "REDIS_HEALTH_PASSWORD",
    "MESSAGE_SAFETY_SERVICE_TOKEN", "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
    "BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN",
    "BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN",
    "KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY",
    "KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN",
    "IDGTL_SMS_API_KEY", "IDGTL_SMS_CALLBACK_USERNAME",
    "IDGTL_SMS_CALLBACK_PASSWORD", "KEYCLOAK_ADMIN_PASSWORD",
    "CURSOR_HMAC_SECRET", "BITRIX_TOKEN_ENCRYPTION_KEY",
    "SELECTEL_S3_ACCESS_KEY", "SELECTEL_S3_SECRET_KEY",
}
OPTIONAL_SECRET_KEYS = {
    "BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN",
    "BITRIX_SYNC_CRM_WEBHOOK_URL", "BITRIX_SYNC_WEBHOOK_TOKEN",
    "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
    "NOTIFICATIONS_TOKEN_PRODUCER_TEST",
    "OTEL_REMOTE_AUTH_HEADER", "KEYCLOAK_OTP_MOCK_CODE",
}
FORBIDDEN_CONFIG_KEYS = REQUIRED_RUNTIME | OPTIONAL_SECRET_KEYS | {
    "BITRIX_SYNC_APP_DATABASE_URL", "PG_BACKUP_DSN",
}
DSN_KEYS = {
    "DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_APP_DATABASE_URL",
    "BITRIX_SYNC_DATABASE_URL", "MESSAGE_SAFETY_DATABASE_URL",
    "SMS_DATABASE_URL", "PG_BACKUP_DSN",
}
PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I)


def load_env(path: Path) -> tuple[dict[str, str], list[str]]:
    values: dict[str, str] = {}
    errors: list[str] = []
    for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            errors.append(f"строка {number}: ожидается KEY=VALUE")
            continue
        key, value = line.split("=", 1)
        key = key.strip()
        if key in values:
            errors.append(f"строка {number}: повтор ключа {key}")
        values[key] = value.strip()
    return values, errors


def load_manifest(path: Path) -> tuple[dict[str, str], list[str]]:
    files, errors = load_env(path)
    values: dict[str, str] = {}
    for key, raw_path in files.items():
        secret_path = Path(raw_path)
        if not secret_path.is_absolute():
            errors.append(f"{key}: manifest должен содержать абсолютный путь")
            continue
        try:
            metadata = secret_path.lstat()
            mode = stat.S_IMODE(metadata.st_mode)
            parent_mode = stat.S_IMODE(secret_path.parent.stat().st_mode)
            if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
                errors.append(f"{key}: secret file должен быть regular file, не symlink")
                continue
            if os.name != "nt" and (mode & 0o022 or parent_mode & 0o077):
                errors.append(
                    f"{key}: secret file или runtime directory имеют небезопасные права"
                )
                continue
            values[key] = secret_path.read_text(encoding="utf-8").rstrip("\r\n")
        except OSError:
            errors.append(f"{key}: secret file недоступен")
    return values, errors


def runtime_from_environment() -> dict[str, str]:
    keys = REQUIRED_RUNTIME | OPTIONAL_SECRET_KEYS
    return {key: os.environ[key] for key in keys if key in os.environ}


def validate_config(config: dict[str, str], errors: list[str]) -> None:
    for key in sorted(REQUIRED_CONFIG):
        if not config.get(key):
            errors.append(f"{key}: обязательный config отсутствует")
    if config.get("SECRETS_SOURCE") not in {"selectel", "file"}:
        errors.append("SECRETS_SOURCE: ожидается selectel или file")
    for key in sorted(FORBIDDEN_CONFIG_KEYS & config.keys()):
        errors.append(f"{key}: секрет или credential-bearing DSN запрещён в обычном .env")
    for key, value in config.items():
        if key.endswith("_FILE") and value:
            errors.append(f"{key}: secret file path задаётся launcher, не обычным .env")
        if key.endswith("_URL"):
            parsed = urlparse(value.replace("jdbc:", "", 1))
            if parsed.username or parsed.password:
                errors.append(f"{key}: URL с credentials запрещён в обычном .env")


def validate_runtime(config: dict[str, str], runtime: dict[str, str], errors: list[str]) -> None:
    required = set(REQUIRED_RUNTIME)
    if config.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true":
        required.add("KEYCLOAK_OTP_MOCK_CODE")
    if config.get("KEYCLOAK_YANDEX_CAPTCHA_ENABLED", "false").lower() == "true":
        required.add("KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY")
        if not config.get("KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY"):
            errors.append(
                "KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY: обязательный public config отсутствует"
            )
    for key in sorted(required):
        value = runtime.get(key, "")
        if not value:
            errors.append(f"{key}: обязательный runtime secret отсутствует")
        elif key == "KEYCLOAK_OTP_MOCK_CODE":
            if not re.fullmatch(r"\d{6,10}", value) or value == "1234":
                errors.append(
                    f"{key}: требуется нестандартный цифровой код длиной 6–10 цифр"
                )
        elif key not in DSN_KEYS and (len(value) < 16 or PLACEHOLDER.search(value)):
            errors.append(f"{key}: runtime secret слишком короткий или является placeholder")

    for key in DSN_KEYS & runtime.keys():
        value = runtime[key]
        if "sslmode=verify-full" not in value or "sslrootcert=" not in value:
            errors.append(f"{key}: требуется sslmode=verify-full и sslrootcert")
        if re.search(r"(?:[?&](?:options|currentSchema)=)", value, re.I):
            errors.append(f"{key}: options/currentSchema запрещены через PgBouncer")

    redis_contract = (
        ("REDIS_URL", "api_backend", "REDIS_API_PASSWORD", "/0"),
        ("REDIS_REALTIME_URL", "api_backend", "REDIS_API_PASSWORD", "/1"),
        ("MESSAGE_SAFETY_REDIS_URL", "message_safety", "REDIS_SAFETY_PASSWORD", "/2"),
    )
    for key, username, password_key, database in redis_contract:
        parsed = urlparse(runtime.get(key, ""))
        if (
            parsed.scheme != "redis" or parsed.hostname != "redis"
            or parsed.username != username or parsed.password != runtime.get(password_key)
            or parsed.path != database
        ):
            errors.append(f"{key}: ACL user/password/host/DB не согласованы с {password_key}")

    for left, right in (
        ("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"),
        ("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"),
        ("KEYCLOAK_SMS_SERVICE_TOKEN", "SMS_SERVICE_TOKEN"),
    ):
        if runtime.get(left) != runtime.get(right):
            errors.append(f"{left} должен совпадать с {right}")


def validate_shared(env: dict[str, str], errors: list[str]) -> None:
    production = env.get("APP_ENV") in {"production-like", "production"}
    if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false":
        errors.append("FRONTEND_DEV_PROXY_ENABLED: production-like/production требует false")
    if production and env.get("NGINX_TLS_ENABLED", "").lower() != "true":
        errors.append("NGINX_TLS_ENABLED: production-like/production требует true")
    for key in (
        "PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL",
        "IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
    ):
        if env.get(key) and urlparse(env[key]).scheme != "https":
            errors.append(f"{key}: публичный URL должен использовать https")
    for key in (
        "KEYCLOAK_INTERNAL_URL", "KEYCLOAK_SMS_SERVICE_URL",
        "MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL",
    ):
        parsed = urlparse(env.get(key, ""))
        if parsed.scheme != "http" or "." in (parsed.hostname or ""):
            errors.append(f"{key}: ожидается http URL с Docker DNS service name")
    if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"):
        errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth")
    try:
        poll = int(env.get("MESSAGE_SAFETY_TASK_POLL_MAX_SEC", "0"))
        nginx = int(env.get("NGINX_MESSAGE_READ_TIMEOUT_SEC", "0"))
        ttl = int(env.get("MESSAGE_SAFETY_TASK_TTL_SEC", "0"))
        if nginx < poll + 30:
            errors.append("NGINX_MESSAGE_READ_TIMEOUT_SEC должен быть >= poll max + 30")
        if ttl <= poll + 60:
            errors.append("MESSAGE_SAFETY_TASK_TTL_SEC должен превышать poll max + recovery margin")
    except ValueError:
        errors.append("Safety/NGINX timeout values должны быть целыми числами")
    if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true":
        if env.get("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED", "").lower() != "true":
            errors.append("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true обязателен для mock OTP")
    callback = f"https://{env.get('PUBLIC_HOST', '')}/callbacks/idgtl/sms"
    if production and env.get("IDGTL_SMS_CALLBACK_PUBLIC_URL") != callback:
        errors.append("IDGTL_SMS_CALLBACK_PUBLIC_URL должен совпадать с PUBLIC_HOST")
    if env.get("HAN_PG_HOST") in {"localhost", "127.0.0.1", "postgres", "db"}:
        errors.append("HAN_PG_HOST: PostgreSQL должен быть внешним managed endpoint")
    keycloak_dsn = env.get("KEYCLOAK_DB_URL", "")
    if "sslmode=verify-full" not in keycloak_dsn or "sslrootcert=" not in keycloak_dsn:
        errors.append("KEYCLOAK_DB_URL: требуется sslmode=verify-full и sslrootcert")


def main() -> int:
    args = sys.argv[1:]
    config_path = Path(args.pop(0) if args and not args[0].startswith("--") else ".env")
    manifest: Path | None = None
    use_runtime_env = False
    while args:
        option = args.pop(0)
        if option == "--runtime-manifest" and args:
            manifest = Path(args.pop(0))
        elif option == "--runtime-env":
            use_runtime_env = True
        else:
            print(f"ERROR: неизвестный параметр {option}", file=sys.stderr)
            return 2
    if not config_path.is_file():
        print(f"ERROR: config файл не найден: {config_path}", file=sys.stderr)
        return 2

    config, errors = load_env(config_path)
    validate_config(config, errors)
    runtime: dict[str, str] = {}
    if manifest:
        if not manifest.is_file():
            errors.append("runtime manifest недоступен")
        else:
            runtime, manifest_errors = load_manifest(manifest)
            errors.extend(manifest_errors)
    elif use_runtime_env:
        runtime = runtime_from_environment()
    if manifest or use_runtime_env:
        validate_runtime(config, runtime, errors)
    validate_shared({**config, **runtime}, errors)

    if errors:
        for error in errors:
            print(f"ERROR: {error}", file=sys.stderr)
        return 1
    scope = "config + runtime secrets" if manifest or use_runtime_env else "non-secret config"
    print(f"OK: {config_path} прошёл проверку ({scope})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
