from __future__ import annotations

import contextlib
import os
import posixpath
import re
from collections.abc import Mapping, Sequence
from typing import Any

from ansible.errors import AnsibleError

from utils.manager.value_generator import ValueGenerator
from utils.templating.expr import (
    find_top_level_op,
    is_paren_wrapped,
    split_list_items,
    split_top_level,
)

try:
    from ansible._internal._datatag._tags import TrustedAsTemplate
except Exception:
    TrustedAsTemplate = None


def to_plain(obj: Any) -> Any:
    """Convert Ansible/Jinja proxy types into plain Python so PyYAML can serialize."""

    if obj is None:
        return None

    if isinstance(obj, str):
        return str(obj)

    if isinstance(obj, bool):
        return bool(obj)

    if isinstance(obj, int):
        return int(obj)

    if isinstance(obj, float):
        return float(obj)

    if isinstance(obj, Mapping):
        return {str(to_plain(k)): to_plain(v) for k, v in obj.items()}

    if isinstance(obj, Sequence) and not isinstance(obj, (str, bytes, bytearray)):
        return [to_plain(x) for x in obj]

    return str(obj)


def _trust_as_template(s: str) -> Any:
    """Tag a string as trusted for Jinja templating in Ansible 2.19+.

    Ansible's templar refuses to render strings that aren't tagged via
    TrustedAsTemplate. YAML loaded directly with yaml.safe_load lacks this
    tag, so embedded {{ ... }} returns unchanged. Tagging restores rendering.
    """
    if TrustedAsTemplate is None or not isinstance(s, str):
        return s
    try:
        return TrustedAsTemplate().tag(s)
    except Exception:
        return s


_RE_LOOKUP_ENV_HEAD = re.compile(
    r"""^lookup\(\s*['"]env['"]\s*,\s*['"]([^'"]+)['"]\s*\)\s*""",
    re.IGNORECASE,
)

_RE_ANY_LOOKUP = re.compile(r"""\blookup\s*\(""", re.IGNORECASE)

_RE_VARPATH = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$")
_RE_INT_LITERAL = re.compile(r"^-?\d+$")
_RE_FLOAT_LITERAL = re.compile(r"^-?\d+\.\d+$")
_RE_JINJA_BLOCK = re.compile(r"\{\{\s*(.*?)\s*\}\}", re.DOTALL)


def _eval_list_literal(head: str, variables: dict) -> list[str]:
    """
    Evaluate a minimal Jinja list literal like:
      [ DIR_BIN, 'ca-inject' ]

    Items supported:
      - quoted strings ('x' / "x")
      - varpaths (FOO / FOO.bar)

    This is intentionally minimal and safe for fallback rendering.
    """
    h = head.strip()
    if not (h.startswith("[") and h.endswith("]")):
        raise ValueError("not a list literal")

    inner = h[1:-1].strip()
    if not inner:
        return []

    out: list[str] = []
    for tok in split_list_items(inner):
        t = tok.strip()
        if not t:
            continue

        if t.startswith(("'", '"')) and t.endswith(t[0]) and len(t) >= 2:
            out.append(t[1:-1])
            continue

        if not _RE_VARPATH.match(t):
            raise ValueError(f"unsupported expression: {head}")

        try:
            v = _get_by_path(variables, t)
        except KeyError:
            v = None

        out.append("" if v is None else str(v))

    return out


def _get_by_path(variables: dict, path: str) -> Any:
    cur: Any = variables
    for part in path.split("."):
        if not isinstance(cur, dict) or part not in cur:
            raise KeyError(path)
        cur = cur[part]
    return cur


def _apply_filter(value: Any, filt: str) -> Any:
    f = filt.strip()

    if f == "lower":
        return str(value).lower()

    if f == "upper":
        return str(value).upper()

    if f == "path_join":
        if not isinstance(value, (list, tuple)):
            return value
        parts = [str(x) for x in value if str(x) != ""]
        return posixpath.join(*parts) if parts else ""

    if f == "strong_password":
        try:
            length = int(value)
        except (TypeError, ValueError):
            length = 32
        return ValueGenerator().generate_strong_password(length)

    if f.startswith("default(") and f.endswith(")"):
        inner = f[len("default(") : -1].strip()

        default_val: Any = ""
        if inner.startswith(("'", '"')):
            q = inner[0]
            end = inner.find(q, 1)
            default_val = inner[1:end] if end != -1 else ""
        else:
            default_val = inner.split(",", 1)[0].strip()

        if value is None:
            return default_val
        if str(value) == "":
            return default_val
        return value

    return value


def _eval_condition(cond: str, variables: dict) -> bool:
    """Evaluate a minimal boolean condition: ``A == B`` / ``A != B`` / truthy."""
    for op, test in (("==", str.__eq__), ("!=", str.__ne__)):
        idx = find_top_level_op(cond, op)
        if idx != -1:
            left = _fallback_eval_expr(cond[:idx], variables)
            right = _fallback_eval_expr(cond[idx + len(op) :], variables)
            return bool(test(left, right))
    val = _fallback_eval_expr(cond, variables)
    return val not in ("", "False", "false", "0", "None")


def _fallback_eval_expr(expr: str, variables: dict) -> str:
    """
    Evaluate a single Jinja expression (no surrounding {{ }}).

    Supported subset (SAFE fallback only):
      - lookup('env','NAME')
      - VAR / VAR.path
      - list literal: [ VAR, 'literal', ... ]
      - string concatenation: 'a' ~ VAR ~ 'b'
      - inline conditional: A if COND else B  (COND: X == 'y' / X != 'y')
      - filters: | lower, | upper, | default('x', true), | path_join

    Any other lookup(...) must NOT be handled here.
    """
    expr = expr.strip()

    if_idx = find_top_level_op(expr, " if ")
    else_idx = find_top_level_op(expr, " else ")
    if if_idx != -1 and else_idx != -1 and if_idx < else_idx:
        a = expr[:if_idx]
        cond = expr[if_idx + len(" if ") : else_idx]
        b = expr[else_idx + len(" else ") :]
        chosen = a if _eval_condition(cond, variables) else b
        return _fallback_eval_expr(chosen, variables)

    if is_paren_wrapped(expr):
        return _fallback_eval_expr(expr[1:-1], variables)

    tilde_parts = split_top_level(expr, "~")
    if len(tilde_parts) > 1:
        return "".join(_fallback_eval_expr(p, variables) for p in tilde_parts)

    if len(expr) >= 2 and expr[0] in "'\"" and expr[-1] == expr[0]:
        return expr[1:-1]

    parts = [p.strip() for p in expr.split("|")]
    head = parts[0].strip()

    m = _RE_LOOKUP_ENV_HEAD.match(head)
    if m:
        key = m.group(1)
        val: Any = os.environ.get(key)
        if val is None:
            val = os.environ.get(key.lower(), os.environ.get(key.upper()))
    elif head.startswith("[") and head.endswith("]"):
        val = _eval_list_literal(head, variables)
    elif _RE_INT_LITERAL.match(head):
        val = int(head)
    elif _RE_FLOAT_LITERAL.match(head):
        val = float(head)
    else:
        if not _RE_VARPATH.match(head):
            raise ValueError(f"unsupported expression: {head}")
        try:
            val = _get_by_path(variables, head)
        except KeyError:
            val = None

    for filt in parts[1:]:
        val = _apply_filter(val, filt)

    return "" if val is None else str(val)


def _fallback_render_embedded(s: str, variables: dict) -> str:
    def repl(m: re.Match) -> str:
        expr = (m.group(1) or "").strip()
        return _fallback_eval_expr(expr, variables)

    return _RE_JINJA_BLOCK.sub(repl, s)


def _contains_non_env_lookup(s: str) -> bool:
    """
    True if any embedded {{ ... }} contains lookup(...) that is NOT lookup('env', ...).

    IMPORTANT:
    - Allow lookup('env', ...) even when followed by filters, e.g.
      {{ lookup('env','domain') | default('x', true) }}
    """
    for m in _RE_JINJA_BLOCK.finditer(s):
        expr = (m.group(1) or "").strip()
        if _RE_ANY_LOOKUP.search(expr):
            if _RE_LOOKUP_ENV_HEAD.match(expr):
                continue
            return True
    return False


def _set_templar_var(templar: Any, name: str, value: Any) -> tuple[bool, Any]:
    """
    Best-effort setter for templar flags across Ansible versions.
    Returns (changed, previous_value).
    """
    if templar is None or not hasattr(templar, name):
        return False, None
    try:
        prev = getattr(templar, name)
        setattr(templar, name, value)
    except Exception:
        return False, None
    return True, prev


def _templar_render_preserve_type(templar: Any, s: str, variables: dict) -> Any:
    """
    Type-preserving render for the case where the entire string is a
    single Jinja expression. Unlike `_templar_render_best_effort` (which
    forces ``str(rendered)``), this returns templar's native output —
    so a list/dict-returning ``lookup(...)`` keeps its type.

    Caller MUST pre-check the input shape; this function does no shape
    detection itself. Returns ``None`` if templar is unavailable or
    raises during render — caller is expected to fall back to the
    string-coerced path in that case.
    """
    if templar is None:
        return None

    disable_changed_1, prev_disable_1 = _set_templar_var(
        templar, "disable_lookups", False
    )
    disable_changed_2, prev_disable_2 = _set_templar_var(
        templar, "_disable_lookups", False
    )

    prev_avail: Any | None = None
    if hasattr(templar, "available_variables"):
        try:
            prev_avail = templar.available_variables
            merged_avail: dict = dict(prev_avail) if prev_avail else {}
            if variables:
                merged_avail.update(variables)
            templar.available_variables = merged_avail
        except Exception:
            prev_avail = None

    rendered: Any = None
    try:
        try:
            rendered = templar.template(_trust_as_template(s), fail_on_undefined=True)
        except TypeError:
            rendered = templar.template(_trust_as_template(s))
        except Exception:
            rendered = None
    finally:
        if prev_avail is not None and hasattr(templar, "available_variables"):
            with contextlib.suppress(Exception):
                templar.available_variables = prev_avail
        if disable_changed_2:
            with contextlib.suppress(Exception):
                templar._disable_lookups = prev_disable_2
        if disable_changed_1:
            with contextlib.suppress(Exception):
                templar.disable_lookups = prev_disable_1

    return rendered


def _templar_render_best_effort(templar: Any, s: str, variables: dict) -> str:
    """
    Render with Ansible templar across versions.

    Policy:
    - Always try templar first (so ALL lookup(...) can be evaluated properly).
    - If templar returns unchanged while Jinja exists:
        - If string contains non-env lookup(...): DO NOT fallback (leave as-is)
        - Else: fallback is allowed (env + varpaths + simple filters)
    """
    if templar is None:
        return _fallback_render_embedded(s, variables)

    prev_avail: Any | None = None

    disable_changed_1, prev_disable_1 = _set_templar_var(
        templar, "disable_lookups", False
    )
    disable_changed_2, prev_disable_2 = _set_templar_var(
        templar, "_disable_lookups", False
    )

    if hasattr(templar, "available_variables"):
        try:
            prev_avail = templar.available_variables
            merged_avail: dict = dict(prev_avail) if prev_avail else {}
            if variables:
                merged_avail.update(variables)
            templar.available_variables = merged_avail
        except Exception:
            prev_avail = None

    rendered: Any = s
    trusted_input = _trust_as_template(s)
    try:
        try:
            rendered = templar.template(trusted_input, fail_on_undefined=True)
        except TypeError:
            rendered = templar.template(trusted_input)
        except Exception:
            rendered = s
    finally:
        if prev_avail is not None and hasattr(templar, "available_variables"):
            with contextlib.suppress(Exception):
                templar.available_variables = prev_avail

        if disable_changed_2:
            with contextlib.suppress(Exception):
                templar._disable_lookups = prev_disable_2
        if disable_changed_1:
            with contextlib.suppress(Exception):
                templar.disable_lookups = prev_disable_1

    out_s = "" if rendered is None else str(rendered)

    if out_s.strip() == s.strip() and ("{{" in s or "{%" in s):
        if _contains_non_env_lookup(s):
            return out_s

        return _fallback_render_embedded(s, variables)

    return out_s


def render_ansible_strict(
    *,
    templar: Any,
    raw: Any,
    var_name: str,
    err_prefix: str,
    variables: dict,
    max_rounds: int = 6,
) -> str:
    """
    Strict rendering helper for lookup/filter plugins.

    - Renders via Ansible templar when possible (lookup(), filters, vars).
    - Automatic fallback is enabled for a SAFE subset (env lookup + varpaths + simple filters)
      only when templar can't/won't render and NO non-env lookup(...) is present.
    - Re-renders multiple rounds because intermediate results can still contain Jinja.
    - Hard-fails if output is empty or still contains unresolved Jinja markers.
    """
    if raw is None:
        raise AnsibleError(f"{err_prefix}: {var_name} resolved to None")

    s = str(raw)

    out = s
    for _ in range(max_rounds):
        if ("{{" not in out) and ("{%" not in out):
            break
        out2 = _templar_render_best_effort(templar, out, variables)
        if out2 == out:
            break
        out = out2

    out = "" if out is None else str(out).strip()
    if not out:
        raise AnsibleError(
            f"{err_prefix}: {var_name} rendered to empty string. Raw: {s}"
        )

    if ("{{" in out) or ("{%" in out):
        raise AnsibleError(
            f"{err_prefix}: {var_name} still contains unresolved Jinja. Rendered: {out}. Raw: {s}"
        )

    return out
