"""OHR — Object for Harmony Representation.

Frozen dataclass implementing :class:`FlexohrProtocol`.  Paradigm-independent:
the body can contain any :class:`Component` values and/or nested OHRs.
"""

from __future__ import annotations

import dataclasses
from dataclasses import dataclass, replace
from enum import StrEnum
from typing import Any, Iterator

from flexohr.core.component import (
    AbsentComponent,
    Component,
    UndefinedComponent,
    _coerce_props,
)
from flexohr.harmony.enums import (
    EMPTY_PROPS,
    FrozenProperties,
    KeyValue,
    _camel_to_snake,
    _property_key,
    get_enum_type,
    get_value_type,
    get_value_type_name,
)


@dataclass(frozen=True, slots=True)
class OHR:
    """Immutable recursive container of Components and/or nested OHRs.

    Modification via :meth:`with_` (returns a new instance).
    """

    reference_component: Component | AbsentComponent | UndefinedComponent | None = None
    reference_ohr: OHR | None = None
    body: tuple[Component | OHR, ...] = ()
    ordered: bool = False
    horizontal: bool = False
    properties: FrozenProperties = EMPTY_PROPS

    def __post_init__(self) -> None:
        if not isinstance(self.body, tuple):
            object.__setattr__(self, "body", tuple(self.body))
        if not isinstance(self.properties, tuple):
            object.__setattr__(self, "properties", _coerce_props(self.properties))

    # ── Modification ──

    def with_(self, **kwargs: Any) -> OHR:
        """Return a new OHR with specified fields replaced.

        Supports ``components=Where(...).set(...)`` for targeted body edits,
        ``inversion=N`` for setting which component is the bass, and dict
        values for *properties*.

        ``inversion`` accepts an ``int`` (0-based index) or an
        :class:`~flexohr.types.harmony_enums.Inversion` enum member.  When
        given, this:

        1. Removes any existing ``ToneFunction.bass`` from all body
           components.
        2. Adds ``ToneFunction.bass`` to the component whose
           ``ToneFunction`` matches the inversion mapping (0 = root,
           1 = third, 2 = fifth, 3 = seventh).
        3. Adds the ``Inversion`` enum member to the OHR's properties.
        """
        if "components" in kwargs and isinstance(kwargs["components"], WhereSet):
            where_set: WhereSet = kwargs.pop("components")
            kwargs["body"] = self._apply_where_set(where_set)
        if "properties" in kwargs:
            p = kwargs["properties"]
            if isinstance(p, dict):
                kwargs["properties"] = _coerce_props(p)
            elif isinstance(p, frozenset):
                kwargs["properties"] = _coerce_props(p)
        if "body" in kwargs and not isinstance(kwargs["body"], tuple):
            kwargs["body"] = tuple(kwargs["body"])

        # Handle inversion= kwarg: sets bass ToneFunction on the right component
        if "inversion" in kwargs:
            inv_arg = kwargs.pop("inversion")
            result = self._apply_inversion(inv_arg, kwargs)
            return result

        return replace(self, **kwargs)

    def _apply_inversion(self, inv_arg: Any, extra_kwargs: dict[str, Any]) -> OHR:
        """Apply inversion to this OHR, returning a new instance.

        *inv_arg* can be an ``int`` (0-based) or an ``Inversion`` enum
        member.  The method:

        1. Strips ``ToneFunction.bass`` from all body components.
        2. Adds ``ToneFunction.bass`` to the component whose ToneFunction
           matches the inversion index (0=root, 1=third, 2=fifth, 3=seventh).
        3. Adds ``Inversion`` to the OHR properties (replacing any existing
           ``Inversion``).
        """
        from flexohr.harmony.chord_tables import (
            INDEX_TO_INVERSION,
            INVERSION_INDEX_TO_TONE_FUNCTION,
            INVERSION_TO_INDEX,
        )
        from flexohr.harmony.harmony_enums import Inversion

        # Normalize to int index and Inversion enum
        if isinstance(inv_arg, Inversion):
            inv_index = INVERSION_TO_INDEX[inv_arg]
            inv_enum = inv_arg
        elif isinstance(inv_arg, int):
            inv_index = inv_arg
            inv_enum = INDEX_TO_INVERSION.get(inv_arg)
            if inv_enum is None:
                raise ValueError(
                    f"Inversion index {inv_arg} out of range; " f"valid indices: {sorted(INDEX_TO_INVERSION.keys())}"
                )
        else:
            raise TypeError(f"inversion= must be an int or Inversion enum, got {type(inv_arg).__name__}")

        # Determine which ToneFunction becomes bass
        target_tf = INVERSION_INDEX_TO_TONE_FUNCTION.get(inv_index)
        if target_tf is None:
            raise ValueError(f"No ToneFunction mapping for inversion index {inv_index}")

        # Use the body from extra_kwargs if already modified, else self.body
        body = extra_kwargs.pop("body", self.body)

        # Rebuild body: strip old bass, add bass to target
        new_body: list[Component | OHR] = []
        for item in body:
            if isinstance(item, Component):
                new_item = _strip_bass(item)
                if _has_tone_function(new_item, target_tf):
                    new_item = _add_bass(new_item)
                new_body.append(new_item)
            else:
                new_body.append(item)

        # Update OHR properties: replace any existing Inversion with the new one
        new_ohr_props = tuple(p for p in self.properties if not isinstance(p, Inversion))
        new_ohr_props = new_ohr_props + (inv_enum,)

        # Merge any remaining extra_kwargs
        extra_kwargs["body"] = tuple(new_body)
        if "properties" not in extra_kwargs:
            extra_kwargs["properties"] = new_ohr_props
        else:
            # If caller also passed properties, append inversion to those
            caller_props = extra_kwargs["properties"]
            if isinstance(caller_props, dict):
                caller_props = _coerce_props(caller_props)
            elif isinstance(caller_props, frozenset):
                caller_props = _coerce_props(caller_props)
            # Strip any existing Inversion from caller props too
            caller_props = tuple(p for p in caller_props if not isinstance(p, Inversion))
            extra_kwargs["properties"] = caller_props + (inv_enum,)

        return replace(self, **extra_kwargs)

    def _apply_where_set(self, where_set: WhereSet) -> tuple[Component | OHR, ...]:
        from flexohr.harmony.enums import props as _props

        new_body: list[Component | OHR] = []
        for item in self.body:
            if isinstance(item, Component) and where_set.where.matches(item):
                extra = _props(**where_set.updates)
                new_body.append(replace(item, properties=item.properties + extra))
            else:
                new_body.append(item)
        return tuple(new_body)

    # ── Body access ──

    def components(self, **filters: Any) -> tuple[Component, ...]:
        """Return body Components, optionally filtered by property values."""
        result = [item for item in self.body if isinstance(item, Component)]
        if filters:
            result = [c for c in result if _matches_filters(c, filters)]
        return tuple(result)

    def nested_ohrs(self) -> tuple[OHR, ...]:
        """Return nested OHRs from the body."""
        return tuple(item for item in self.body if isinstance(item, OHR))

    # ── Resolution ──

    def resolve(self) -> OHR:
        """Return a new OHR with values resolved where possible.

        Resolution replaces relative values (Vectors, Selectors) with
        absolute values by combining them with available references.  The
        dispatch is based on the **type** of each component's ``.value``:

        * **Vector** — resolves against a component (``point + vector``).
          Tries (in order): this OHR's resolved ``reference_component``,
          then the ``reference_ohr``'s reference_component, then walks up
          to the parent.
        * **Selector** — resolves against a collection's body.  Tries:
          this OHR's resolved ``reference_ohr``, then the parent's.
        * **Point** / other — already absolute; returned unchanged.

        If no suitable reference is found, the component is returned
        unchanged (no error, no warning).  The result is always a new OHR.
        """

        # Step 1: resolve the reference_component itself (it may be relative)
        resolved_ref_comp = self._resolve_ref_comp()

        # Step 2: resolve each body item
        resolved_body: list[Component | OHR] = []
        for item in self.body:
            if isinstance(item, OHR):
                resolved_body.append(item.resolve())
            elif isinstance(item, Component):
                resolved_body.append(self._resolve_component(item, resolved_ref_comp))
            else:
                resolved_body.append(item)

        return replace(self, body=tuple(resolved_body), reference_component=resolved_ref_comp)

    def _resolve_ref_comp(self) -> Component | None:
        """Resolve this OHR's reference_component, returning a Component or None.

        If the reference_component's value is a Vector, it is resolved
        against the reference_ohr's reference_component.  If it is a
        Selector, it is resolved against the reference_ohr's body.
        If it is already a Point or None, it is returned as-is.
        """
        from flexohr.core.abstract import Selector, Vector

        rc = self.reference_component
        if rc is None:
            return None
        if rc.value is None:
            return rc

        val = rc.value

        if isinstance(val, Vector):
            ref_point = self._find_reference_point()
            if ref_point is not None:
                try:
                    return rc.with_(value=ref_point + val)
                except Exception:
                    return rc
            return rc

        if isinstance(val, Selector):
            ref_ohr = self.reference_ohr
            if ref_ohr is not None:
                try:
                    resolved_val = val.resolve_against(ref_ohr)
                    # If selector resolved to a Vector, resolve it further
                    if isinstance(resolved_val, Vector):
                        ref_point = self._find_reference_point()
                        if ref_point is not None:
                            resolved_val = ref_point + resolved_val
                    return rc.with_(value=resolved_val)
                except Exception:
                    return rc
            return rc

        # Point or unknown type — already absolute
        return rc

    def _resolve_component(self, comp: Component, resolved_ref_comp: Component | None) -> Component:
        """Resolve a single body component's value."""
        from flexohr.core.abstract import Selector, Vector
        from flexohr.paradigms.pitchspace.scale_degrees import SpecificScaleDegree

        val = comp.value
        if val is None:
            return comp

        # SpecificScaleDegree: treat its .sic as a Vector
        if isinstance(val, SpecificScaleDegree):
            ref_point = None
            if resolved_ref_comp is not None and resolved_ref_comp.value is not None:
                ref_point = resolved_ref_comp.value
            if ref_point is None and self.reference_ohr is not None:
                ref_ohr_rc = self.reference_ohr.reference_component
                if ref_ohr_rc is not None:
                    ref_point = ref_ohr_rc.value
            if ref_point is not None:
                try:
                    return comp.with_(value=ref_point + val.sic)
                except Exception:
                    return comp
            return comp

        # Vector: resolve against a component (ref_comp.value + val)
        if isinstance(val, Vector):
            ref_point = None
            if resolved_ref_comp is not None and resolved_ref_comp.value is not None:
                ref_point = resolved_ref_comp.value
            # Fallback: reference_ohr's reference_component
            if ref_point is None and self.reference_ohr is not None:
                ref_ohr_rc = self.reference_ohr.reference_component
                if ref_ohr_rc is not None:
                    ref_point = ref_ohr_rc.value
            # TODO: parent inheritance (walk up parent chain)
            if ref_point is not None:
                try:
                    return comp.with_(value=ref_point + val)
                except Exception:
                    return comp
            return comp

        # Selector: resolve against the reference OHR
        if isinstance(val, Selector):
            ref_ohr = self.reference_ohr
            if ref_ohr is not None:
                try:
                    resolved_val = val.resolve_against(ref_ohr)
                    # If selector resolved to a Vector, resolve further
                    if isinstance(resolved_val, Vector):
                        ref_point = None
                        if resolved_ref_comp is not None and resolved_ref_comp.value is not None:
                            ref_point = resolved_ref_comp.value
                        if ref_point is not None:
                            resolved_val = ref_point + resolved_val
                    return comp.with_(value=resolved_val)
                except Exception:
                    return comp
            return comp

        # Point or unknown: no resolution needed
        return comp

    def _find_reference_point(self) -> Any | None:
        """Find the nearest reference point value for resolving vectors.

        Looks in: reference_ohr.reference_component.
        TODO: walk parent chain.
        """
        if self.reference_ohr is not None:
            rc = self.reference_ohr.reference_component
            if rc is not None and rc.value is not None:
                return rc.value
        return None

    def _find_reference_collection_body(self) -> tuple | None:
        """Find the nearest reference collection body for resolving selectors.

        Looks in: reference_ohr.body.
        TODO: walk parent chain.
        """
        if self.reference_ohr is not None and self.reference_ohr.body:
            return self.reference_ohr.body
        return None

    # ── Properties ──

    def get_property(self, key: str, default: Any = None) -> Any:
        """Look up OHR-level properties by *key*.

        Returns a single value when exactly one match exists, a tuple when
        multiple matches exist, or *default* when no match is found.
        """
        matches = []
        for item in self.properties:
            try:
                if _property_key(item) == key:
                    if isinstance(item, KeyValue):
                        matches.append(item.value)
                    else:
                        matches.append(item)
            except TypeError:
                continue
        if not matches:
            return default
        if len(matches) == 1:
            return matches[0]
        return tuple(matches)

    # ── Iteration ──

    def __iter__(self) -> Iterator[Component | OHR]:
        return iter(self.body)

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

    # ── Display helpers ──

    def _collection_shorthand(self) -> str | None:
        """Return a shorthand string if this OHR matches a known CollectionType.

        Returns e.g. ``"A:major"`` if the OHR was built from
        ``OHR.from_collection_type(SPC("A"), CollectionType.major)``, or
        ``None`` if no match is found.
        """
        ct = self.get_property("collection_type")
        if ct is None:
            return None
        ref = self.reference_component
        if ref is None:
            return None
        val = ref.value
        name = getattr(val, "name", repr(val))
        ct_name = ct.name if hasattr(ct, "name") else str(ct)
        return f"{name}:{ct_name}"

    # ── HTML representation ──

    def _repr_html_(self) -> str:
        parts = []
        parts.append(
            '<div style="font-family:monospace;border:1px solid #d1d5db;'
            "border-radius:6px;padding:8px 12px;margin:4px 0;"
            'background:#f9fafb;display:inline-block">'
        )
        parts.append('<div style="font-weight:bold;color:#374151;margin-bottom:4px">OHR</div>')

        # Reference component
        if self.reference_component is not None:
            ref_html = (
                self.reference_component._repr_html_()
                if hasattr(self.reference_component, "_repr_html_")
                else repr(self.reference_component)
            )
            parts.append(f'<div style="margin-left:12px"><span style="color:#6b7280">ref:</span> {ref_html}</div>')

        # Reference OHR — display as shorthand if it matches a CollectionType
        if self.reference_ohr is not None:
            shorthand = self.reference_ohr._collection_shorthand()
            if shorthand:
                ref_ohr_html = (
                    f'<span style="font-family:monospace;font-weight:bold;' f'color:#7c3aed">{shorthand}</span>'
                )
            else:
                ref_ohr_html = (
                    self.reference_ohr._repr_html_()
                    if hasattr(self.reference_ohr, "_repr_html_")
                    else repr(self.reference_ohr)
                )
            parts.append(
                f'<div style="margin-left:12px"><span style="color:#7c3aed">ref_ohr:</span> {ref_ohr_html}</div>'
            )

        # Body
        if self.body:
            parts.append('<div style="margin-left:12px"><span style="color:#6b7280">body:</span></div>')
            for item in self.body:
                item_html = item._repr_html_() if hasattr(item, "_repr_html_") else repr(item)
                parts.append(f'<div style="margin-left:24px">{item_html}</div>')

        # Properties as badges (grouped by key)
        if self.properties:
            from flexohr.core.component import _props_badges_html

            ohr_bg_map = {"globalkey": "#fecaca", "localkey": "#ede9fe"}
            badges = _props_badges_html(
                self.properties,
                bg="#dbeafe",
                bg_map=ohr_bg_map,
            )
            parts.append(f'<div style="margin-left:12px;margin-top:4px">{badges}</div>')

        # Flags
        flags = []
        if self.ordered:
            flags.append("ordered")
        if self.horizontal:
            flags.append("horizontal")
        if flags:
            flag_html = " ".join(
                f'<span style="background:#fef3c7;border-radius:3px;padding:1px 5px;' f'font-size:0.8em">{f}</span>'
                for f in flags
            )
            parts.append(f'<div style="margin-left:12px;margin-top:2px">{flag_html}</div>')

        parts.append("</div>")
        return "\n".join(parts)

    # ── Serialization ──

    def to_dict(self) -> dict[str, Any]:
        """Serialize to a verbose, self-describing dict (JSON orientation model)."""
        result: dict[str, Any] = {"type": "ohr"}
        result["reference_component"] = (
            _component_to_dict(self.reference_component) if self.reference_component is not None else None
        )
        result["reference_ohr"] = self.reference_ohr.to_dict() if self.reference_ohr is not None else None
        result["body"] = [item.to_dict() if isinstance(item, OHR) else _component_to_dict(item) for item in self.body]
        result["ordered"] = self.ordered
        result["horizontal"] = self.horizontal
        if self.properties:
            result["properties"] = _properties_to_json(self.properties)
        return result

    @classmethod
    def from_(cls, *args: Any, **kwargs: Any) -> OHR:
        """Convenience dispatch: build an OHR from typed arguments.

        Inspects positional arguments by type and delegates to the
        appropriate ``from_*`` classmethod.  Keyword arguments are passed
        through to the target method.

        Currently dispatches on:

        * :class:`~flexohr.types.harmony_enums.ChordQuality`
          → :meth:`from_chord_quality`
        * :class:`~flexohr.types.harmony_enums.CollectionType`
          → :meth:`from_collection_type`

        Raises :class:`TypeError` when no positional argument matches a
        known dispatch type.
        """
        from flexohr.harmony.harmony_enums import ChordQuality, CollectionType

        remaining: list[Any] = []
        dispatch_target = None
        dispatch_key = None

        for arg in args:
            if isinstance(arg, ChordQuality):
                dispatch_target = cls.from_chord_quality
                dispatch_key = arg
            elif isinstance(arg, CollectionType):
                dispatch_target = cls.from_collection_type
                dispatch_key = arg
            else:
                remaining.append(arg)

        if dispatch_target is None:
            raise TypeError(
                f"OHR.from_() requires at least one argument whose type "
                f"determines the dispatch target (e.g. ChordQuality, "
                f"CollectionType).  Got: {[type(a).__name__ for a in args]}"
            )

        return dispatch_target(dispatch_key, *remaining, **kwargs)

    @classmethod
    def from_collection_type(cls, collection_type: Any, root: Any) -> OHR:
        """Build a scale/collection OHR from a CollectionType and root pitch.

        Looks up ``COLLECTION_TYPE_INTERVALS`` for the given collection type,
        creates Components for each interval, and sets ``ordered=True``.

        Parameters
        ----------
        root : pitchspace pitch class (e.g. ``SPC("A")``)
            The root/tonic of the collection.
        collection_type : CollectionType
            The type of collection (e.g. ``CollectionType.major``).

        Returns
        -------
        OHR
            An ordered OHR with the root as reference_component and the
            collection intervals as body.
        """
        from flexohr.harmony.chord_tables import COLLECTION_TYPE_INTERVALS
        from flexohr.harmony.enums import props as _props

        intervals = COLLECTION_TYPE_INTERVALS[collection_type]
        return cls(
            reference_component=Component(root),
            body=tuple(Component(sic) for sic in intervals),
            ordered=True,
            properties=_props(collection_type),
        )

    @classmethod
    def from_chord_quality(
        cls,
        quality: Any,
        root: Any = None,
        reference_ohr: OHR | None = None,
        inversion: Any = None,
        bass: Any = None,
        **extra_props: Any,
    ) -> OHR:
        """Build a chord OHR from a ChordQuality and optional root/bass.

        Looks up ``CHORD_QUALITY_INTERVALS`` for the given quality, creates
        Components with ToneFunction properties, and optionally sets a
        reference_ohr (e.g. a scale context).

        When *root* is ``None`` and *bass* is ``None``, the resulting OHR has
        no reference component (``reference_component=None``), producing an
        abstract chord template whose body contains interval classes (SICs).
        This OHR can later inherit a reference from a parent OHR.

        Parameters
        ----------
        quality : ChordQuality
            The chord quality (e.g. ``ChordQuality.major_triad``).
        root : pitchspace pitch class or None, optional
            The root of the chord (e.g. ``SPC("C")``).  When ``None``, the
            OHR is abstract — no reference component is set.
        reference_ohr : OHR, optional
            A reference OHR providing tonal context (e.g. a scale).
        inversion : int or Inversion or None, optional
            Inversion index (0-based) or ``Inversion`` enum member.  When
            provided, the component whose ToneFunction matches the inversion
            gets ``ToneFunction.bass`` added.  Also adds ``Inversion`` to
            OHR-level properties.
        bass : pitchspace pitch class or None, optional
            The bass note of the chord.  When provided together with
            *inversion*, the intervals are rebased so that the bass
            interval becomes P1 and the bass pitch becomes the
            ``reference_component``.  Requires *inversion* to be set.
        **extra_props
            Additional properties (e.g. ``numeral="V"``).

        Returns
        -------
        OHR
            An OHR with intervals as body and the chord quality in properties.

        Raises
        ------
        ValueError
            If *bass* is given without *inversion*.
        """
        from flexohr.harmony.chord_tables import (
            CHORD_QUALITY_INTERVALS,
            INDEX_TO_INVERSION,
            INVERSION_INDEX_TO_TONE_FUNCTION,
            INVERSION_TO_INDEX,
        )
        from flexohr.harmony.enums import props as _props
        from flexohr.harmony.harmony_enums import Inversion as InversionEnum
        from flexohr.harmony.harmony_enums import (
            ToneFunction,
        )

        # Validate bass/inversion co-occurrence
        if bass is not None and inversion is None:
            raise ValueError(
                "bass= requires inversion= to be specified.  Without "
                "inversion, the meaning of a bass pitch is ambiguous."
            )

        # Look up intervals for this quality
        intervals = CHORD_QUALITY_INTERVALS[quality]

        # Normalize inversion
        inv_index: int | None = None
        inv_enum: InversionEnum | None = None
        if inversion is not None:
            if isinstance(inversion, InversionEnum):
                inv_index = INVERSION_TO_INDEX[inversion]
                inv_enum = inversion
            elif isinstance(inversion, int):
                inv_index = inversion
                inv_enum = INDEX_TO_INVERSION.get(inversion)
                if inv_enum is None:
                    raise ValueError(
                        f"Inversion index {inversion} out of range; " f"valid: {sorted(INDEX_TO_INVERSION.keys())}"
                    )
            else:
                raise TypeError(f"inversion= must be int or Inversion enum, " f"got {type(inversion).__name__}")

        # ── Case: bass= given (with inversion) — rebase intervals ──
        if bass is not None and inv_index is not None:
            # The bass interval is the one at position inv_index in the
            # chord's interval tuple.
            bass_sic = intervals[inv_index][0]

            # Rebase: subtract bass_sic from each interval.
            # new_interval = old_interval - bass_sic
            bass_tf = INVERSION_INDEX_TO_TONE_FUNCTION[inv_index]
            body = tuple(
                Component(
                    sic - bass_sic,
                    properties=_props(*tfs, *((ToneFunction.bass,) if bass_tf in tfs else ())),
                )
                for sic, tfs in intervals
            )
            ref_comp = Component(bass)
            ohr_props = _props(quality, inv_enum, **extra_props)

            result = cls(
                reference_component=ref_comp,
                reference_ohr=reference_ohr,
                body=body,
                properties=ohr_props,
            )

            # If root is also given, store it as a property for validation
            # (the ref_comp is the bass, not the root, in this case)
            # Validation of root/bass consistency happens via validators.
            return result

        # ── Case: root= given (possibly with inversion) ──
        body = tuple(Component(sic, properties=_props(*tfs)) for sic, tfs in intervals)
        ref_comp = Component(root) if root is not None else None
        ohr_extra = {}
        if inv_enum is not None:
            ohr_extra["inversion_enum"] = inv_enum

        ohr = cls(
            reference_component=ref_comp,
            reference_ohr=reference_ohr,
            body=body,
            properties=_props(quality, **extra_props),
        )

        # Apply inversion if given (adds bass ToneFunction + Inversion prop)
        if inv_index is not None:
            ohr = ohr.with_(inversion=inv_index)

        return ohr

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> OHR:
        """Deserialize from dict."""
        body_items: list[Component | OHR] = []
        for item in d.get("body", []):
            if item.get("type") == "ohr":
                body_items.append(cls.from_dict(item))
            else:
                body_items.append(_component_from_dict(item))

        ref_comp_data = d.get("reference_component")
        ref_comp = _component_from_dict(ref_comp_data) if ref_comp_data is not None else None
        ref_ohr_data = d.get("reference_ohr")
        ref_ohr = cls.from_dict(ref_ohr_data) if ref_ohr_data is not None else None

        raw_props = d.get("properties")
        if raw_props is not None:
            properties = _properties_from_json(raw_props)
        else:
            properties = EMPTY_PROPS

        return cls(
            reference_component=ref_comp,
            reference_ohr=ref_ohr,
            body=tuple(body_items),
            ordered=d.get("ordered", False),
            horizontal=d.get("horizontal", False),
            properties=properties,
        )


# ── Serialization helpers ──


def _property_item_to_json(item: Any) -> dict[str, Any]:
    """Serialize a single property item to JSON-compatible dict."""
    if isinstance(item, KeyValue):
        return {"type": "key_value", "key": item.key, "value": item.value}
    if isinstance(item, StrEnum):
        return {"type": "enum", "enum_class": type(item).__name__, "value": item.value}
    if dataclasses.is_dataclass(item) and not isinstance(item, type):
        return {"type": "object", "class": type(item).__name__, "fields": dataclasses.asdict(item)}
    return {"type": "unknown", "value": repr(item)}


def _properties_to_json(properties: FrozenProperties) -> list[dict[str, Any]]:
    """Serialize properties tuple to JSON-compatible list."""
    return [_property_item_to_json(item) for item in properties]


def _properties_from_json(data: Any) -> FrozenProperties:
    """Deserialize properties from JSON.

    Accepts:
    - list of dicts (new format)
    - dict of {key: value} (backward compat: old frozenset format)
    """
    if isinstance(data, dict):
        # Backward compat: old {key: value} format
        result = []
        for key, value in data.items():
            result.append(KeyValue(key=key, value=value))
        return tuple(result)
    if isinstance(data, list):
        result = []
        for item in data:
            if item.get("type") == "key_value":
                result.append(KeyValue(key=item["key"], value=item["value"]))
            elif item.get("type") == "enum":
                enum_cls = get_enum_type(item["enum_class"])
                if enum_cls is not None:
                    result.append(enum_cls(item["value"]))
                else:
                    # Fallback: store as KeyValue when enum class is not registered
                    result.append(KeyValue(key=_camel_to_snake(item["enum_class"]), value=item["value"]))
            elif item.get("type") == "object":
                result.append(KeyValue(key=_camel_to_snake(item["class"]), value=item["fields"]))
            else:
                result.append(KeyValue(key="unknown", value=item.get("value")))
        return tuple(result)
    return EMPTY_PROPS


def _serialize_value(value: Any) -> tuple[Any, str | None]:
    """Serialize a component value, returning (json_value, type_name).

    For registered types, returns a JSON-compatible representation plus the
    registry key.  For plain Python types, returns the value as-is with None.
    """
    if value is None:
        return None, None
    type_name = get_value_type_name(value)
    if type_name is None:
        return value, None
    # PairOfInts subclass (Pitch / Interval): store as [class_int, octave_int]
    if isinstance(value, tuple):
        return [int(value[0]), int(value[1])], type_name
    # IntType subclass (PitchClass / IntervalClass): store as plain int
    return int(value), type_name


def _reconstruct_value(cls: type, raw: Any) -> Any:
    """Reconstruct a typed value from its JSON representation and class."""
    if isinstance(raw, list) and len(raw) == 2:
        # PairOfInts: pass as tuple
        return cls(tuple(raw))
    # IntType: pass directly
    return cls(raw)


def _component_to_dict(c: Component) -> dict[str, Any]:
    value, value_type = _serialize_value(c.value)
    result: dict[str, Any] = {
        "type": "component",
        "value": value,
        "status": c.status.value,
    }
    if value_type is not None:
        result["value_type"] = value_type
    if c.properties:
        result["properties"] = _properties_to_json(c.properties)
    if isinstance(c, AbsentComponent):
        result["type"] = "absent_component"
        result["reason"] = c.reason
    elif isinstance(c, UndefinedComponent):
        result["type"] = "undefined_component"
    return result


def _component_from_dict(d: dict[str, Any]) -> Component:
    raw_props = d.get("properties")
    if raw_props is not None:
        properties = _properties_from_json(raw_props)
    else:
        properties = EMPTY_PROPS

    # Reconstruct typed value if value_type is present
    raw_value = d.get("value")
    value_type_name = d.get("value_type")
    if value_type_name is not None:
        cls = get_value_type(value_type_name)
        if cls is not None:
            value = _reconstruct_value(cls, raw_value)
        else:
            value = raw_value  # fallback: unregistered type
    else:
        value = raw_value

    comp_type = d.get("type", "component")
    if comp_type == "absent_component":
        return AbsentComponent(
            value=value,
            properties=properties,
            reason=d.get("reason", ""),
        )
    if comp_type == "undefined_component":
        return UndefinedComponent(properties=properties)
    return Component(
        value=value,
        properties=properties,
    )


# ── Where / WhereSet ──


def _matches_filters(component: Component, filters: dict[str, Any]) -> bool:
    """Check if component's properties match all filter criteria.

    For each filter key, checks if the component has a property matching
    that key with the given value. Works with both enum members and KeyValue items.
    """
    for key, expected_value in filters.items():
        prop_val = component.get_property(key)
        if prop_val is None:
            return False
        # If get_property returned a tuple (multiple matches), check if expected is in it
        if isinstance(prop_val, tuple):
            if expected_value not in prop_val:
                return False
        elif prop_val != expected_value:
            return False
    return True


# ── Inversion helpers ──


def _strip_bass(comp: Component) -> Component:
    """Return a copy of *comp* with ToneFunction.bass removed from properties."""
    from flexohr.harmony.harmony_enums import ToneFunction

    new_props = tuple(p for p in comp.properties if p is not ToneFunction.bass)
    if len(new_props) == len(comp.properties):
        return comp  # no change
    return replace(comp, properties=new_props)


def _has_tone_function(comp: Component, tf: Any) -> bool:
    """Check if *comp* has a specific ToneFunction in its properties."""
    return any(p is tf for p in comp.properties)


def _add_bass(comp: Component) -> Component:
    """Return a copy of *comp* with ToneFunction.bass appended to properties."""
    from flexohr.harmony.harmony_enums import ToneFunction

    return replace(comp, properties=comp.properties + (ToneFunction.bass,))


class Where:
    """Filter for targeting components in :meth:`OHR.with_`.

    Usage::

        ohr.with_(components=Where(tone_function="root").set(octave=4))
    """

    __slots__ = ("_filters",)

    def __init__(self, **filters: Any) -> None:
        self._filters = filters

    def matches(self, component: Component) -> bool:
        """Return True if *component*'s properties match all filters."""
        return _matches_filters(component, self._filters)

    def set(self, **new_props: Any) -> WhereSet:
        """Create a :class:`WhereSet` that updates matching components."""
        return WhereSet(where=self, updates=new_props)


@dataclass(frozen=True)
class WhereSet:
    """A Where filter paired with property updates, for use with :meth:`OHR.with_`."""

    where: Where
    updates: dict[str, Any]
