"""Generic, runtime-enforced typed parameter validation — shared by the Task
Designer's node graph (app/crud/tasks.py) and the Workflow Designer's step
instances (app/routers/workflow_instances.py). Both designers let every
node/step declare input_params (name, type, source_field, required) and
output_params (name, type, optional source) — see app/schemas/automation.py::
NodeInputParam/NodeOutputParam and app/schemas/workflow.py's identical pair.
This module holds the one type-coercion/validation algorithm both use, so a
fix or tweak here doesn't need to be kept in sync by hand across the two
engines.

Every variable in the running context is reachable by path: a source_field
(or an output's source) may be a plain key ("recordId") or a dotted path into
objects and lists ("record.status", "item.vendor.id", "rows.0.amount",
"rows[0].amount") — see get_path.
"""

import re
from typing import Any

ParamResult = tuple[bool, str, dict[str, Any]]

_MISSING = object()
_TRUE = {"true", "yes", "y", "1", "on"}
_FALSE = {"false", "no", "n", "0", "off", ""}


def get_path(data: Any, path: str, default: Any = None) -> Any:
    """Looks `path` up in `data`: an exact key first (so a key that itself
    contains a dot still works), then a dotted walk through dicts and lists,
    where a list step is an index ("rows.0.name" / "rows[0].name")."""
    if not path:
        return default
    if isinstance(data, dict) and path in data:
        return data[path]
    parts = [p for p in re.split(r"\.|\[(\d+)\]", path) if p]
    current = data
    for part in parts:
        if isinstance(current, dict) and part in current:
            current = current[part]
        elif isinstance(current, (list, tuple)) and part.lstrip("-").isdigit() and -len(current) <= int(part) < len(current):
            current = current[int(part)]
        else:
            return default
    return current


def _coerce_type(value: Any, ptype: str) -> tuple[bool, Any]:
    if ptype == "any":
        return True, value
    if ptype == "int":
        if isinstance(value, bool):
            return False, None
        if isinstance(value, int):
            return True, value
        if isinstance(value, float) and value.is_integer():
            return True, int(value)
        try:
            return True, int(str(value).strip())
        except (ValueError, TypeError):
            return False, None
    if ptype == "number":
        if isinstance(value, bool):
            return False, None
        if isinstance(value, (int, float)):
            return True, value
        try:
            number = float(str(value).strip())
            return True, int(number) if number.is_integer() and "." not in str(value) else number
        except (ValueError, TypeError):
            return False, None
    if ptype == "boolean":
        if isinstance(value, bool):
            return True, value
        text = str(value).strip().lower()
        if text in _TRUE:
            return True, True
        if text in _FALSE:
            return True, False
        return False, None
    if ptype == "string":
        if isinstance(value, (str, int, float, bool)):
            return True, str(value)
        return False, None
    if ptype == "list":
        return (True, value) if isinstance(value, list) else (False, None)
    if ptype == "object":
        return (True, value) if isinstance(value, dict) else (False, None)
    return False, None


_PLACEHOLDER = re.compile(r"\{\{\s*([\w.\[\]-]+)\s*\}\}")


def render_value(template: str, context: dict) -> Any:
    """An input's fixed value: plain text, with each {{variable}} (a key or
    path, as in get_path) filled from the running context. A value that is just
    one placeholder keeps that variable's own type (a number stays a number);
    an unknown variable becomes empty text rather than staying as "{{...}}"."""
    whole = _PLACEHOLDER.fullmatch(template.strip())
    if whole:
        return get_path(context, whole.group(1))

    def fill(m: re.Match) -> str:
        value = get_path(context, m.group(1))
        return "" if value is None else str(value)

    return _PLACEHOLDER.sub(fill, template)


# "object"/"any" may legitimately be empty (e.g. Data — Get found nothing), so
# a None there is written as null rather than failing — branch on it with an
# If / Else "is empty" rule.
_NULLABLE = {"object", "any"}


def resolve_input_params(input_params: list[dict], context: dict) -> ParamResult:
    """Reads each declared input's `source_field` (a key or dotted path) out
    of `context`, coerces it to the declared type, and returns the resolved
    {name: value} dict a node/step actually runs with. Fails closed: a
    required input that's missing or the wrong type stops execution with a
    clear detail message, rather than silently running with `None`."""
    resolved: dict[str, Any] = {}
    for p in input_params:
        name, ptype, source_field, required = p.get("name"), p.get("type"), p.get("source_field"), p.get("required", True)
        fixed = p.get("value")
        if not name or (fixed is None and not source_field):
            continue
        raw = render_value(fixed, context) if fixed is not None else get_path(context, source_field)
        if raw is None or raw == "":
            if required:
                missing = f"value '{fixed}' is empty" if fixed is not None else f"variable '{source_field}' not set"
                return False, f"missing required input '{name}' ({missing})", {}
            continue
        ok, coerced = _coerce_type(raw, ptype)
        if not ok:
            return False, f"input '{name}' expected type {ptype}, got {raw!r}", {}
        resolved[name] = coerced
    return True, "", resolved


def apply_output_params(output_params: list[dict], result: Any) -> ParamResult:
    """For each declared output, the value comes from:
      - `source` "*"            -> the node's whole result
      - `source` "a.b" / "0.x"  -> that path inside the result
      - no source, dict result  -> a *strict* key lookup by the output's name
        (a typo'd output name should fail loudly, not quietly expose the
        wrong shape)
      - no source, other result -> the whole scalar/list result, the normal
        case for a node with one natural result.
    Coerces to the declared type; a mismatch or a missing value fails — the
    side effect already happened, but the graph can't safely continue with
    data that doesn't match what the next node was told to expect."""
    written: dict[str, Any] = {}
    for p in output_params:
        name, ptype, source = p.get("name"), p.get("type"), (p.get("source") or "").strip()
        if not name:
            continue
        if source == "*":
            value = result
        elif source:
            value = get_path(result, source)
        else:
            value = result.get(name) if isinstance(result, dict) else result
        if value is None:
            if ptype in _NULLABLE:
                written[name] = None
                continue
            return False, f"output '{name}' expected but the node produced no result", {}
        ok, coerced = _coerce_type(value, ptype)
        if not ok:
            return False, f"output '{name}' expected type {ptype}, got {value!r}", {}
        written[name] = coerced
    return True, "", written
