from __future__ import annotations

from typing import TYPE_CHECKING, Iterator, Literal, overload

import bpy
from bpy.types import NodeSocket

from ._registry import _wrap_socket
from ._utils import (
    SocketError,
    _allow_innactive_sockets,
    denormalize_name,
    normalize_name,
)

if TYPE_CHECKING:
    from .node import BaseNode
    from .socket import Socket


class SocketAccessor:
    """Unified accessor for a node's input or output socket collection.

    Supports identifier/name lookup, dict-style ``[]`` access, availability
    filtering, and type-compatible matching — replacing the former pairs of
    ``_input_idx``/``_output_idx``, ``_input``/``_output``,
    ``_available_inputs``/``_available_outputs``, and ``_best_output_socket``.
    """

    def __init__(
        self,
        collection: bpy.types.NodeInputs | bpy.types.NodeOutputs | list[NodeSocket],
        direction: Literal["input", "output"],
        *,
        builder: BaseNode | None = None,
    ):
        self._direction = direction
        self._collection = collection
        self._builder = builder

    def _index(self, key: str | int) -> int:
        """Find socket index by identifier, falling back to name.

        Tries identifier match first. If no identifier matches, falls back to
        name lookup — but raises if the name is duplicated (ambiguous).
        Integer keys are returned directly.
        """
        if isinstance(key, int):
            return key
        ids = [s.identifier for s in self._collection]
        names = [s.name for s in self._collection]
        denorm = denormalize_name(key)
        for candidate in (key, denorm):
            if candidate in ids:
                return ids.index(candidate)
        # Normalized identifier match: 'value_001' matches identifier 'Value_001'
        normalized_ids = [normalize_name(id) for id in ids]
        if key in normalized_ids:
            return normalized_ids.index(key)
        for candidate in (key, denorm):
            if candidate in names:
                if names.count(candidate) > 1:
                    raise RuntimeError(
                        f"{self._direction.title()} name '{candidate}' is ambiguous "
                        f"on {self._node.bl_idname} (appears {names.count(candidate)} "
                        f"times). Use the socket identifier instead."
                    )
                return names.index(candidate)
        # Normalized name match: 'flip_and_cyclic' matches name 'Flip and Cyclic'
        # (denormalize_name can't recover the original capitalisation of small
        # connector words like "and"/"to").
        normalized_names = [normalize_name(n) for n in names]
        if key in normalized_names:
            if normalized_names.count(key) > 1:
                raise RuntimeError(
                    f"{self._direction.title()} name '{key}' is ambiguous on "
                    f"{self._node.bl_idname} (appears {normalized_names.count(key)} "
                    f"times). Use the socket identifier instead."
                )
            return normalized_names.index(key)
        raise RuntimeError(
            f"{self._direction.title()} '{key}' not found on "
            f"{self._node.bl_idname}. Available sockets (id: name): {list(zip(ids, names))}"
        )

    @overload
    def _get(self, key: slice) -> "list[Socket]": ...
    @overload
    def _get(self, key: str | int) -> "Socket": ...
    def _get(self, key: str | int | slice) -> "Socket | list[Socket]":
        """Get a Socket for a socket by identifier, name, or index."""
        if isinstance(key, slice):
            sockets = [
                _wrap_socket(self._collection[i])
                for i in range(*key.indices(len(self._collection)))
            ]
            if self._builder is not None:
                for s in sockets:
                    s._builder_node = self._builder
            return sockets
        socket = _wrap_socket(self._collection[self._index(key)])
        if self._builder is not None:
            socket._builder_node = self._builder
        return socket

    @overload
    def __getitem__(self, key: slice) -> "list[Socket]": ...
    @overload
    def __getitem__(self, key: str | int) -> "Socket": ...
    def __getitem__(self, key: str | int | slice) -> "Socket | list[Socket]":
        """Access by identifier, name, or integer index."""
        return self._get(key)

    @property
    def _node(self) -> bpy.types.Node:
        """The node this accessor is associated with."""
        if isinstance(self._collection, list):
            assert self._collection[0].node is not None
            return self._collection[0].node
        # bpy NodeInputs/NodeOutputs.id_data returns the NodeTree (top-level ID),
        # not the Node. Retrieve the node via the first socket instead.
        for s in self._collection:
            assert s.node is not None
            return s.node
        return self._collection.data  # empty collection fallback

    @property
    def _ignore_visibility(self) -> bool:
        """Whether to ignore socket visibility when selecting available sockets.

        Only affects ``available`` / ``best_match`` (the auto-linking heuristics).
        ``values()`` / ``items()`` always respect node-level visibility so that
        iteration over a node's sockets stays predictable regardless of context.
        Returns False when called outside a tree context (e.g. from a bare
        Socket that was created outside a ``with tree:`` block).
        """
        from .tree import TreeBuilder

        if not TreeBuilder._tree_contexts:
            return False
        return TreeBuilder._tree_contexts[-1].ignore_visibility

    def _visible_sockets(self) -> list[NodeSocket]:
        """Sockets that should appear in iteration (values/items/keys).

        Uses the per-node allowlist (``_allow_innactive_sockets``) rather than
        the tree-level ``ignore_visibility`` flag — enumeration should always
        reflect what is meaningfully present on the node, not the linking context.
        """
        if self._direction == "input":
            return [
                s
                for s in self._collection
                if _allow_innactive_sockets(self._node)
                or (not s.is_inactive and s.is_icon_visible)
            ]
        return [s for s in self._collection if s.is_icon_visible]

    @property
    def _available(self) -> list[NodeSocket]:
        """Sockets eligible for automatic linking.

        Respects ``ignore_visibility`` on the active ``TreeBuilder`` context, so
        nodes with normally-hidden sockets can still be auto-linked when that flag
        is set (e.g. during ``test_add_all_nodes``).
        """
        if self._direction == "input":
            return [
                s
                for s in self._collection
                if (
                    self._ignore_visibility or (not s.is_inactive and s.is_icon_visible)
                )
                and (not s.links or s.is_multi_input)
            ]
        return [
            s for s in self._collection if self._ignore_visibility or s.is_icon_visible
        ]

    def _best_match(self, socket_type: str) -> NodeSocket:
        """Find the best compatible socket for the given type."""
        from ..types import SOCKET_COMPATIBILITY

        compatible = SOCKET_COMPATIBILITY.get(socket_type, ())
        possible = [s for s in self._available if s.type in compatible]
        if possible:
            possible.sort(key=lambda x: compatible.index(x.type))
            return possible[0]
        raise SocketError(
            f"No compatible {self._direction} socket found for type "
            f"{socket_type} on {self._node.name}"
        )

    def _values(self) -> "list[Socket]":
        """All visible sockets as Sockets.

        Uses node-level visibility rules regardless of ``ignore_visibility`` —
        see ``_visible_sockets`` for rationale.
        """
        return [_wrap_socket(s) for s in self._visible_sockets()]

    def _items(self) -> "list[tuple[str, Socket]]":
        """All visible sockets as (name, Socket) pairs.

        Uses node-level visibility rules regardless of ``ignore_visibility`` —
        see ``_visible_sockets`` for rationale.
        """
        return [(s.name, _wrap_socket(s)) for s in self._visible_sockets()]

    def _keys(self) -> list[str]:
        """All visible socket names."""
        return [name for name, _ in self._items()]

    def __len__(self) -> int:
        return len(self._items())

    def __iter__(self) -> Iterator["Socket"]:
        return iter(self._values())

    def __getattr__(self, name: str) -> "Socket":
        """Dynamic socket access by normalised attribute name.

        Converts ``node.o.base_color`` to ``self._get("Base Color")``.
        Raises ``AttributeError`` (not ``RuntimeError``) so that ``hasattr``
        and ``getattr(..., default)`` behave correctly.
        """
        if name.startswith("_"):
            raise AttributeError(name)
        try:
            return self._get(name)
        except RuntimeError:
            raise AttributeError(
                f"Socket '{name}' not found on {self._direction} accessor"
            )
