from collections import defaultdict

from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import router, transaction
from django.utils.translation import gettext as _

from dcim.constants import MODULE_TOKEN


def inherit_module_token(position, parent_positions):
    """
    Resolve a single {module} token in a bay position by inheriting from the position
    one level deeper in a module bay hierarchy. Returns position unchanged unless
    parent_positions is non-empty and position contains {module}, in which case the
    token is substituted with parent_positions[-1].

    Used by resolve_position_chain(), the single inheritance implementation shared by
    get_module_bay_positions() and the module move planner.
    """
    if parent_positions and MODULE_TOKEN in position:
        return position.replace(MODULE_TOKEN, parent_positions[-1])
    return position


def get_module_bay_raw_positions(module_bay):
    """
    Given a module bay, traverse up the module hierarchy and return the stored
    (unresolved) bay position strings from root to leaf.

    Raises ValueError if the module bay hierarchy contains a cycle.
    """
    positions = []
    visited = set()
    while module_bay:
        if module_bay.pk in visited:
            raise ValueError(_("Module bay hierarchy contains a cycle."))
        visited.add(module_bay.pk)
        positions.append(module_bay.position or '')
        module_bay = module_bay.module.module_bay if module_bay.module else None
    positions.reverse()
    return positions


def resolve_position_chain(raw_positions):
    """
    Apply leaf-to-root {module} token inheritance over a root-to-leaf list of raw bay
    positions: each position inherits from the resolved position one level deeper, and
    the leaf's own token is never resolved. Shared by get_module_bay_positions() and
    the module move planner so a planned chain always equals what a fresh walk
    computes once the planned positions are stored.
    """
    resolved = []
    for position in reversed(raw_positions):
        resolved.append(inherit_module_token(position, resolved))
    resolved.reverse()
    return resolved


def get_module_bay_positions(module_bay):
    """
    Given a module bay, traverse up the module hierarchy and return a list of bay
    position strings from root to leaf, resolving any {module} tokens in each
    position using the parent position (position inheritance).

    Raises ValueError if the module bay hierarchy contains a cycle.
    """
    return resolve_position_chain(get_module_bay_raw_positions(module_bay))


def resolve_module_placeholder(value, positions):
    """
    Resolve {module} placeholder tokens in a string using the given
    list of module bay positions (ordered root to leaf).

    A single {module} token resolves to the leaf (immediate parent) bay's position.
    Multiple tokens must match the tree depth and resolve level-by-level.

    Returns the resolved string.
    Raises ValueError if token count is greater than 1 and doesn't match tree depth.
    """
    if MODULE_TOKEN not in value:
        return value

    token_count = value.count(MODULE_TOKEN)
    if token_count == 1:
        return value.replace(MODULE_TOKEN, positions[-1])
    if token_count == len(positions):
        for pos in positions:
            value = value.replace(MODULE_TOKEN, pos, 1)
        return value
    raise ValueError(
        _("Cannot install module with placeholder values in a module bay tree "
          "{level} levels deep but {tokens} placeholders given.").format(
            level=len(positions), tokens=token_count
        )
    )


def compile_path_node(ct_id, object_id):
    return f'{ct_id}:{object_id}'


def decompile_path_node(repr):
    ct_id, object_id = repr.split(':')
    return int(ct_id), int(object_id)


def object_to_path_node(obj):
    """
    Return a representation of an object suitable for inclusion in a CablePath path. Node representation is in the
    form <ContentType ID>:<Object ID>.
    """
    ct = ContentType.objects.get_for_model(obj)
    return compile_path_node(ct.pk, obj.pk)


def path_node_to_object(repr):
    """
    Given the string representation of a path node, return the corresponding instance. If the object no longer
    exists, return None.
    """
    ct_id, object_id = decompile_path_node(repr)
    ct = ContentType.objects.get_for_id(ct_id)
    return ct.model_class().objects.filter(pk=object_id).first()


def create_cablepaths(objects):
    """
    Create CablePaths for all paths originating from the specified set of nodes.

    :param objects: Iterable of cabled objects (e.g. Interfaces)
    """
    from dcim.models import CablePath, Interface

    # Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its
    # own; instead, each channel subinterface traces independently from the single connector position it occupies.
    # Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that
    # rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds.
    expanded = []
    for obj in objects:
        if isinstance(obj, Interface) and obj.channels:
            expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False))
        else:
            expanded.append(obj)

    # Arrange objects by cable connector. All objects with a null connector are grouped together. Channel
    # subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of
    # siblings into a single malformed path.
    origins = defaultdict(list)
    for obj in expanded:
        if isinstance(obj, Interface) and obj.channel_id:
            if cp := CablePath.from_origin([obj]):
                cp.save()
        else:
            origins[obj.cable_connector].append(obj)

    for connector, objects in origins.items():
        if cp := CablePath.from_origin(objects):
            cp.save()


def rebuild_paths(terminations):
    """
    Rebuild all CablePaths which traverse the specified nodes.
    """
    from dcim.models import CablePath

    for obj in terminations:
        cable_paths = CablePath.objects.filter(_nodes__contains=obj)

        with transaction.atomic(using=router.db_for_write(CablePath)):
            for cp in cable_paths:
                cp.delete()
                create_cablepaths(cp.origins)


def rebuild_cable_paths(cable):
    """
    Delete and rebuild every CablePath traversing the given Cable, tracing freshly from the Cable's current
    terminations in both directions. Used when the channelization of a terminated interface changes (e.g. a channel
    subinterface is added, moved, or removed) without the Cable itself being modified.
    """
    from dcim.choices import CableEndChoices
    from dcim.models import CablePath, CableTermination, PathEndpoint

    with transaction.atomic(using=router.db_for_write(CablePath)):
        # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints.
        for cp in CablePath.objects.filter(_nodes__contains=cable):
            cp.delete()

        a_terminations, b_terminations = [], []
        for ct in CableTermination.objects.filter(cable=cable):
            if ct.cable_end == CableEndChoices.SIDE_A:
                a_terminations.append(ct.termination)
            else:
                b_terminations.append(ct.termination)

        for nodes in (a_terminations, b_terminations):
            if not nodes:
                continue
            if isinstance(nodes[0], PathEndpoint):
                create_cablepaths(nodes)
            else:
                rebuild_paths(nodes)


def update_interface_parents(device, interface_templates, module=None):
    """
    Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to
    the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists)
    and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent).
    """
    Interface = apps.get_model('dcim', 'Interface')

    for interface_template in interface_templates.exclude(parent=None):
        interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
        interface.parent = Interface.objects.get(
            device=device,
            name=interface_template.parent.resolve_name(module=module)
        )
        interface.full_clean()
        interface.save()


def update_interface_bridges(device, interface_templates, module=None):
    """
    Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
    and applies it to the actual interfaces.
    """
    Interface = apps.get_model('dcim', 'Interface')

    for interface_template in interface_templates.exclude(bridge=None):
        interface = Interface.objects.get(
            device=device,
            name=interface_template.resolve_name(module=module, device=device)
        )

        if interface_template.bridge:
            interface.bridge = Interface.objects.get(
                device=device,
                name=interface_template.bridge.resolve_name(module=module, device=device)
            )
            interface.full_clean()
            interface.save()


def create_port_mappings(device, device_or_module_type, module=None):
    """
    Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device.
    """
    from dcim.models import FrontPort, PortMapping, RearPort

    templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port')

    # Cache front & rear ports for efficient lookups by name
    front_ports = {
        fp.name: fp for fp in FrontPort.objects.filter(device=device)
    }
    rear_ports = {
        rp.name: rp for rp in RearPort.objects.filter(device=device)
    }

    # Replicate PortMappings
    mappings = []
    for template in templates:
        front_port = front_ports.get(template.front_port.resolve_name(module=module, device=device))
        rear_port = rear_ports.get(template.rear_port.resolve_name(module=module, device=device))
        mappings.append(
            PortMapping(
                device_id=front_port.device_id,
                front_port=front_port,
                front_port_position=template.front_port_position,
                rear_port=rear_port,
                rear_port_position=template.rear_port_position,
            )
        )
    # Bulk-created (no per-mapping ObjectChange) to match how every other component is instantiated.
    PortMapping.objects.bulk_create(mappings)


def reconcile_port_mappings(mapping_model, parent_field, parent, desired):
    """
    Reconcile a parent port's mappings against `desired`, writing only the difference so unchanged
    mappings keep their PK (and emit no changelog entry). Changed/removed rows are deleted before
    replacements are created, all in one transaction, so position swaps don't trip the unique
    constraint. Per-row create()/delete() let the change-logging signals fire naturally.

    Args:
        mapping_model: PortMapping or PortTemplateMapping.
        parent_field: 'front_port' or 'rear_port' — the side being edited; its '<parent_field>_position'
            is each mapping's stable identity within the set.
        parent: the parent instance (FrontPort/RearPort or their templates).
        desired: iterable of dicts of mapping field values EXCLUDING the parent FK, using '<field>_id'
            for the opposite-port FK, e.g. {'front_port_position': 1, 'rear_port_id': 5,
            'rear_port_position': 2}. save() derives device/device_type/module_type from the front port.
    """
    key_field = f'{parent_field}_position'
    other_field = 'rear_port' if parent_field == 'front_port' else 'front_port'
    value_fields = (f'{other_field}_id', f'{other_field}_position')

    def target(source):
        # The comparable "value" of a mapping: the opposite port and its position. Two mappings with
        # the same parent-side position but a different target represent a re-pointing of that slot.
        get = source.get if isinstance(source, dict) else lambda f: getattr(source, f)
        return tuple(get(f) for f in value_fields)

    desired_by_key = {d[key_field]: d for d in desired}

    with transaction.atomic(using=router.db_for_write(mapping_model)):
        # Lock the parent's existing mappings for the duration of the reconcile. Two requests editing
        # the same port would otherwise read the same snapshot and race, the second colliding on a
        # unique constraint when it recreates rows the first has already committed.
        existing = {
            getattr(m, key_field): m
            for m in mapping_model.objects.filter(**{parent_field: parent}).select_for_update()
        }

        # Delete rows that no longer exist or whose target changed (before creating, to free the slots)
        for key, mapping in existing.items():
            if key not in desired_by_key or target(mapping) != target(desired_by_key[key]):
                mapping.delete()

        # Create rows that are new or whose target changed
        for key, attrs in desired_by_key.items():
            if key not in existing or target(existing[key]) != target(attrs):
                mapping_model.objects.create(**{parent_field: parent, **attrs})
