"""Cross-cutting helpers for the `utils.cache.*` package.

Holds primitives that the domain modules (`users`, `applications`,
`domains`) all need but no domain owns: filesystem constants, YAML
loaders, deep-merge, cache-key + content-fingerprint signatures,
templar-render machinery, and the cross-domain re-entry guard.

Stays import-cheap so that `utils.cache.applications` (consumed on
GitHub Actions runner hosts that ship without ansible — see CI runs
24934007615 / 24935979190) can pull this module without dragging
ansible in. The single ansible-coupled symbol exposed from here is
`_render_with_templar`, which lazy-imports `utils.templating.ansible`
only when actually invoked.
"""

from __future__ import annotations

import copy
import hashlib
import os
import threading
from collections.abc import Mapping
from pathlib import Path
from typing import Any

from plugins.filter.merge.with_defaults import (
    merge_with_defaults,  # noqa: F401  re-exported
)
from utils.paths import FILE_TOKENS

from . import PROJECT_ROOT, ROLES_DIR  # noqa: F401

_TOKENS_DIGESTS: dict[tuple, str] = {}

try:
    from ansible.parsing.vault import EncryptedString as _AnsibleEncryptedString
except Exception:
    _AnsibleEncryptedString = None


def _decrypt_ansible_encrypted_strings(value: Any) -> Any:
    """Recursively convert Ansible EncryptedString values to plaintext str.

    Ansible 2.19+ refuses to store EncryptedString as an intermediate variable
    during task arg finalization, so decrypt at the lookup boundary.
    """
    if _AnsibleEncryptedString is not None and isinstance(
        value, _AnsibleEncryptedString
    ):
        try:
            return str(value)
        except Exception:
            return value
    if isinstance(value, Mapping):
        return {k: _decrypt_ansible_encrypted_strings(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_decrypt_ansible_encrypted_strings(v) for v in value]
    if isinstance(value, tuple):
        return tuple(_decrypt_ansible_encrypted_strings(v) for v in value)
    return value


DEFAULT_TOKENS_FILE = FILE_TOKENS


_RENDER_GUARD = threading.local()


def _cache_key(roles_dir: Path) -> str:
    return str(roles_dir.resolve())


def _fingerprint_mapping(obj: Any) -> str:
    """Cheap-ish content fingerprint for cache keying.

    Ansible 2.19+ composes a fresh `variables` mapping per task via
    VariableManager.get_vars() and — empirically — often reconstructs the
    inventory-level `applications`/`users` dicts too, so keying on `id(obj)`
    misses the cache across tasks. A content fingerprint hits across tasks
    whenever the inventory payload is unchanged.

    repr-based MD5 over the mapping's sorted items.

    Exception: memoising the digest under ``id(obj)`` is unsound and MUST NOT
    be reintroduced. The memo would hold no reference to *obj*, so CPython
    recycles the address of a freed mapping and serves its digest for an
    unrelated live one — measured at 398/400 wrong digests for short-lived
    mappings, which silently defeats every cache keyed on this function.
    """
    if obj is None:
        return "0"
    try:
        import hashlib

        data = repr(sorted(obj.items())) if isinstance(obj, Mapping) else repr(obj)
        return hashlib.md5(
            data.encode("utf-8", errors="replace"),
            usedforsecurity=False,
        ).hexdigest()
    except Exception:
        return "unhashable"


def _stable_variables_signature(variables: Mapping[str, Any] | None) -> tuple:
    """Build a content-based cache signature from the subset of `variables`
    that influences the merged applications/users payload.

    `group_names` belongs in the key because a service flag may be written as
    `{{ '<role>' in group_names }}`, so two hosts of one play render different
    payloads from identical host_vars. Ansible forks its workers, so a payload
    rendered in the strategy's main process for one host is inherited by every
    later worker; without this component that payload answers for hosts whose
    groups say otherwise.

    See `_fingerprint_mapping` for why id()-only keys don't work reliably.
    """
    if not variables:
        return ("0", "0", "", "", "")
    groups = variables.get("group_names")
    return (
        _fingerprint_mapping(variables.get("applications")),
        _fingerprint_mapping(variables.get("users")),
        str(variables.get("DOMAIN_PRIMARY") or ""),
        str(variables.get("SYSTEM_EMAIL_DOMAIN") or ""),
        ",".join(sorted(str(group) for group in groups)) if groups else "",
    )


def _stat_identity(st: os.stat_result) -> tuple:
    return (st.st_ino, st.st_mtime_ns, st.st_size)


def _tokens_file_signature(path: Path) -> tuple:
    """Return ``(path, sha256 of the content)`` for the tokens file.

    Args:
        path: the tokens file.

    Returns:
        ``(str(path), "")`` when the file cannot be read.
    """
    try:
        identity = (str(path), *_stat_identity(path.stat()))
    except OSError:
        return (str(path), "")
    digest = _TOKENS_DIGESTS.get(identity)
    if digest is None:
        try:
            with path.open("rb") as handle:
                identity = (str(path), *_stat_identity(os.fstat(handle.fileno())))
                digest = hashlib.sha256(handle.read()).hexdigest()
        except OSError:
            return (str(path), "")
        _TOKENS_DIGESTS[identity] = digest
    return (str(path), digest)


def _revalidate_tokens_file(path: Path) -> None:
    """Open and close the tokens file so an NFS client refreshes its attributes.

    Args:
        path: the tokens file.
    """
    try:
        with path.open("rb"):
            pass
    except OSError:
        return


def _deep_merge(base: Any, override: Any) -> Any:
    if isinstance(base, Mapping) and isinstance(override, Mapping):
        merged = {k: copy.deepcopy(v) for k, v in base.items()}
        for key, value in override.items():
            merged[key] = _deep_merge(merged.get(key), value)
        return merged
    return copy.deepcopy(override)


def _resolve_roles_dir(*, roles_dir: str | os.PathLike[str] | None = None) -> Path:
    return Path(roles_dir).resolve() if roles_dir else ROLES_DIR.resolve()


def _resolve_override_mapping(
    variables: Mapping[str, Any] | None,
    key: str,
    templar: Any = None,
) -> dict[str, Any]:
    """Return runtime override mappings defensively.

    Nested `lookup('template', ...)` renders sometimes expose top-level lookup
    inputs like `applications`/`users` as non-mapping placeholder values instead
    of the original inventory overrides. Try to coerce via templar before
    falling back to aggregated defaults.
    """

    variables = variables or {}
    value = variables.get(key, {})
    if value is None:
        value = {}
    if not isinstance(value, Mapping) and templar is not None:
        try:
            rendered = templar.template(value, fail_on_undefined=False)
        except TypeError:
            try:
                rendered = templar.template(value)
            except Exception:
                rendered = value
        except Exception:
            rendered = value
        if isinstance(rendered, Mapping):
            value = rendered
    if not isinstance(value, Mapping):
        raw_key = {
            "applications": "_INFINITO_APPLICATIONS_RAW",
            "users": "_INFINITO_USERS_RAW",
        }.get(key)
        if raw_key:
            raw = variables.get(raw_key)
            if isinstance(raw, Mapping):
                value = raw
    if isinstance(value, Mapping):
        return dict(value)
    return {}


def _render_with_templar(
    value: Any,
    *,
    templar: Any,
    variables: dict[str, Any] | None,
    raw_applications: dict[str, Any] | None = None,
    raw_users: dict[str, Any] | None = None,
    max_rounds: int = 4,
) -> Any:
    if templar is None:
        return value

    from utils.templating.ansible import _templar_render_best_effort

    prev_templar_avail = getattr(templar, "available_variables", None)
    base_variables: dict[str, Any] = (
        dict(prev_templar_avail) if prev_templar_avail else {}
    )
    if variables:
        base_variables.update(variables)
    if raw_applications is not None:
        base_variables["_INFINITO_APPLICATIONS_RAW"] = raw_applications
    if raw_users is not None:
        base_variables["_INFINITO_USERS_RAW"] = raw_users

    def _render_scalar(raw: Any) -> Any:
        if isinstance(raw, str) and "{{" not in raw and "{%" not in raw:
            return raw
        data = copy.deepcopy(raw)
        if isinstance(data, str):
            stripped = data.strip()
            if (
                stripped.startswith("{{")
                and stripped.endswith("}}")
                and "{{" not in stripped[2:]
                and "{%" not in stripped
            ):
                from utils.templating.ansible import _templar_render_preserve_type

                try:
                    rendered = _templar_render_preserve_type(
                        templar, data, base_variables
                    )
                except Exception:
                    rendered = None
                if rendered is not None and not isinstance(rendered, str):
                    return _render_deep(rendered)
            for _ in range(max_rounds):
                try:
                    rendered = _templar_render_best_effort(
                        templar, data, base_variables
                    )
                except Exception:
                    return data
                if rendered == data:
                    break
                data = rendered
            return data

        for _ in range(max_rounds):
            try:
                rendered = templar.template(data, fail_on_undefined=False)
            except TypeError:
                rendered = templar.template(data)
            except Exception:
                return data
            if rendered == data:
                break
            data = rendered
        return data

    def _render_deep(raw: Any) -> Any:
        if isinstance(raw, Mapping):
            return {key: _render_deep(item) for key, item in raw.items()}
        if isinstance(raw, list):
            return [_render_deep(item) for item in raw]
        if isinstance(raw, tuple):
            return tuple(_render_deep(item) for item in raw)
        return _render_scalar(raw)

    try:
        if hasattr(templar, "available_variables"):
            templar.available_variables = base_variables
        data = _render_deep(value)
    finally:
        if hasattr(templar, "available_variables"):
            templar.available_variables = prev_templar_avail

    return _decrypt_ansible_encrypted_strings(data)


def _reset() -> None:
    """Clear the tokens-file digest memo."""
    _TOKENS_DIGESTS.clear()
