"""Application configuration loaded from environment and config.json."""

import base64
import hashlib
import json
import logging
import os
import re
import secrets
from pathlib import Path
from typing import Any, Optional

from cryptography.fernet import Fernet, InvalidToken
from pydantic_settings import BaseSettings

logger = logging.getLogger(__name__)

DATA_DIR = Path("data")
CONFIG_FILE = DATA_DIR / "config.json"
DB_FILE = DATA_DIR / "dashboard.db"
SECRET_KEY_FILE = DATA_DIR / ".secret_key"
SECRETS_FILE = DATA_DIR / "secrets.json"
# Local dashboard accounts (username + PBKDF2 password hash + optional TOTP).
USERS_FILE = DATA_DIR / "users.json"
# Per-secret instance scopes: {"SECRET_NAME": ["inst_id_1", "inst_id_2"]}.
# Empty list or missing entry = "all instances" (backwards compatible default).
# Stored separately from secrets.json so legacy string secrets and compounds
# share one scoping mechanism without shape changes to the secret entries.
SECRET_SCOPE_FILE = DATA_DIR / "secret_scope.json"


class Settings(BaseSettings):
    # App
    port: int = 3000
    database_url: str = ""
    # SECRET_KEY is NOT declared here. It is loaded/generated by _get_secret_key()
    # and persisted to data/.secret_key so it survives container rebuilds.
    # Set SECRET_KEY in .env to override.

    # AI Assistant (optional)
    llm_provider: str = ""
    llm_api_key: str = ""
    llm_model: str = ""
    ollama_url: str = "http://localhost:11434"
    qdrant_url: str = ""
    qdrant_collection: str = "n8n_docs"
    embedding_provider: str = ""
    embedding_model: str = ""
    search_provider: str = "none"
    search_api_key: str = ""

    # Host alias detection for the auto-update eligibility check.
    # Comma-separated hostnames/IPs the operator considers "this host"
    # (e.g. "192.0.2.10,host,host.local"). UNIONed with the IPs
    # discovered from /proc/net/route and container network interfaces.
    agd_host_aliases: str = ""

    # Public hostname/IP this dashboard host reaches from the operator's
    # browser. Used to synthesize "Open container" URLs for deployed
    # templates. When unset, the dashboard falls back to the request's Host
    # header (strip port), then "localhost". Right value for a remote LXC,
    # NAS, or VPS deployment: the public DNS name or LAN IP.
    agd_public_host: str = ""

    # Agent Vault sidecar URL (upstream Go binary, docker-compose agents profile).
    # Single port serves admin REST API, web UI, and HTTP-proxy egress via
    # path-based + verb-based routing. Override in .env if vault runs elsewhere.
    # Set to empty string ("") in .env to explicitly disable Vault features.
    vault_admin_url: Optional[str] = "http://vault:14321"  # admin REST + web UI
    vault_proxy_url: Optional[str] = "http://vault:14322"  # MITM transparent proxy for subprocess HTTP_PROXY

    # In-app auth gate. Local account login is enforced by default unless
    # AGD_DISABLE_LOGIN=true. AGD_REQUIRE_AUTH is the token/edge-auth hard gate
    # for automation/proxy-fronted installs. Edge identity headers are trusted
    # only when AGD_TRUST_EDGE_AUTH=true; never enable that on a directly
    # reachable port because clients can spoof those headers.
    agd_require_auth: bool = False
    agd_admin_token: str = ""
    agd_trust_edge_auth: bool = False
    agd_trust_forwarded_for: bool = False
    # Optional token for the legacy machine-ingest endpoints:
    # /api/errors/webhook and /api/messages/webhook. When unset, those endpoints
    # stay open for backward compatibility with existing n8n handlers. Prefer
    # the X-API-Key protected /api/v1/... webhooks for new integrations.
    agd_webhook_token: str = ""

    # Local account login (accounts + sessions + optional TOTP 2FA). On a fresh
    # install the first browser visit forces creation of an owner account, then
    # requires login. Edge identity (Cloudflare Access / trusted proxy) still
    # satisfies the gate without a local account. Set AGD_DISABLE_LOGIN=true to
    # skip browser login entirely (dev/localhost only); it is logged loudly.
    agd_disable_login: bool = False
    agd_session_ttl_days: int = 14        # sliding session lifetime
    agd_session_absolute_days: int = 30   # hard cap regardless of activity
    agd_login_max_attempts: int = 8       # failures before lockout
    agd_login_lockout_minutes: int = 15
    # Password policy (composition). Enforced on setup, reset, and change.
    agd_password_min_length: int = 12
    agd_password_require_upper: bool = True
    agd_password_require_lower: bool = True
    agd_password_require_number: bool = True
    agd_password_require_symbol: bool = True
    agd_password_reset_ttl_minutes: int = 30  # password-reset link lifetime

    # SMTP for transactional email (password reset). When host is unset, the
    # reset link is written to the server log instead of being emailed, so a
    # self-hosted operator without SMTP can still recover access.
    agd_smtp_host: str = ""
    agd_smtp_port: int = 587
    agd_smtp_user: str = ""
    agd_smtp_password: str = ""
    agd_smtp_from: str = ""               # defaults to smtp_user when blank
    agd_smtp_starttls: bool = True
    # Public base URL used to build links in emails (e.g. https://app.example.com).
    # Falls back to agd_public_host, then the request origin.
    agd_public_url: str = ""

    # Security hardening knobs (all default to prior behavior).
    # CORS allowed origins: "*" (default, any origin) or a comma-separated list
    # of exact origins, e.g. "https://app.example.com,https://admin.example.com".
    agd_cors_origins: str = "*"
    # Max request body size in bytes (Content-Length based). Default 25 MiB.
    agd_max_request_bytes: int = 26214400
    # Optional Content-Security-Policy header value. Empty = no CSP header.
    # Opt-in because the editors (Monaco/CodeMirror via CDN) and the music
    # embeds accept arbitrary origins; a wrong policy breaks those features.
    # A recommended starting policy is documented in .env.example.
    agd_csp: str = ""

    # OpenTelemetry observability (embedded OTLP/HTTP receiver). n8n exports
    # workflow + node spans straight to AgeniusDesk; the Observability view
    # renders them as a trace waterfall. Off by default. The ingest endpoint is
    # a machine-ingest surface: gate it with AGD_OTEL_TOKEN before exposing the
    # port publicly (unset = open, trusted-LAN only, same posture as the legacy
    # webhooks). Retention is bounded both ways — oldest pruned past either cap.
    agd_otel_enabled: bool = False
    agd_otel_token: str = ""
    agd_otel_retention_hours: int = 72       # age-based span pruning
    agd_otel_max_spans: int = 500000         # hard row cap, oldest pruned first
    # Cost observability: how often to refresh the LLM price book from OpenRouter's
    # public models API. The fetched table is cached to data/price_book.json with a
    # last-good fallback; operator overrides and a bundled default layer over it.
    agd_pricebook_refresh_hours: int = 24

    model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}


settings = Settings()


def harden_file_permissions() -> None:
    """Best-effort chmod 600 on sensitive data files.

    Effective on the Linux container/host where the data volume lives; a no-op
    or partial on Windows dev. Called once at startup so secrets, encrypted
    config, and the master key are not world/group readable.
    """
    for f in (SECRET_KEY_FILE, SECRETS_FILE, CONFIG_FILE, SECRET_SCOPE_FILE, USERS_FILE, DB_FILE):
        try:
            if f.exists():
                os.chmod(f, 0o600)
        except OSError as e:
            logger.debug("could not chmod %s: %s", f, e)


def _get_secret_key() -> str:
    """Return a stable SECRET_KEY. Priority: env var > persisted file > generate + persist."""
    from_env = os.environ.get("SECRET_KEY")
    if from_env:
        return from_env
    if SECRET_KEY_FILE.exists():
        return SECRET_KEY_FILE.read_text().strip()
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    new_key = secrets.token_urlsafe(32)
    SECRET_KEY_FILE.write_text(new_key)
    try:
        SECRET_KEY_FILE.chmod(0o600)
    except OSError:
        pass
    logger.warning(
        "Generated new SECRET_KEY at %s. Back this file up with your data/ directory; "
        "losing it makes encrypted values unrecoverable.",
        SECRET_KEY_FILE,
    )
    return new_key


def load_config() -> dict:
    """Load saved config from data/config.json."""
    if CONFIG_FILE.exists():
        return json.loads(CONFIG_FILE.read_text())
    return {}


def save_config(config: dict) -> None:
    """Save config to data/config.json."""
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    CONFIG_FILE.write_text(json.dumps(config, indent=2))


# ── Multi-instance support ───────────────────────────────────────────────────


def get_instances() -> list[dict]:
    """Get all configured n8n instances. Migrates legacy single-instance config."""
    config = load_config()
    instances = config.get("instances", [])

    # Migrate legacy single-instance config
    if not instances and config.get("n8n_url") and config.get("n8n_api_key"):
        inst = {
            "id": secrets.token_hex(8),
            "name": config["n8n_url"].split("//")[-1].split("/")[0].split(".")[0],
            "url": config["n8n_url"],
            "api_key": config["n8n_api_key"],
            "color": "#ff6d5a",
        }
        config["instances"] = [inst]
        config["active_instance"] = inst["id"]
        # Clean up legacy keys
        config.pop("n8n_url", None)
        config.pop("n8n_api_key", None)
        save_config(config)
        return [inst]

    return instances


def get_active_instance_id() -> str:
    """Get the active instance ID."""
    return load_config().get("active_instance", "")


def get_active_instance() -> Optional[dict]:
    """Get the active n8n instance config."""
    active_id = get_active_instance_id()
    for inst in get_instances():
        if inst["id"] == active_id:
            return inst
    # Fall back to first instance
    instances = get_instances()
    return instances[0] if instances else None


# ── Secret encryption ────────────────────────────────────────────────────────
#
# Stored values use one of these forms:
#   fernet:<token>  authenticated encryption (AES-128-CBC + HMAC-SHA256) via
#                   cryptography.fernet. Current format.
#   $NAME           env var / secrets.json lookup. Never encrypted at rest
#                   because the secret's real value lives elsewhere.
#   enc:<base64>    legacy homegrown XOR-stream format. Unauthenticated, and
#                   broken if SECRET_KEY ever rotated. Kept for decryption so
#                   old installs don't silently drop data; on read we try to
#                   decrypt and log a migration warning.
#   (anything else) plaintext fallback (legacy / pre-encryption installs).

_FERNET_PREFIX = "fernet:"
_LEGACY_PREFIX = "enc:"


def _fernet() -> Fernet:
    """Build a Fernet instance from SECRET_KEY. SHA-256 the key to a 32-byte
    buffer and urlsafe-b64-encode so any SECRET_KEY string is accepted."""
    raw = hashlib.sha256(_get_secret_key().encode()).digest()
    return Fernet(base64.urlsafe_b64encode(raw))


def encrypt_value(plaintext: str) -> str:
    """Encrypt a value for storage. Returns 'fernet:<token>' string.

    Pass-through for empty strings, $NAME refs, and already-encrypted values
    (both new fernet: and legacy enc:) so callers can re-save without double-
    wrapping.
    """
    if not plaintext:
        return plaintext
    if plaintext.startswith("$") or plaintext.startswith(_FERNET_PREFIX) or plaintext.startswith(_LEGACY_PREFIX):
        return plaintext
    token = _fernet().encrypt(plaintext.encode()).decode()
    return _FERNET_PREFIX + token


def _is_compound(entry: Any) -> bool:
    """True when a secrets.json entry is a compound (typed multi-field) secret."""
    return isinstance(entry, dict) and "type" in entry and isinstance(entry.get("fields"), dict)


def _resolve_secret_ref(name: str) -> str:
    """Resolve a $NAME or $NAME.field reference.

    Dotted form ($NAME.field) returns the decrypted value of that field on a
    compound secret. Bare $NAME on a compound secret returns a JSON string of
    its decrypted fields dict (use the dotted form in string contexts).
    """
    field: Optional[str] = None
    if "." in name:
        name, field = name.split(".", 1)

    # Env var wins for bare $NAME when it is set. For dotted refs the secrets
    # store is authoritative (an env var can only ever hold one string).
    if field is None:
        env_val = os.environ.get(name)
        if env_val:
            return env_val

    if SECRETS_FILE.exists():
        try:
            secrets_dict = json.loads(SECRETS_FILE.read_text())
        except Exception:
            secrets_dict = {}
        entry = secrets_dict.get(name)
        if entry is None:
            return f"{name}.{field}" if field else name
        if _is_compound(entry):
            fields = entry.get("fields", {})
            if field is not None:
                if field not in fields:
                    return ""
                return decrypt_value(fields[field])
            # Bare $NAME on a compound — decrypt all fields and return JSON.
            decrypted = {k: decrypt_value(v) for k, v in fields.items()}
            return json.dumps(decrypted)
        # Legacy string entry.
        if field is not None:
            # Dotted ref against a legacy string — no subfield exists.
            return ""
        return decrypt_value(entry)
    return f"{name}.{field}" if field else name


def get_secret_field(name: str, field: str) -> str:
    """Programmatic lookup: return the decrypted value of one field on a compound.

    Returns '' when the secret is missing, not compound, or missing the field.
    """
    if not SECRETS_FILE.exists():
        return ""
    try:
        secrets_dict = json.loads(SECRETS_FILE.read_text())
    except Exception:
        return ""
    entry = secrets_dict.get(name)
    if not _is_compound(entry):
        return ""
    enc = entry.get("fields", {}).get(field)
    if enc is None:
        return ""
    return decrypt_value(enc)


# ── Secrets store (data/secrets.json) ────────────────────────────────────────
#
# Lives in config.py (not the admin module) so config-layer helpers like
# promote_to_secret and migrate_inline_to_secrets can use it without circular
# imports. The admin router wraps these for HTTP.


def load_secrets() -> dict[str, Any]:
    """Load encrypted secrets store.

    Returns `{name: value}` where value is either an encrypted string (legacy
    single-value secret) or a compound dict `{type, fields:{name: encrypted}}`.
    """
    if SECRETS_FILE.exists():
        try:
            return json.loads(SECRETS_FILE.read_text())
        except Exception:
            logger.warning("secrets.json unreadable; treating as empty")
    return {}


def save_secrets(secrets_dict: dict[str, Any]) -> None:
    """Persist encrypted secrets store."""
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    SECRETS_FILE.write_text(json.dumps(secrets_dict, indent=2))


def load_secret_scopes() -> dict[str, list[str]]:
    """Load per-secret instance scope map. Missing file → empty dict."""
    if SECRET_SCOPE_FILE.exists():
        try:
            raw = json.loads(SECRET_SCOPE_FILE.read_text())
            # Sanitize: drop non-list values, coerce each to list[str].
            return {k: [str(x) for x in v] for k, v in raw.items() if isinstance(v, list)}
        except Exception:
            logger.warning("secret_scope.json unreadable; treating as empty")
    return {}


def save_secret_scopes(scopes: dict[str, list[str]]) -> None:
    """Persist the per-secret instance scope map."""
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    # Drop entries with empty lists to keep the file compact — empty == all.
    compact = {k: v for k, v in scopes.items() if v}
    SECRET_SCOPE_FILE.write_text(json.dumps(compact, indent=2))


def get_allowed_instances(name: str) -> list[str]:
    """Return the scope for one secret. Empty list = allowed on all instances."""
    return load_secret_scopes().get(name, [])


def is_secret_allowed_on_instance(name: str, instance_id: str) -> bool:
    """True when the secret may be mirrored to the given instance.

    SCOPE: n8n credential mirror only. `_resolve_secret_ref` ignores this
    function, so any module calling `decrypt_value("$NAME")` resolves the
    secret regardless of scope. Do not rely on this as a general security
    boundary; it gates only the n8n credential mirror in
    `backend/modules/n8n_credentials/router.py`.

    Unscoped secrets (empty/missing list) are allowed everywhere, preserving
    the pre-scope default.
    """
    scope = get_allowed_instances(name)
    return not scope or instance_id in scope


def _slugify(s: str) -> str:
    """Slugify to UPPER_SNAKE_CASE for secret names.
    Replaces non-alphanumeric runs with a single underscore. Strips leading/
    trailing underscores. Empty input returns empty string.
    """
    if not s:
        return ""
    s = re.sub(r"[^A-Za-z0-9]+", "_", s).strip("_").upper()
    # Names cannot start with a digit (mirror shell env var convention)
    if s and s[0].isdigit():
        s = "_" + s
    return s


def promote_to_secret(value: str, prefix: str, context: str = "") -> str:
    """Move a raw (or fernet-encrypted) value into the secrets store.

    Pass-through for empty, None, or values that are already $VAR refs. If the
    incoming value is fernet: or enc:, it is decrypted first so the secrets
    store owns the plaintext.

    Generates name "<PREFIX>[_<CONTEXT>]" (both slugified). If that collides,
    appends _2, _3, ... until unique. Encrypts for storage and also sets the
    value on os.environ so it resolves in the current process.

    Returns the "$NAME" reference string.
    """
    if not value:
        return value
    if value.startswith("$"):
        return value

    # If caller hands us an already-encrypted value, unwrap first so we store
    # the real plaintext in the secrets store.
    if value.startswith(_FERNET_PREFIX) or value.startswith(_LEGACY_PREFIX):
        value = decrypt_value(value)
        if value.startswith(_FERNET_PREFIX) or value.startswith(_LEGACY_PREFIX):
            # decrypt failed, don't promote — return original to avoid data loss
            logger.error("promote_to_secret: could not decrypt incoming value, aborting")
            return value

    prefix_slug = _slugify(prefix) or "SECRET"
    context_slug = _slugify(context)
    base_name = f"{prefix_slug}_{context_slug}" if context_slug else prefix_slug

    existing = load_secrets()
    name = base_name
    i = 2
    while name in existing:
        # Promotion only produces legacy string secrets; skip compound entries
        # (compound names are picked with intent and cannot be reused).
        current = existing[name]
        if not _is_compound(current):
            try:
                if decrypt_value(current) == value:
                    os.environ[name] = value
                    return f"${name}"
            except Exception:
                pass
        name = f"{base_name}_{i}"
        i += 1

    existing[name] = encrypt_value(value)
    save_secrets(existing)
    os.environ[name] = value
    logger.info("Promoted value to secret store as $%s", name)
    return f"${name}"


def _find_mcp_credential_keys(server: dict) -> list[str]:
    """Return the credential-style keys on an mcp_server dict.
    Covers common variants: auth_token, api_key, token.
    """
    return [k for k in ("auth_token", "api_key", "token") if k in server]


def _looks_encrypted(v: str) -> bool:
    return isinstance(v, str) and (v.startswith(_FERNET_PREFIX) or v.startswith(_LEGACY_PREFIX))


def migrate_inline_to_secrets() -> None:
    """One-time migration: move inline-encrypted credentials into secrets.json.

    Scans known paths on data/config.json and promotes each non-empty, non-$VAR
    value into the secrets store. Idempotent: writes a marker under
    config["_migrations"] so it only runs once per install.

    Migrated paths:
      instances[].api_key              -> $N8N_KEY_<INSTANCE_NAME>
      assistant.api_key                -> $<PROVIDER>_KEY
      assistant.qdrant_url             -> $QDRANT_URL
      mcp_servers[].auth_token|api_key|token -> $MCP_TOKEN_<SERVER_NAME>
    """
    config = load_config()
    if not config:
        return
    marker = config.get("_migrations", {}) or {}
    if marker.get("inline_to_secrets_v1"):
        return

    changed = False

    # 1) n8n instances
    for idx, inst in enumerate(config.get("instances", []) or []):
        raw = inst.get("api_key", "")
        if not raw or raw.startswith("$"):
            continue
        if not _looks_encrypted(raw):
            # Plaintext. Still worth promoting — operational win.
            pass
        ref = promote_to_secret(raw, prefix="N8N_KEY", context=inst.get("name", f"inst{idx}"))
        if ref and ref != raw:
            inst["api_key"] = ref
            logger.info("Migrated instances[%d].api_key to %s", idx, ref)
            changed = True

    # 2) assistant.api_key + assistant.qdrant_url
    assistant = config.get("assistant") or {}
    provider = (assistant.get("provider") or "").lower()
    provider_prefix_map = {
        "anthropic": "ANTHROPIC_KEY",
        "openai": "OPEN_AI_KEY",
        "openrouter": "OPEN_ROUTER_KEY",
        "ollama": "OLLAMA_KEY",
    }
    api_key = assistant.get("api_key", "")
    if api_key and not api_key.startswith("$"):
        prefix = provider_prefix_map.get(provider, "LLM_KEY")
        # Prefix is already the full name for the LLM case (no context needed).
        ref = promote_to_secret(api_key, prefix=prefix)
        if ref and ref != api_key:
            assistant["api_key"] = ref
            logger.info("Migrated assistant.api_key to %s", ref)
            changed = True

    qdrant_url = assistant.get("qdrant_url", "")
    if qdrant_url and not qdrant_url.startswith("$") and _looks_encrypted(qdrant_url):
        ref = promote_to_secret(qdrant_url, prefix="QDRANT_URL")
        if ref and ref != qdrant_url:
            assistant["qdrant_url"] = ref
            logger.info("Migrated assistant.qdrant_url to %s", ref)
            changed = True

    if assistant:
        config["assistant"] = assistant

    # 3) MCP servers
    for idx, server in enumerate(config.get("mcp_servers", []) or []):
        for field in _find_mcp_credential_keys(server):
            raw = server.get(field, "")
            if not raw or raw.startswith("$"):
                continue
            ref = promote_to_secret(raw, prefix="MCP_TOKEN", context=server.get("name", f"server{idx}"))
            if ref and ref != raw:
                server[field] = ref
                logger.info("Migrated mcp_servers[%d].%s to %s", idx, field, ref)
                changed = True

    # Mark migration complete even if nothing moved — prevents re-scans.
    marker["inline_to_secrets_v1"] = True
    config["_migrations"] = marker

    if changed:
        save_config(config)
        logger.info("Inline-to-secrets migration complete (changes saved)")
    else:
        # Still persist the marker so we skip next time.
        save_config(config)
        logger.info("Inline-to-secrets migration complete (no changes needed)")


def _decrypt_legacy(stored: str) -> str:
    """Best-effort decrypt of old enc: values for migration. Uses the pre-Fernet
    derivation (PBKDF2 from SECRET_KEY). Returns the ciphertext unchanged on
    failure so callers can surface the breakage rather than silently corrupt."""
    try:
        key = hashlib.pbkdf2_hmac("sha256", _get_secret_key().encode(), b"n8n-flow-dashboard", 100_000)
        payload = base64.urlsafe_b64decode(stored[len(_LEGACY_PREFIX):])
        salt, encrypted = payload[:16], payload[16:]
        stream = hashlib.pbkdf2_hmac("sha256", key, salt, 1, dklen=len(encrypted))
        return bytes(a ^ b for a, b in zip(encrypted, stream)).decode()
    except Exception:
        return stored


def decrypt_value(stored: str) -> str:
    """Decrypt a stored value. Handles plaintext, $VAR refs, fernet: and legacy enc:."""
    if not stored:
        return stored
    if stored.startswith("$"):
        return _resolve_secret_ref(stored[1:])
    if stored.startswith(_FERNET_PREFIX):
        try:
            return _fernet().decrypt(stored[len(_FERNET_PREFIX):].encode()).decode()
        except InvalidToken:
            logger.error(
                "fernet decrypt failed: SECRET_KEY does not match the one used to encrypt. "
                "Restore data/.secret_key from backup or re-enter this secret."
            )
            return stored
    if stored.startswith(_LEGACY_PREFIX):
        logger.warning(
            "Legacy enc: value encountered. This format is deprecated and unauthenticated. "
            "Re-save the value to migrate to fernet: format."
        )
        return _decrypt_legacy(stored)
    return stored  # Plaintext (pre-encryption / legacy)


def migrate_legacy_enc_to_fernet() -> None:
    """One-time sweep: re-encrypt any legacy `enc:` secret to authenticated
    `fernet:`. Idempotent (no-op once none remain). Best-effort: a value that
    cannot be decrypted (e.g. SECRET_KEY changed) is left as-is, not corrupted,
    since _decrypt_legacy returns the ciphertext unchanged on failure.
    """
    secrets = load_secrets()
    if not secrets:
        return
    changed = False

    def _reencrypt(v: Any) -> Any:
        nonlocal changed
        if isinstance(v, str) and v.startswith(_LEGACY_PREFIX):
            plain = _decrypt_legacy(v)
            if plain and not plain.startswith(_LEGACY_PREFIX):
                changed = True
                return encrypt_value(plain)
        return v

    for name, entry in list(secrets.items()):
        if isinstance(entry, dict) and isinstance(entry.get("fields"), dict):
            entry["fields"] = {k: _reencrypt(val) for k, val in entry["fields"].items()}
        else:
            secrets[name] = _reencrypt(entry)

    if changed:
        save_secrets(secrets)
        logger.info("Re-encrypted legacy enc: secret(s) to fernet:")


def get_n8n_url() -> str:
    """Get n8n URL for the active instance."""
    inst = get_active_instance()
    return decrypt_value(inst["url"]) if inst else ""


def get_n8n_api_key() -> str:
    """Get n8n API key for the active instance (decrypts if encrypted)."""
    inst = get_active_instance()
    return decrypt_value(inst["api_key"]) if inst else ""


def is_configured() -> bool:
    """Check if at least one n8n instance is configured."""
    return bool(get_instances())


def is_setup_complete() -> bool:
    """Check whether first-run onboarding has been completed."""
    config = load_config()
    return bool(config.get("setup_complete") or config.get("instances"))


def set_setup_complete(value: bool = True) -> None:
    """Persist the first-run onboarding completion flag."""
    config = load_config()
    config["setup_complete"] = bool(value)
    save_config(config)


def get_active_theme() -> str:
    """Get the active theme name from config."""
    return load_config().get("theme", "dark")


def add_instance(instance: dict) -> None:
    """Add a new n8n instance. Encrypts the API key and owner_password before storing."""
    config = load_config()
    instances = config.get("instances", [])
    instance["api_key"] = encrypt_value(instance["api_key"])
    if instance.get("owner_password"):
        instance["owner_password"] = encrypt_value(instance["owner_password"])
    instances.append(instance)
    config["instances"] = instances
    # Set as active if it's the first one
    if not config.get("active_instance"):
        config["active_instance"] = instance["id"]
    config["setup_complete"] = True
    save_config(config)


def update_instance(instance_id: str, updates: dict) -> bool:
    """Update an existing instance. Returns True if found."""
    config = load_config()
    for inst in config.get("instances", []):
        if inst["id"] == instance_id:
            # Encrypt API key if provided and non-empty
            if updates.get("api_key"):
                updates["api_key"] = encrypt_value(updates["api_key"])
            else:
                updates.pop("api_key", None)  # Keep existing key if not provided
            # Same treatment for owner_password — blank means "keep existing"
            if "owner_password" in updates:
                if updates["owner_password"]:
                    updates["owner_password"] = encrypt_value(updates["owner_password"])
                else:
                    updates.pop("owner_password", None)
            # owner_email and login_url: blank == "keep existing" so partial
            # updates (e.g. scripts/fix-login-urls.sh) don't clobber data.
            for field in ("owner_email", "login_url"):
                if field in updates and not updates[field]:
                    updates.pop(field, None)
            inst.update(updates)
            save_config(config)
            return True
    return False


def remove_instance(instance_id: str) -> bool:
    """Remove an instance. Returns True if found."""
    config = load_config()
    instances = config.get("instances", [])
    before = len(instances)
    config["instances"] = [i for i in instances if i["id"] != instance_id]
    if len(config["instances"]) == before:
        return False
    # If we removed the active instance, switch to first available
    if config.get("active_instance") == instance_id:
        config["active_instance"] = config["instances"][0]["id"] if config["instances"] else ""
    save_config(config)
    return True


def set_active_instance(instance_id: str) -> bool:
    """Set the active instance. Returns True if found."""
    instances = get_instances()
    if any(i["id"] == instance_id for i in instances):
        config = load_config()
        config["active_instance"] = instance_id
        save_config(config)
        return True
    return False
