"""Deterministic normalization and safe repair of an AI-written ``compose`` entry.

The manifest is a generated artifact, not a hand-authored contract: the coding agent
expresses *intent* and Core is authoritative (see requirements_spec.CompositionRequirement
and composer.py, which derive the canonical pack, access policy and Cloud entity from it).
An almost-right entry should therefore be repaired, not bounced, but never at the cost of
guessing anything that matters for security or data.

Pipeline: raw -> normalize/repair (this module) -> validate (CompositionRequirement) ->
provision. The original entry is preserved unchanged, every repair is recorded with its
path, before/after and reason, and the caller gets a structured error list for anything
that remains invalid so it can be sent back to the coding agent.

SAFE (each is reversible, meaning-preserving, and reported):
- identifiers (entity ``name``, field ``key``, requirement ``resource``, workflow ``field``,
  ``relation_entity_key``) normalized to the slug form the schema requires;
- unambiguous aliases: entity ``entity_key``/``entity`` -> ``name``; field ``name`` -> ``label``;
- a small fixed table of unambiguous field-type synonyms (``string`` -> ``text`` ...);
- case/whitespace of ``action``, ``actor`` and ``criticality``;
- ``criticality`` made explicit as ``"required"`` when absent (the fail-safe default);
- ``actors`` inferred from, or completed by, the actors the requirements already name
  (this grants nothing: the requirement itself is what grants access);
- keys from a fixed *cosmetic* list dropped (descriptions, placeholders, UI hints).

NEVER repaired -- only reported, because guessing is a security or data decision:
actions, which actor may do what, workflow transitions, function slugs, ``requires``,
missing fields or keys, field types outside the synonym table, select options, and any
unknown key that is not on the cosmetic list (an unknown key such as ``public`` could be
an access intent; dropping it silently would change what the author meant).

Renames matter to the frontend: an entity or field renamed here changes the name the
generated app must call. ``repairs`` is what tells the coding agent so.
"""

from __future__ import annotations

import copy
import re
import unicodedata
from dataclasses import dataclass, field
from typing import Any

from pydantic import ValidationError

from src.domain.feature_packs.requirements_spec import CompositionRequirement

_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,40}$")  # CompositionRequirement.name
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,62}$")  # field key / resource / workflow field

# Cosmetic keys only: none of these changes data shape, access or behavior.
_COSMETIC_TOP = frozenset(
    {
        "description",
        "title",
        "label",
        "display_name",
        "displayName",
        "icon",
        "notes",
        "note",
        "comment",
        "comments",
        "summary",
        "plural_name",
        "pluralName",
    }
)
_COSMETIC_FIELD = frozenset(
    {
        "placeholder",
        "description",
        "help",
        "help_text",
        "helpText",
        "hint",
        "ui",
        "icon",
        "order",
        "width",
        "position",
        "group",
        "section",
        "example",
        "examples",
    }
)
_COSMETIC_REQUIREMENT = frozenset({"description", "note", "notes", "reason", "comment"})

# Only synonyms with exactly one sensible meaning in the platform's type set.
_TYPE_SYNONYMS = {
    "string": "text",
    "str": "text",
    "varchar": "text",
    "textarea": "text",
    "email": "text",
    "url": "text",
    "phone": "text",
    "tel": "text",
    "integer": "number",
    "int": "number",
    "float": "number",
    "double": "number",
    "decimal": "number",
    "bool": "boolean",
    "enum": "select",
}
_CRITICALITY = {"required", "optional"}


@dataclass
class RepairResult:
    original: Any
    repaired: Any
    repairs: list[dict[str, Any]] = field(default_factory=list)
    errors: list[dict[str, str]] = field(default_factory=list)
    requirement: CompositionRequirement | None = None

    @property
    def status(self) -> str:
        if self.errors or self.requirement is None:
            return "invalid"
        return "repaired" if self.repairs else "valid"

    @property
    def renames(self) -> list[dict[str, Any]]:
        """Repairs that change a NAME the generated frontend may already be calling (entity,
        field key, resource). These must be reported back to the coding agent."""
        return [r for r in self.repairs if r["reason"] in {"normalized_to_slug", "alias_to_name"}]

    def record(self) -> dict[str, Any]:
        """What Core keeps for audit and for telling the coding agent what changed."""
        return {
            "original_manifest": self.original,
            "repaired_manifest": self.repaired,
            "repairs": self.repairs,
            "validation": "failed" if self.status == "invalid" else "passed",
            "errors": self.errors,
        }


def to_slug(value: str) -> str | None:
    """'Contact Submission' / 'contactSubmission' / 'contact-submission' -> 'contact_submission'."""
    text = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
    text = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", text)
    slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
    return slug or None


def _note(repairs: list, path: str, old: Any, new: Any, reason: str) -> None:
    def shown(value: Any) -> Any:
        return value[:200] if isinstance(value, str) else value

    repairs.append({"path": path, "from": shown(old), "to": shown(new), "reason": reason})


def _slug_in_place(
    container: dict, key: str, pattern: re.Pattern, path: str, repairs: list
) -> None:
    value = container.get(key)
    if not isinstance(value, str) or pattern.fullmatch(value):
        return
    slug = to_slug(value)
    # A value that cannot be normalized into a valid identifier (starts with a digit, too
    # long, empty) is left alone so validation reports it; truncating could collide names.
    if slug and pattern.fullmatch(slug):
        container[key] = slug
        _note(repairs, path, value, slug, "normalized_to_slug")


def _drop_cosmetic(container: dict, cosmetic: frozenset, path: str, repairs: list) -> None:
    for key in [k for k in container if k in cosmetic]:
        _note(
            repairs, f"{path}.{key}" if path else key, container[key], None, "cosmetic_key_removed"
        )
        del container[key]


def _repair_top(data: dict, repairs: list) -> None:
    # entity name aliases (Cloud's own shape says `entity_key`; intent sketches say `entity`)
    for alias in ("entity_key", "entity"):
        if alias in data and isinstance(data[alias], str):
            if "name" not in data:
                data["name"] = data.pop(alias)
                _note(repairs, "name", alias, "name", "alias_to_name")
            elif to_slug(str(data["name"])) == to_slug(data[alias]):
                del data[alias]
                _note(repairs, alias, "<duplicate of name>", None, "duplicate_alias_removed")
            # differing values are a conflict: left in place so validation reports it
    _slug_in_place(data, "name", _NAME_RE, "name", repairs)
    _drop_cosmetic(data, _COSMETIC_TOP, "", repairs)
    if "criticality" not in data:
        data["criticality"] = "required"
        _note(repairs, "criticality", None, "required", "default_made_explicit")
    elif isinstance(data["criticality"], str):
        clean = data["criticality"].strip().lower()
        if clean != data["criticality"] and clean in _CRITICALITY:
            _note(repairs, "criticality", data["criticality"], clean, "normalized_case")
            data["criticality"] = clean


def _repair_field(item: dict, path: str, repairs: list) -> None:
    if "name" in item:
        label = item.get("label")
        if label is None and isinstance(item["name"], str):
            item["label"] = item.pop("name")
            _note(repairs, f"{path}.label", "name", "label", "alias_to_label")
        elif isinstance(label, str) and isinstance(item["name"], str):
            # Cloud's field shape carries a display `name` next to `key`; with a label already
            # present it is a duplicate, cosmetic display string.
            _note(repairs, f"{path}.name", item["name"], None, "duplicate_display_name_removed")
            del item["name"]
    _slug_in_place(item, "key", _KEY_RE, f"{path}.key", repairs)
    _slug_in_place(item, "relation_entity_key", _KEY_RE, f"{path}.relation_entity_key", repairs)
    kind = item.get("type")
    if isinstance(kind, str):
        clean = kind.strip().lower()
        target = _TYPE_SYNONYMS.get(clean, clean)
        if target != kind:
            _note(
                repairs,
                f"{path}.type",
                kind,
                target,
                "type_synonym" if clean in _TYPE_SYNONYMS else "normalized_case",
            )
            item["type"] = target
    _drop_cosmetic(item, _COSMETIC_FIELD, path, repairs)


def _repair_requirement(item: dict, path: str, repairs: list) -> None:
    for key in ("action", "actor"):
        value = item.get(key)
        if isinstance(value, str) and value != value.strip().lower():
            _note(repairs, f"{path}.{key}", value, value.strip().lower(), "normalized_case")
            item[key] = value.strip().lower()
    _slug_in_place(item, "resource", _KEY_RE, f"{path}.resource", repairs)
    _drop_cosmetic(item, _COSMETIC_REQUIREMENT, path, repairs)


def _repair_actors(data: dict, repairs: list) -> None:
    requirements = data.get("requirements")
    if not isinstance(requirements, list):
        return
    used = [
        r["actor"] for r in requirements if isinstance(r, dict) and isinstance(r.get("actor"), str)
    ]
    if not used:
        return
    declared = data.get("actors")
    if declared is None or declared == []:
        data["actors"] = list(dict.fromkeys(used))
        _note(repairs, "actors", declared, data["actors"], "actors_inferred_from_requirements")
        return
    if not isinstance(declared, list) or not all(isinstance(a, str) for a in declared):
        return
    normalized = [a.strip().lower() for a in declared]
    if normalized != declared:
        _note(repairs, "actors", declared, normalized, "normalized_case")
        data["actors"] = declared = normalized
    for actor in dict.fromkeys(used):
        if actor not in declared:
            declared.append(actor)
            _note(repairs, "actors", None, actor, "actor_added_from_requirements")


def _meaning(data: dict) -> Any:
    """What the entry *says*, insensitive to the spelling we are allowed to normalize."""

    def slug(value: Any) -> Any:
        return (to_slug(value) or value) if isinstance(value, str) else value

    def low(value: Any) -> Any:
        return value.strip().lower() if isinstance(value, str) else value

    # key=repr: entries may hold None/odd types, which plain tuple sorting cannot compare.
    requirements = sorted(
        (
            (low(r.get("action")), slug(r.get("resource")), low(r.get("actor")))
            for r in data.get("requirements") or []
            if isinstance(r, dict)
        ),
        key=repr,
    )
    fields = sorted(
        (
            (
                slug(f.get("key")),
                _TYPE_SYNONYMS.get(low(f.get("type")), low(f.get("type"))),
                bool(f.get("required")),
                bool(f.get("unique")),
                tuple(f.get("options") or ()),
            )
            for f in data.get("fields") or []
            if isinstance(f, dict)
        ),
        key=repr,
    )
    workflow = data.get("workflow")
    workflow_meaning = (
        (slug(workflow.get("field")), workflow.get("transitions"))
        if isinstance(workflow, dict)
        else None
    )
    return (
        requirements,
        fields,
        workflow_meaning,
        sorted(map(repr, data.get("function_slugs") or [])),
        sorted(map(repr, data.get("requires") or [])),
    )


def _errors_from(exc: ValidationError) -> list[dict[str, str]]:
    errors = []
    for item in exc.errors():
        path = ".".join(str(part) for part in item["loc"])
        if item["type"] == "extra_forbidden":
            errors.append(
                {
                    "path": path,
                    "code": "unknown_key",
                    "message": f"'{item['loc'][-1]}' is not a recognized key. It was not dropped "
                    "because it might carry meaning; remove it or use a supported key.",
                }
            )
        else:
            errors.append({"path": path, "code": item["type"], "message": item["msg"]})
    return errors


def repair_composition(entry: Any) -> RepairResult:
    """Normalize and safely repair one ``compose`` entry, then validate it."""
    original = copy.deepcopy(entry)
    if not isinstance(entry, dict):
        return RepairResult(
            original=original,
            repaired=original,
            errors=[
                {
                    "path": "",
                    "code": "not_an_object",
                    "message": "A compose entry must be a JSON object.",
                }
            ],
        )
    data = copy.deepcopy(entry)
    repairs: list[dict[str, Any]] = []
    _repair_top(data, repairs)
    if isinstance(data.get("fields"), list):
        for index, item in enumerate(data["fields"]):
            if isinstance(item, dict):
                _repair_field(item, f"fields.{index}", repairs)
    if isinstance(data.get("requirements"), list):
        for index, item in enumerate(data["requirements"]):
            if isinstance(item, dict):
                _repair_requirement(item, f"requirements.{index}", repairs)
    workflow = data.get("workflow")
    if isinstance(workflow, dict):
        _slug_in_place(workflow, "field", _KEY_RE, "workflow.field", repairs)
    _repair_actors(data, repairs)

    if _meaning(data) != _meaning(entry):
        # Defensive: no repair here is allowed to change what the entry says. If one ever
        # does (a bug in this module), refuse the repair rather than ship a different meaning.
        return RepairResult(
            original=original,
            repaired=original,
            errors=[
                {
                    "path": "",
                    "code": "repair_changed_meaning",
                    "message": "Automatic repair was refused because it would have changed "
                    "the requirement's meaning.",
                }
            ],
        )
    try:
        requirement = CompositionRequirement.model_validate(data)
    except ValidationError as exc:
        return RepairResult(
            original=original, repaired=data, repairs=repairs, errors=_errors_from(exc)
        )
    return RepairResult(original=original, repaired=data, repairs=repairs, requirement=requirement)
