import importlib.resources
import json
import logging
from typing import Any
from typing import Callable

from django.apps import apps as django_apps


logger = logging.getLogger("ipfabric_netbox.utilities.transform_map")

# region Transform Map Creation

# These functions are used in the migration file to prepare the transform maps
# Because of this we have to use historical models
# see https://docs.djangoproject.com/en/5.1/topics/migrations/#historical-models

# Meta-fields emitted by Django's JSON serializer and old transform_map.json entries.
# These must be stripped before calling Model.objects.create().
_STRIP_FIELDS = frozenset({"created", "last_updated", "custom_field_data"})


def build_fields(data, apps, db_alias):
    ContentType = apps.get_model("contenttypes", "ContentType")
    if "target_model" in data:
        ct = ContentType.objects.db_manager(db_alias).get_for_model(
            apps.get_model(
                data["target_model"]["app_label"],
                data["target_model"]["model"],
            )
        )
        data["target_model"] = ct
    elif "source_model" in data:
        ct = ContentType.objects.db_manager(db_alias).get_for_model(
            apps.get_model(
                data["source_model"]["app_label"],
                data["source_model"]["model"],
            )
        )
        data["source_model"] = ct
    return data


def _normalize_parent_spec(spec) -> dict:
    """Return a normalised ``{"group": <name|None>, "target_model": "app.model"}`` dict.

    Accepts both the legacy plain-string format (``"dcim.site"``) and the new
    structured dict format (``{"group": "Prod", "target_model": "dcim.site"}``).
    The legacy format always resolves against the ungrouped (default) maps.
    """
    if isinstance(spec, str):
        return {"group": None, "target_model": spec}
    return {"group": spec.get("group"), "target_model": spec["target_model"]}


def _resolve_parent(spec_dict, ContentType, IPFabricTransformMap, db_alias):
    """Look up and return the ``IPFabricTransformMap`` matching *spec_dict*.

    *spec_dict* must be the normalised form produced by :func:`_normalize_parent_spec`.
    Raises :class:`ValueError` with a descriptive message when no match is found.
    """
    app, model = spec_dict["target_model"].split(".")
    try:
        ct = ContentType.objects.using(db_alias).get(app_label=app, model=model)
    except ContentType.DoesNotExist:
        raise ValueError(f"ContentType '{spec_dict['target_model']}' not found")

    group_name = spec_dict["group"]
    qs = IPFabricTransformMap.objects.using(db_alias).filter(target_model=ct)
    if group_name is None:
        qs = qs.filter(group__isnull=True)
    else:
        qs = qs.filter(group__name=group_name)

    try:
        return qs.get()
    except IPFabricTransformMap.DoesNotExist:
        group_info = "group=None" if group_name is None else f"group='{group_name}'"
        raise ValueError(
            f"Parent Transform Map not found: "
            f"target_model='{spec_dict['target_model']}', {group_info}"
        )
    except IPFabricTransformMap.MultipleObjectsReturned:
        group_info = "group=None" if group_name is None else f"group='{group_name}'"
        raise ValueError(
            f"Multiple Transform Maps found for: "
            f"target_model='{spec_dict['target_model']}', {group_info}. "
            f"Data integrity issue — ensure uniqueness constraints are met."
        )


def build_transform_maps(data, apps: django_apps = None, db_alias: str = "default"):
    apps = apps or django_apps
    ContentType = apps.get_model("contenttypes", "ContentType")
    IPFabricTransformMap = apps.get_model("ipfabric_netbox", "IPFabricTransformMap")
    IPFabricTransformField = apps.get_model("ipfabric_netbox", "IPFabricTransformField")
    IPFabricRelationshipField = apps.get_model(
        "ipfabric_netbox", "IPFabricRelationshipField"
    )

    # Mapping for backward compatibility (endpoint -> source_model)
    # Used only when running against old model that still has source_model field
    # TODO: Remove once migrations are squashed and old versions are no longer supported
    endpoint_to_source_model = {
        "/technology/addressing/managed-ip/ipv4": "ipaddress",
        "/inventory/devices": "device",
        "/inventory/sites/overview": "site",
        "/inventory/interfaces": "interface",
        "/inventory/part-numbers": "part_number",
        "/technology/vlans/site-summary": "vlan",
        "/technology/routing/vrf/detail": "vrf",
        "/technology/networks/managed-networks": "prefix",
        "/technology/platforms/stack1/members": "virtualchassis",
    }

    model_fields = {f.name for f in IPFabricTransformMap._meta.get_fields()}

    # ── Pass 1: create maps + their fields/relationships ────────────────────
    # Parent resolution is deferred to pass 2 so that children listed before
    # their parents in the JSON payload are handled correctly, and cross-group
    # references can be resolved once all maps exist in the DB.
    pending_parents: list[tuple] = []  # [(tm_obj, [raw_parent_spec, ...])]

    for tm in data:
        field_data = build_fields(tm["data"], apps, db_alias)

        # Strip meta-fields emitted by Django's serializer / old json entries
        for key in _STRIP_FIELDS:
            field_data.pop(key, None)

        endpoint_value = field_data.pop("source_endpoint")

        if "source_endpoint" in model_fields:
            # New model: use source_endpoint foreign key to IPFabricEndpoint
            # This model does not exist when TMs are populated via migration,
            # so we look it up here.
            IPFabricEndpoint = apps.get_model("ipfabric_netbox", "IPFabricEndpoint")
            try:
                endpoint = IPFabricEndpoint.objects.using(db_alias).get(
                    endpoint=endpoint_value
                )
                field_data["source_endpoint"] = endpoint
            except IPFabricEndpoint.DoesNotExist:
                # Use first endpoint as fallback and flag the map for correction
                endpoint = IPFabricEndpoint.objects.using(db_alias).first()
                field_data["source_endpoint"] = endpoint
                field_data["name"] = (
                    f"[NEEDS CORRECTION - Expected endpoint '{endpoint_value}' not found] "
                    f"{field_data['name']}"
                )
        else:
            # Old model: convert source_endpoint value to source_model string
            source_model_value = endpoint_to_source_model.get(endpoint_value, "device")
            field_data["source_model"] = source_model_value

        # Collect raw parent specs for pass 2
        raw_parents = field_data.pop("parents", None)

        # Handle group: look up or create by name (new export format supplies a name
        # string; old migration format omits the field entirely).
        group_value = field_data.pop("group", None)
        if group_value is not None and "group" in model_fields:
            try:
                IPFabricTransformMapGroup = apps.get_model(
                    "ipfabric_netbox", "IPFabricTransformMapGroup"
                )
                group_obj, _ = IPFabricTransformMapGroup.objects.using(
                    db_alias
                ).get_or_create(name=group_value)
                field_data["group"] = group_obj
            except Exception:
                # Group model not available in very old migrations — skip silently
                pass

        tm_obj = IPFabricTransformMap.objects.using(db_alias).create(**field_data)

        if raw_parents and "parents" in model_fields:
            if isinstance(raw_parents, (str, dict)):
                raw_parents = [raw_parents]
            pending_parents.append((tm_obj, list(raw_parents)))

        for fm in tm["field_maps"]:
            fm_data = build_fields(dict(fm), apps, db_alias)
            IPFabricTransformField.objects.using(db_alias).create(
                transform_map=tm_obj, **fm_data
            )
        for rm in tm["relationship_maps"]:
            rm_data = build_fields(dict(rm), apps, db_alias)
            IPFabricRelationshipField.objects.using(db_alias).create(
                transform_map=tm_obj, **rm_data
            )

    # ── Pass 2: resolve and wire parent relationships ────────────────────────
    for tm_obj, raw_parents in pending_parents:
        if not hasattr(tm_obj, "parents"):
            continue
        resolved = []
        for spec in raw_parents:
            norm = _normalize_parent_spec(spec)
            parent_tm = _resolve_parent(
                norm, ContentType, IPFabricTransformMap, db_alias
            )
            resolved.append(parent_tm)
        tm_obj.parents.set(resolved)


def export_transform_maps(queryset=None) -> list[dict]:
    """Serialise transform maps to the portable JSON format used by :func:`build_transform_maps`.

    Each entry in the returned list has the form::

        {
            "data": {
                "name": str,
                "source_endpoint": str,          # endpoint path string
                "target_model": {"app_label": str, "model": str},
                "group": str | None,             # group name, or null for ungrouped
                "parents": [                     # list of cross-group parent refs
                    {"group": str | None, "target_model": "app_label.model"},
                    ...
                ] | None
            },
            "field_maps": [
                {"source_field": str, "target_field": str,
                 "coalesce": bool, "template": str},
                ...
            ],
            "relationship_maps": [
                {"source_model": {"app_label": str, "model": str},
                 "target_field": str, "coalesce": bool, "template": str},
                ...
            ]
        }

    Args:
        queryset: Optional queryset of ``IPFabricTransformMap`` instances to export.
                  Defaults to ``IPFabricTransformMap.objects.all()``.

    Returns:
        A list of portable dicts that can be round-tripped through
        :func:`build_transform_maps`.
    """
    from ipfabric_netbox.models import (
        IPFabricTransformMap,
    )  # avoid circular at module load

    if queryset is None:
        queryset = IPFabricTransformMap.objects.all()

    result = []
    for tm in queryset.prefetch_related(
        "parents__group",
        "parents__target_model",
        "field_maps",
        "relationship_maps__source_model",
        "source_endpoint",
        "group",
        "target_model",
    ):
        target_ct = tm.target_model

        parents_list = [
            {
                "group": parent.group.name if parent.group else None,
                "target_model": f"{parent.target_model.app_label}.{parent.target_model.model}",
            }
            for parent in tm.parents.all()
        ]

        entry = {
            "data": {
                "name": tm.name,
                "source_endpoint": tm.source_endpoint.endpoint,
                "target_model": {
                    "app_label": target_ct.app_label,
                    "model": target_ct.model,
                },
                "group": tm.group.name if tm.group else None,
                "parents": parents_list if parents_list else None,
            },
            "field_maps": [
                {
                    "source_field": f.source_field,
                    "target_field": f.target_field,
                    "coalesce": f.coalesce,
                    "template": f.template,
                }
                for f in tm.field_maps.all()
            ],
            "relationship_maps": [
                {
                    "source_model": {
                        "app_label": r.source_model.app_label,
                        "model": r.source_model.model,
                    },
                    "target_field": r.target_field,
                    "coalesce": r.coalesce,
                    "template": r.template,
                }
                for r in tm.relationship_maps.all()
            ],
        }
        result.append(entry)
    return result


def get_transform_map() -> dict:
    for data_file in importlib.resources.files("ipfabric_netbox.data").iterdir():
        if data_file.name != "transform_map.json":
            continue
        with open(data_file, "rb") as data_file:
            return json.load(data_file)
    raise FileNotFoundError("'transform_map.json' not found in installed package")


# endregion
# region Transform Map Updating


class Record:
    """Base class for field and relationship records."""

    def __init__(
        self,
        coalesce: bool | None = None,
        old_template: str = None,
        new_template: str = None,
    ):
        self.coalesce = coalesce
        # Keep the original template here rather than loading it from transform_map.json
        # so our revert won’t break if that template ever changes.
        self.old_template = old_template
        self.new_template = new_template


class FieldRecord(Record):
    def __init__(
        self,
        source_field: str,
        target_field: str,
        new_source_field: str | None = None,
        new_target_field: str | None = None,
        **kwargs,
    ):
        super().__init__(**kwargs)
        self.source_field = source_field
        self.target_field = target_field
        self.new_source_field = new_source_field
        self.new_target_field = new_target_field


class RelationshipRecord(Record):
    def __init__(self, source_model: str, target_field: str, **kwargs):
        super().__init__(**kwargs)
        self.source_model = source_model
        self.target_field = target_field


class TransformMapRecord:
    def __init__(
        self,
        target_model: str,
        fields: tuple[FieldRecord, ...] = tuple(),
        relationships: tuple[RelationshipRecord, ...] = tuple(),
        # Support both source_model string and source_endpoint string for backward compatibility
        source_model: str | None = None,
        source_endpoint: str | None = None,
    ):
        self.source_model = source_model
        self.source_endpoint = source_endpoint
        self.target_model = target_model
        self.fields = fields
        self.relationships = relationships


def do_change(
    apps, schema_editor, changes: tuple[TransformMapRecord, ...], forward: bool = True
):
    """Apply the changes, `forward` determines direction."""

    ContentType = apps.get_model("contenttypes", "ContentType")
    IPFabricTransformMap = apps.get_model("ipfabric_netbox", "IPFabricTransformMap")
    IPFabricTransformField = apps.get_model("ipfabric_netbox", "IPFabricTransformField")
    IPFabricRelationshipField = apps.get_model(
        "ipfabric_netbox", "IPFabricRelationshipField"
    )

    try:
        for change in changes:
            app, model = change.target_model.split(".")
            try:
                if change.source_model:
                    transform_map = IPFabricTransformMap.objects.get(
                        source_model=change.source_model,
                        target_model=ContentType.objects.get(
                            app_label=app, model=model
                        ),
                    )
                else:
                    transform_map = IPFabricTransformMap.objects.get(
                        source_endpoint__endpoint=change.source_endpoint,
                        target_model=ContentType.objects.get(
                            app_label=app, model=model
                        ),
                    )
            except IPFabricTransformMap.DoesNotExist:
                continue

            for field in change.fields:
                # Find the correct transform field.
                # Only 1 should be found if it exists, but keep it as queryset so we can filter and update.
                transform_field_qs = IPFabricTransformField.objects.filter(
                    transform_map=transform_map,
                    source_field=field.source_field
                    if forward
                    else field.new_source_field or field.source_field,
                    target_field=field.target_field
                    if forward
                    else field.new_target_field or field.target_field,
                )
                if not transform_field_qs.exists():
                    continue

                if field.old_template is not None and field.new_template is not None:
                    # First update the template if needed
                    transform_field_qs.filter(
                        template=field.old_template if forward else field.new_template
                    ).update(
                        template=field.new_template if forward else field.old_template
                    )

                if field.coalesce is not None:
                    # Next update coalesce if needed
                    transform_field_qs.filter(
                        coalesce=not field.coalesce if forward else field.coalesce
                    ).update(coalesce=field.coalesce if forward else not field.coalesce)

                if (
                    field.new_target_field is not None
                    or field.new_source_field is not None
                ):
                    # And at the end update source_field/target_field if needed
                    transform_field_qs.update(
                        source_field=field.new_source_field or field.source_field
                        if forward
                        else field.source_field,
                        target_field=field.new_target_field or field.target_field
                        if forward
                        else field.target_field,
                    )

            for relationship in change.relationships:
                s_app, s_model = relationship.source_model.split(".")
                source_model = ContentType.objects.get(app_label=s_app, model=s_model)

                # Find the correct relationship field.
                # Only 1 should be found if it exists, but keep it as queryset so we can filter and update.
                relationship_qs = IPFabricRelationshipField.objects.filter(
                    transform_map=transform_map,
                    source_model=source_model,
                    target_field=relationship.target_field,
                )
                if not relationship_qs.exists():
                    continue

                if (
                    relationship.old_template is not None
                    and relationship.new_template is not None
                ):
                    # First update the template if needed
                    relationship_qs.filter(
                        template=relationship.old_template
                        if forward
                        else relationship.new_template,
                    ).update(
                        template=relationship.new_template
                        if forward
                        else relationship.old_template
                    ),

                if relationship.coalesce is not None:
                    # Next update coalesce if needed
                    relationship_qs.filter(
                        coalesce=not relationship.coalesce
                        if forward
                        else relationship.coalesce,
                    ).update(
                        coalesce=relationship.coalesce
                        if forward
                        else not relationship.coalesce
                    ),

    except Exception as e:
        print(f"Error applying Transform map updates: {e}")


# endregion

# region Cycle Detection


def has_cycle_dfs(
    node_id: int,
    get_parents_func: Callable[[int, Any], Any],
    parent_override: Any = None,
    visited: set[int] | None = None,
    rec_stack: set[int] | None = None,
) -> bool:
    """
    DFS helper to detect cycles in transform map parent relationships.

    Args:
        node_id: The ID of the current node being checked
        get_parents_func: Function that takes (node_id, parent_override) and returns parent objects
        parent_override: Optional override for parents of a specific node (used for validation)
        visited: Set of already visited node IDs (created automatically if not provided)
        rec_stack: Set of node IDs in the current recursion stack (created automatically if not provided)

    Returns:
        True if a cycle is detected, False otherwise
    """
    if visited is None:
        visited = set()
    if rec_stack is None:
        rec_stack = set()

    visited.add(node_id)
    rec_stack.add(node_id)

    try:
        parents = get_parents_func(node_id, parent_override)

        for parent in parents:
            parent_pk = parent.pk if hasattr(parent, "pk") else parent.id
            if parent_pk not in visited:
                if has_cycle_dfs(
                    parent_pk, get_parents_func, visited=visited, rec_stack=rec_stack
                ):
                    return True
            elif parent_pk in rec_stack:
                # Found a back edge - cycle detected
                return True
    except Exception as err:
        logger.warning(f"Error applying Transform map updates: {err}")

    rec_stack.remove(node_id)
    return False


# endregion
