"""Secrets — named sensitive values a tenant stores once and uses by
reference, never by value.

    {{secret.NAME}}   in connector settings and Task node templates
    secret.NAME       as a Task node input's source

Rules this module enforces (with the Task engine, app/crud/tasks.py):

* The plaintext never leaves the server. It is stored encrypted
  (app/core/crypto.py), no API returns it, and it is decrypted only at the
  moment a connector or a Task node that calls out to another system uses it.
* It is only usable where it travels to another system: connector settings
  and the "outbound" Task nodes (SECRET_NODE_TYPES). A Data — Update, email,
  notification or Navigate node can't reference one — that would write the
  value somewhere people can read it. Nor can a Google or Firebase node, whose
  fields end up in documents and on people's screens: their sign-in is on the
  connector, which can hold secrets as usual.
* Anything coming back — a node's result, a run's trace and context, a code
  run's output — has every secret value replaced with MASK, so a value can't
  leak into records, pages or the designer by being echoed back.

Forms, Pages and Workflows run in the browser or show their data to people,
so they use secrets indirectly: through the Tasks and connectors they trigger.
"""

import json
import re
import uuid
from typing import Any

from cryptography.fernet import InvalidToken
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.crypto import decrypt_secret
from app.models.secret import Secret

NAME_PATTERN = r"^[A-Za-z_][A-Za-z0-9_]{0,99}$"
NAME_RE = re.compile(NAME_PATTERN)
# Any reference — a {{secret.NAME}} template or a bare secret.NAME path.
_REFERENCE = re.compile(r"(?<![\w.])secret\.([A-Za-z_][A-Za-z0-9_]*)")
_TEMPLATE = re.compile(r"\{\{\s*secret\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")

MASK = "••••••"
# Values shorter than this aren't scrubbed from results — masking every "1"
# or "abc" would mangle ordinary output. Secrets are long in practice.
SCRUB_MIN_LENGTH = 4

# Task nodes that send what they're given to another system. Only these may
# reference a secret (see the module docstring).
SECRET_NODE_TYPES = frozenset(
    {"api_rest", "api_soap", "api_mqtt", "sql_query", "sql_execute", "ftp_transfer", "rpa_run", "rpa_job_status", "payment_initiate", "custom_code"}
)


def referenced_names(value: Any) -> set[str]:
    """Every secret name `value` (any JSON-able structure) refers to."""
    text = value if isinstance(value, str) else json.dumps(value, default=str)
    return set(_REFERENCE.findall(text))


async def load_values(db: AsyncSession, tenant_id: uuid.UUID, names: set[str] | None = None) -> dict[str, str]:
    """Decrypted values — all of the tenant's, or just `names`. A value that
    can't be decrypted (the encryption key changed) is left out, so it reads
    as a missing secret rather than an error deep inside a run."""
    stmt = select(Secret.name, Secret.value_encrypted).where(Secret.tenant_id == tenant_id)
    if names is not None:
        if not names:
            return {}
        stmt = stmt.where(Secret.name.in_(names))
    values: dict[str, str] = {}
    for name, encrypted in (await db.execute(stmt)).all():
        try:
            values[name] = decrypt_secret(encrypted)
        except InvalidToken:
            continue
    return values


def fill_templates(value: Any, values: dict[str, str]) -> Any:
    """`value` with every {{secret.NAME}} replaced by its secret's value — an
    unknown name is left as written, so the system it's sent to rejects it
    visibly instead of getting an empty password."""
    if isinstance(value, str):
        return _TEMPLATE.sub(lambda m: values.get(m.group(1), m.group(0)), value)
    if isinstance(value, dict):
        return {k: fill_templates(v, values) for k, v in value.items()}
    if isinstance(value, list):
        return [fill_templates(v, values) for v in value]
    return value


async def connector_config(db: AsyncSession, tenant_id: uuid.UUID, connector: Any) -> dict:
    """A connector's settings with any {{secret.NAME}} filled in — a fresh
    dict, so the stored configuration itself is never touched."""
    config = connector.configuration or {}
    names = referenced_names(config)
    if not names:
        return config
    return fill_templates(config, await load_values(db, tenant_id, names))


class Scrubber:
    """Replaces every secret value found in strings (however deeply nested)
    with MASK — applied to whatever a run sends back."""

    def __init__(self, values: dict[str, str] | list[str]):
        plain = values.values() if isinstance(values, dict) else values
        self._values = sorted({v for v in plain if v and len(v) >= SCRUB_MIN_LENGTH}, key=len, reverse=True)

    def __bool__(self) -> bool:
        return bool(self._values)

    def __call__(self, value: Any) -> Any:
        if not self._values:
            return value
        if isinstance(value, str):
            for secret in self._values:
                if secret in value:
                    value = value.replace(secret, MASK)
            return value
        if isinstance(value, dict):
            return {k: self(v) for k, v in value.items()}
        if isinstance(value, (list, tuple)):
            return [self(v) for v in value]
        return value


def is_valid_name(name: str) -> bool:
    return bool(NAME_RE.fullmatch(name))
