# src/svy/core/design.py
from __future__ import annotations

import logging
import re

from typing import (
    Any,
    Self,
    Sequence,
    TypeGuard,
    TypeVar,
    cast,
    overload,
)

import msgspec

from svy.core.enumerations import EstimationMethod


log = logging.getLogger(__name__)


# =============================================================================
# Types & Sentinels
# =============================================================================


class _MissingType:
    pass


_MISSING = _MissingType()


def _is_MissingType(x: Any) -> TypeGuard[_MissingType]:
    return x is _MISSING


# =============================================================================
# Replicate Weights (Strict Configuration)
# =============================================================================


class RepWeights(msgspec.Struct, frozen=True):
    """
    Strict definition of Replicate Weights.
    This object should ONLY be instantiated if a valid replicate design exists.
    """

    # Required Fields (No Defaults)
    method: EstimationMethod
    prefix: str
    n_reps: int

    # Optional / defaulted fields
    fay_coef: float = 0.0
    df: int | None = None  # None = "Calculate from data", Int = "User Override"
    padding: int | None = None  # None = auto-detect, 0 = no padding, >0 = zero-pad width

    def __post_init__(self):
        """
        Enforce strict statistical validity upon initialization.
        """
        # 1. Method Validation: Taylor is not a replicate method.
        valid_methods = (
            EstimationMethod.BRR,
            EstimationMethod.BOOTSTRAP,
            EstimationMethod.JACKKNIFE,
            EstimationMethod.SDR,
        )
        if self.method not in valid_methods:
            raise ValueError(
                f"Method '{self.method}' is not a valid replication method "
                f"(expected one of: {[m.value for m in valid_methods]})."
            )

        # 2. Prefix Validation: Must be a non-empty string.
        if not self.prefix or not self.prefix.strip():
            raise ValueError("RepWeights 'prefix' cannot be empty or whitespace.")

        # 3. Replicate Count Validation: Must be >= 2.
        if self.n_reps < 2:
            raise ValueError(f"n_reps must be >= 2. Got {self.n_reps}.")

        # 4. Fay Coefficient Validation: Sanity check (usually [0, 1]).
        if self.fay_coef < 0:
            raise ValueError(f"fay_coef cannot be negative. Got {self.fay_coef}.")

        # 5. Degrees of Freedom Validation: Must be positive if manually set.
        if self.df is not None and self.df <= 0:
            raise ValueError(f"df must be > 0. Got {self.df}.")

        # 6. Padding Validation: Must be non-negative if set.
        if self.padding is not None and self.padding < 0:
            raise ValueError(f"padding must be >= 0. Got {self.padding}.")

    def _detect_padding(self, data_columns: Sequence[str]) -> int:
        """
        Detect zero-padding width from existing column names in data.

        Parameters
        ----------
        data_columns : Sequence[str]
            Column names from the actual data

        Returns
        -------
        int
            Detected padding width (0 if no padding detected)

        Examples
        --------
        btwt1, btwt2, ... -> 0 (no padding)
        btwt01, btwt02, ... -> 2 (2-digit padding)
        btwt001, btwt002, ... -> 3 (3-digit padding)
        """
        pattern = re.compile(rf"^{re.escape(self.prefix)}(\d+)$")
        max_padding = 0

        for col in data_columns:
            match = pattern.match(col)
            if match:
                num_str = match.group(1)
                # Zero-padded numbers start with '0' and have multiple digits
                if len(num_str) > 1 and num_str[0] == "0":
                    max_padding = max(max_padding, len(num_str))

        return max_padding

    def _generate_columns(self, padding: int) -> list[str]:
        """
        Generate column names with specified padding.

        Parameters
        ----------
        padding : int
            Zero-padding width (0 = no padding)

        Returns
        -------
        list[str]
            Column names like ['btwt1', 'btwt2', ...] or ['btwt001', 'btwt002', ...]
        """
        if padding > 0:
            return [f"{self.prefix}{i:0{padding}d}" for i in range(1, self.n_reps + 1)]
        else:
            return [f"{self.prefix}{i}" for i in range(1, self.n_reps + 1)]

    def columns_from_data(self, data_columns: Sequence[str]) -> list[str]:
        """
        Generate column names, auto-detecting padding from actual data columns.

        This is the preferred method when validating against actual data,
        as it handles both padded and unpadded column naming conventions.

        Parameters
        ----------
        data_columns : Sequence[str]
            Column names from the actual data

        Returns
        -------
        list[str]
            Expected replicate weight column names with appropriate padding

        Examples
        --------
        >>> # Data has btwt001, btwt002, ...
        >>> rw = RepWeights(method=EstMethod.JACKKNIFE, prefix="btwt", n_reps=3)
        >>> rw.columns_from_data(["btwt001", "btwt002", "btwt003"])
        ['btwt001', 'btwt002', 'btwt003']

        >>> # Data has btwt1, btwt2, ...
        >>> rw.columns_from_data(["btwt1", "btwt2", "btwt3"])
        ['btwt1', 'btwt2', 'btwt3']
        """
        # Use explicit padding if provided
        if self.padding is not None:
            return self._generate_columns(self.padding)

        # Auto-detect padding from data
        detected_padding = self._detect_padding(data_columns)
        return self._generate_columns(detected_padding)

    @property
    def columns(self) -> list[str]:
        """
        Dynamically generate the list of expected column names.

        WARNING: This uses explicit padding if set, otherwise assumes no padding.
        For validation against actual data, use columns_from_data() instead.

        Returns
        -------
        list[str]
            Column names with padding specified in initialization, or no padding
        """
        padding = self.padding if self.padding is not None else 0
        return self._generate_columns(padding)

    def __repr__(self) -> str:
        parts = [f"method={self.method}", f"prefix='{self.prefix}'", f"n_reps={self.n_reps}"]

        # Only show optional params if they deviate from defaults/standard
        if self.df is not None:
            parts.append(f"df={self.df}")

        if self.fay_coef != 0.0 or self.method == EstimationMethod.BRR:
            parts.append(f"fay={self.fay_coef}")

        if self.padding is not None:
            parts.append(f"padding={self.padding}")

        return f"RepWeights({', '.join(parts)})"


# =============================================================================
# Resolvers (Internal Helpers)
# =============================================================================

T = TypeVar("T")


@overload
def _pick(current: str, new: str | _MissingType) -> str: ...
@overload
def _pick(
    current: RepWeights | None, new: RepWeights | None | _MissingType
) -> RepWeights | None: ...
@overload
def _pick(current: bool, new: bool | _MissingType) -> bool: ...
@overload
def _pick(current: T, new: T | _MissingType) -> T: ...
def _pick(current: T, new: T | _MissingType) -> T:
    """Overwrite with `new` unless `new` is the _MissingType sentinel."""
    return current if _is_MissingType(new) else cast(T, new)


def _pick_if_none(current: T | None, new: T | _MissingType) -> T | None:
    """
    Only uses `new` when current is None; otherwise keeps current.
    (Useful for "fill defaults" semantics.)
    """
    if current is not None:
        return current
    if _is_MissingType(new):
        return None
    return cast(T, new)


def _norm_spec(
    name: str,
    value: str | Sequence[str] | None,
) -> str | tuple[str, ...] | None:
    if value is None:
        return None
    if isinstance(value, str):
        if value == "":
            raise ValueError(f"'{name}' must not be an empty string when provided")
        return value
    if isinstance(value, (bytes, bytearray)) or not isinstance(value, Sequence):
        raise TypeError(f"'{name}' must be str | Sequence[str] | None")
    items = list(value)
    if not items:
        raise ValueError(f"'{name}' sequence must not be empty")
    for i, s in enumerate(items):
        if not isinstance(s, str):
            raise TypeError(f"'{name}' items must be str; got {type(s).__name__} at index {i}")
        if s == "":
            raise ValueError(f"'{name}' items must not contain empty strings")
    return tuple(items)


def _ui_module():
    """Try to import central UI module (optional)."""
    try:
        from svy.ui import printing as _p

        return _p
    except Exception:
        return None


# =============================================================================
# Design Definition
# =============================================================================

_FIELDS: tuple[str, ...] = (
    "row_index",
    "stratum",
    "wgt",
    "prob",
    "hit",
    "mos",
    "psu",
    "ssu",
    "pop_size",
    "wr",
)


class Design:
    row_index: str | None
    stratum: str | tuple[str, ...] | None
    wgt: str | None
    prob: str | None
    hit: str | None
    mos: str | None
    psu: str | tuple[str, ...] | None
    ssu: str | tuple[str, ...] | None
    pop_size: str | None
    wr: bool
    rep_wgts: RepWeights | None
    _frozen: bool

    PRINT_WIDTH: int | None = None

    __slots__ = (*_FIELDS, "rep_wgts", "_frozen")

    def __init__(
        self,
        row_index: str | None = None,
        stratum: str | Sequence[str] | None = None,
        wgt: str | None = None,
        prob: str | None = None,
        hit: str | None = None,
        mos: str | None = None,
        psu: str | Sequence[str] | None = None,
        ssu: str | Sequence[str] | None = None,
        pop_size: str | None = None,
        wr: bool = False,
        rep_wgts: RepWeights | None = None,
    ) -> None:
        object.__setattr__(self, "_frozen", False)

        norm_stratum = _norm_spec("stratum", stratum)
        norm_psu = _norm_spec("psu", psu)
        norm_ssu = _norm_spec("ssu", ssu)

        object.__setattr__(self, "row_index", row_index)
        object.__setattr__(self, "stratum", norm_stratum)
        object.__setattr__(self, "wgt", wgt)
        object.__setattr__(self, "prob", prob)
        object.__setattr__(self, "hit", hit)
        object.__setattr__(self, "mos", mos)
        object.__setattr__(self, "psu", norm_psu)
        object.__setattr__(self, "ssu", norm_ssu)
        object.__setattr__(self, "pop_size", pop_size)
        object.__setattr__(self, "wr", wr)
        object.__setattr__(self, "rep_wgts", rep_wgts)

        # Validate simple string-or-None fields
        for name in ("row_index", "wgt", "prob", "hit", "mos", "pop_size"):
            val = getattr(self, name)
            if val is not None and not isinstance(val, str):
                raise TypeError(f"{name!r} must be str | None, got {type(val).__name__}")
            if isinstance(val, str) and not val:
                raise ValueError(f"{name!r} must not be an empty string when provided")

        if not isinstance(self.wr, bool):
            raise TypeError(f"'wr' must be bool, got {type(self.wr).__name__}")
        if rep_wgts is not None and not isinstance(rep_wgts, RepWeights):
            raise TypeError("'rep_wgts' must be RepWeights | None")

        object.__setattr__(self, "_frozen", True)

    # -----------------------------
    # Immutability Guards
    # -----------------------------
    def __setattr__(self, name: str, value: object) -> None:
        if getattr(self, "_frozen", False):
            raise AttributeError("Design is frozen; use .update(...) to create a modified copy.")
        object.__setattr__(self, name, value)

    def __delattr__(self, name: str) -> None:
        if getattr(self, "_frozen", False):
            raise AttributeError("Design is frozen; attributes cannot be deleted.")
        object.__delattr__(self, name)

    # -----------------------------
    # Properties
    # -----------------------------
    @property
    def method(self) -> EstimationMethod:
        """Convenience accessor for the estimation method."""
        if self.rep_wgts is None:
            return EstimationMethod.TAYLOR
        return self.rep_wgts.method

    # -----------------------------
    # Update Methods
    # -----------------------------
    def update(
        self,
        *,
        row_index: str | None | _MissingType = _MISSING,
        stratum: str | Sequence[str] | None | _MissingType = _MISSING,
        wgt: str | None | _MissingType = _MISSING,
        prob: str | None | _MissingType = _MISSING,
        hit: str | None | _MissingType = _MISSING,
        mos: str | None | _MissingType = _MISSING,
        psu: str | Sequence[str] | None | _MissingType = _MISSING,
        ssu: str | Sequence[str] | None | _MissingType = _MISSING,
        pop_size: str | None | _MissingType = _MISSING,
        wr: bool | _MissingType = _MISSING,
        rep_wgts: RepWeights | _MissingType | None = _MISSING,
    ) -> Self:
        return self._merge(
            only_if_none=False,
            row_index=row_index,
            stratum=stratum,
            wgt=wgt,
            prob=prob,
            hit=hit,
            mos=mos,
            psu=psu,
            ssu=ssu,
            pop_size=pop_size,
            wr=wr,
            rep_wgts=rep_wgts,
        )

    def fill_missing(
        self,
        *,
        row_index: str | None | _MissingType = _MISSING,
        stratum: str | Sequence[str] | None | _MissingType = _MISSING,
        wgt: str | None | _MissingType = _MISSING,
        prob: str | None | _MissingType = _MISSING,
        hit: str | None | _MissingType = _MISSING,
        mos: str | None | _MissingType = _MISSING,
        psu: str | Sequence[str] | None | _MissingType = _MISSING,
        ssu: str | Sequence[str] | None | _MissingType = _MISSING,
        pop_size: str | None | _MissingType = _MISSING,
        wr: bool | _MissingType = _MISSING,
        rep_wgts: RepWeights | Sequence[str] | _MissingType | None = _MISSING,
    ) -> Self:
        # Sequence[str] is handled inside _merge (it becomes _MISSING); cast for ty.
        rep_wgts_arg: RepWeights | _MissingType | None = (
            _MISSING
            if isinstance(rep_wgts, (list, tuple)) and not isinstance(rep_wgts, str)
            else cast(RepWeights | _MissingType | None, rep_wgts)
        )
        return self._merge(
            only_if_none=True,
            row_index=row_index,
            stratum=stratum,
            wgt=wgt,
            prob=prob,
            hit=hit,
            mos=mos,
            psu=psu,
            ssu=ssu,
            pop_size=pop_size,
            wr=wr,
            rep_wgts=rep_wgts_arg,
        )

    def update_rep_weights(
        self,
        *,
        method: EstimationMethod | None | _MissingType = _MISSING,
        prefix: str | _MissingType = _MISSING,
        n_reps: int | _MissingType = _MISSING,
        fay_coef: float | _MissingType = _MISSING,
        df: int | None | _MissingType = _MISSING,
        padding: int | None | _MissingType = _MISSING,
    ) -> Self:
        """
        Return a new Design with selected RepWeights fields updated.
        Ensures strict validity: if creating weights for the first time,
        mandatory fields (method, prefix, n_reps) must be provided.
        """
        # 1. Quick exit if no arguments provided
        if (
            isinstance(prefix, _MissingType)
            and isinstance(method, _MissingType)
            and isinstance(n_reps, _MissingType)
            and isinstance(fay_coef, _MissingType)
            and isinstance(df, _MissingType)
            and isinstance(padding, _MissingType)
        ):
            return self

        # 2. Explicitly handle method=None to clear weights
        if method is None:
            return self.update(rep_wgts=None)

        # 3. Get current state
        cur = self.rep_wgts

        # 4. Resolve Values

        def resolve_mandatory(arg_val: T | _MissingType, arg_name: str) -> T:
            if not isinstance(arg_val, _MissingType):
                return arg_val
            if cur is not None:
                return getattr(cur, arg_name)
            raise ValueError(
                f"When initializing RepWeights for the first time, '{arg_name}' is mandatory."
            )

        # Resolve Mandatory Fields
        resolved_method = resolve_mandatory(method, "method")
        resolved_prefix = resolve_mandatory(prefix, "prefix")
        resolved_n_reps = resolve_mandatory(n_reps, "n_reps")

        # Resolve Optional Fields
        if isinstance(fay_coef, _MissingType):
            fay_coef = cur.fay_coef if cur else 0.0

        if isinstance(df, _MissingType):
            df = cur.df if cur else None

        if isinstance(padding, _MissingType):
            padding = cur.padding if cur else None

        # 5. Create new RepWeights object
        updated_rep_wgts = RepWeights(
            method=resolved_method,
            prefix=resolved_prefix,
            n_reps=resolved_n_reps,
            fay_coef=fay_coef,
            df=df,
            padding=padding,
        )

        return self.update(rep_wgts=updated_rep_wgts)

    # -----------------------------
    # Internal Merge Logic
    # -----------------------------
    def _merge(
        self,
        *,
        only_if_none: bool,
        row_index: str | None | _MissingType = _MISSING,
        stratum: str | Sequence[str] | None | _MissingType = _MISSING,
        wgt: str | None | _MissingType = _MISSING,
        prob: str | None | _MissingType = _MISSING,
        hit: str | None | _MissingType = _MISSING,
        mos: str | None | _MissingType = _MISSING,
        psu: str | Sequence[str] | None | _MissingType = _MISSING,
        ssu: str | Sequence[str] | None | _MissingType = _MISSING,
        pop_size: str | None | _MissingType = _MISSING,
        wr: bool | _MissingType = _MISSING,
        rep_wgts: RepWeights | _MissingType | None = _MISSING,
    ) -> Self:
        """
        Internal: merge fields either by overwriting or only filling when current is None.
        """
        # Normalize rep_wgts arg
        rep_arg: RepWeights | _MissingType | None
        if isinstance(rep_wgts, Sequence) and not isinstance(rep_wgts, (str, bytes)):
            rep_arg = _MISSING
        else:
            rep_arg = cast(RepWeights | _MissingType | None, rep_wgts)

        pick = _pick_if_none if only_if_none else _pick

        def is_missing(x: object, /) -> TypeGuard[_MissingType]:
            return x is _MISSING

        def _norm_multi_arg(
            field_name: str, val: str | Sequence[str] | None | _MissingType
        ) -> str | tuple[str, ...] | None | _MissingType:
            if is_missing(val):
                return _MISSING
            if val is None:
                return None
            if isinstance(val, str):
                if val == "":
                    raise ValueError(f"'{field_name}' must not be an empty string when provided")
                return val
            if not isinstance(val, Sequence) or isinstance(val, (bytes, bytearray)):
                raise TypeError(f"'{field_name}' must be a sequence of str")
            if len(val) == 0:
                raise ValueError(f"'{field_name}' sequence must not be empty")
            for x in val:
                if not isinstance(x, str):
                    raise TypeError(f"'{field_name}' items must be str")
                if x == "":
                    raise ValueError(f"'{field_name}' items must not be empty")
            return cast(tuple[str, ...], tuple(val))

        stratum_arg = _norm_multi_arg("stratum", stratum)
        psu_arg = _norm_multi_arg("psu", psu)
        ssu_arg = _norm_multi_arg("ssu", ssu)

        return type(self)(
            row_index=pick(self.row_index, row_index),
            stratum=pick(self.stratum, stratum_arg),
            wgt=pick(self.wgt, wgt),
            prob=pick(self.prob, prob),
            hit=pick(self.hit, hit),
            mos=pick(self.mos, mos),
            psu=pick(self.psu, psu_arg),
            ssu=pick(self.ssu, ssu_arg),
            pop_size=pick(self.pop_size, pop_size),
            wr=_pick(self.wr, wr),
            rep_wgts=pick(self.rep_wgts, rep_arg),
        )

    # -----------------------------
    # Introspection
    # -----------------------------
    def specified_fields(
        self,
        *,
        ignore_cols: Sequence[str] | None = None,
        data_columns: Sequence[str] | None = None,
    ) -> list[str]:
        """
        Return a de-duplicated (order-preserving) list of column names referenced
        by the design (stratum/psu/ssu/etc.), including replicate weight columns.

        Parameters
        ----------
        ignore_cols : Sequence[str], optional
            Column names to ignore
        data_columns : Sequence[str], optional
            Actual data column names (used for auto-detecting padding in rep weights)

        Returns
        -------
        list[str]
            List of all column names referenced by this design
        """
        default_ignores = {"wr"}
        ignore = default_ignores | (set(ignore_cols) if ignore_cols else set())

        out: list[str] = []
        seen: set[str] = set()

        def add(name: str) -> None:
            if name and name not in ignore and name not in seen:
                out.append(name)
                seen.add(name)

        # 1. Add standard fields
        for name in _FIELDS:
            if name in ignore:
                continue

            val = getattr(self, name, None)
            if not val:
                continue

            # Handle multi-column fields
            if name in {"stratum", "psu", "ssu"}:
                if isinstance(val, str):
                    add(val)
                elif isinstance(val, (tuple, list)):
                    for s in val:
                        add(s)
                continue

            # Handle standard string fields
            if isinstance(val, str):
                add(val)

        # 2. Add Replicate Weight columns with auto-detection
        if self.rep_wgts:
            if data_columns is not None:
                # Use auto-detection from actual data
                rep_cols = self.rep_wgts.columns_from_data(data_columns)
            else:
                # Fall back to default columns (explicit padding or no padding)
                rep_cols = self.rep_wgts.columns

            for col in rep_cols:
                add(col)

        return out

    # -----------------------------
    # Printing & Rendering
    # -----------------------------
    @staticmethod
    def _pad_and_surround(text: str, *, indent: int = 2, surround: bool = False) -> str:
        if text is None:
            return ""
        text = str(text).rstrip("\n")
        if indent > 0:
            pad = " " * indent
            text = "\n".join(pad + line if line else pad for line in text.splitlines())
        return f"\n{text}\n" if surround else text

    @staticmethod
    def _fmt_tuple_names(x) -> str:
        if x is None:
            return "None"
        if isinstance(x, (tuple, list)):
            inner = ", ".join(str(v) for v in x)
            if len(x) == 1:
                inner += ","
            return f"({inner})"
        return str(x)

    def _repweights_summary(self) -> str:
        return str(self.rep_wgts) if self.rep_wgts else "None"

    def __rich_console__(self, console, options):
        from rich import box
        from rich.console import Group
        from rich.panel import Panel
        from rich.table import Table as RTable
        from rich.text import Text

        try:
            from rich.panel import Panel
        except Exception:
            Panel = None  # type: ignore

        t = RTable(
            show_header=True,
            header_style="bold",
            box=box.HORIZONTALS,
            show_edge=False,
            show_lines=False,
            pad_edge=False,
            expand=False,
        )
        t.add_column("Field", justify="left", no_wrap=True)
        t.add_column("Value", justify="left", no_wrap=False, overflow="fold")

        rows: list[tuple[str, str]] = [
            ("Row index", str(self.row_index)),
            ("Stratum", self._fmt_tuple_names(self.stratum)),
            ("PSU", self._fmt_tuple_names(self.psu)),
            ("SSU", self._fmt_tuple_names(self.ssu)),
            ("Weight", str(self.wgt)),
            ("With replacement", str(bool(self.wr))),
            ("Prob", str(self.prob)),
            ("Hit", str(self.hit)),
            ("MOS", str(self.mos)),
            ("Population size", str(self.pop_size)),
            ("Replicate weights", self._repweights_summary()),
        ]
        for k, v in rows:
            t.add_row(k, v)

        P = _ui_module()
        if P:
            yield P.make_panel([t], title="Design", obj=self, kind="estimate")
        else:
            yield Panel(Group(Text("", end=""), t), title="Design", border_style="cyan")

    def __plain_str__(self) -> str:
        """Plain-text fallback when rich is not installed."""
        lines: list[str] = [
            "Design",
            f"  Row index        : {self.row_index}",
            f"  Stratum          : {self._fmt_tuple_names(self.stratum)}",
            f"  PSU              : {self._fmt_tuple_names(self.psu)}",
            f"  SSU              : {self._fmt_tuple_names(self.ssu)}",
            f"  Weight           : {self.wgt}",
            f"  With replacement : {bool(self.wr)}",
            f"  Prob             : {self.prob}",
            f"  Hit              : {self.hit}",
            f"  MOS              : {self.mos}",
            f"  Population size  : {self.pop_size}",
            f"  Replicate weights: {self._repweights_summary()}",
        ]
        return "\n".join(lines)

    def __str__(self) -> str:
        from svy.ui.printing import render_rich_to_str, resolve_width

        result = render_rich_to_str(self, width=resolve_width(self))
        return self._pad_and_surround(result, indent=2, surround=False)

    def __repr__(self) -> str:
        parts: list[str] = []
        if self.row_index is not None:
            parts.append(f"row_index={self.row_index!r}")

        def add_nonempty(name: str, value) -> None:
            if value is None:
                return
            if isinstance(value, (tuple, list)) and not value:
                return
            parts.append(f"{name}={value!r}")

        add_nonempty("stratum", self.stratum)
        add_nonempty("psu", self.psu)
        add_nonempty("ssu", self.ssu)
        add_nonempty("wgt", self.wgt)
        add_nonempty("prob", self.prob)
        add_nonempty("hit", self.hit)
        add_nonempty("mos", self.mos)
        add_nonempty("pop_size", self.pop_size)
        if self.wr:
            parts.append("wr=True")

        if self.rep_wgts:
            rw = self.rep_wgts
            method_name = getattr(rw.method, "name", str(rw.method))
            parts.append(
                f"rep_wgts={method_name}(n_reps={rw.n_reps}, prefix='{rw.prefix}', df={rw.df})"
            )
        else:
            parts.append("rep_wgts=None")

        return f"Design({', '.join(parts)})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Design):
            return False
        return all(getattr(self, f) == getattr(other, f) for f in _FIELDS) and (
            self.rep_wgts == other.rep_wgts
        )

    def __hash__(self) -> int:
        return hash((tuple(getattr(self, f) for f in _FIELDS), self.rep_wgts))
