# ──────────────────────────────────────────────────────────────────────
# SCPN Control — Knm Coupling Matrix (Paper 27)
# © 1998–2026 Miroslav Šotek. All rights reserved.
# Contact: www.anulum.li | protoscience@anulum.li
# ORCID: https://orcid.org/0009-0009-3560-0851
# License: GNU AGPL v3 | Commercial licensing available
# ──────────────────────────────────────────────────────────────────────
"""
Paper 27 Knm specification.

K[n, m] encodes coupling from source layer n to target layer m.
Diagonal: intra-layer synchronisation strength.
Off-diagonal: inter-layer bidirectional causality (bottom-up / top-down).
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional, Sequence

import numpy as np
from numpy.typing import NDArray

FloatArray = NDArray[np.float64]

# ── Canonical 16-layer frequencies (rad/s) from SCPN calibration ─────
OMEGA_N_16 = np.array([
    1.329, 2.610, 0.844, 1.520, 0.710, 3.780, 1.055, 0.625,
    2.210, 1.740, 0.480, 3.210, 0.915, 1.410, 2.830, 0.991,
], dtype=np.float64)


@dataclass(frozen=True)
class KnmSpec:
    """Paper 27 coupling specification.

    K      : (L, L) coupling matrix.  K[n, m] = source n -> target m.
    alpha  : (L, L) Sakaguchi phase-lag (optional).
    zeta   : (L,) per-layer global-driver gain ζ_m (optional).
    """
    K: FloatArray
    alpha: Optional[FloatArray] = None
    zeta: Optional[FloatArray] = None
    layer_names: Optional[Sequence[str]] = None

    def __post_init__(self) -> None:
        K = np.asarray(self.K, dtype=np.float64)
        if K.ndim != 2 or K.shape[0] != K.shape[1]:
            raise ValueError("K must be square (L, L)")
        L = K.shape[0]
        if self.alpha is not None:
            a = np.asarray(self.alpha, dtype=np.float64)
            if a.shape != (L, L):
                raise ValueError(f"alpha shape {a.shape} != ({L}, {L})")
        if self.zeta is not None:
            z = np.asarray(self.zeta, dtype=np.float64)
            if z.shape != (L,):
                raise ValueError(f"zeta shape {z.shape} != ({L},)")
        if self.layer_names is not None and len(self.layer_names) != L:
            raise ValueError("layer_names length must equal L")

    @property
    def L(self) -> int:
        return int(np.asarray(self.K).shape[0])


def build_knm_paper27(
    L: int = 16,
    K_base: float = 0.45,
    K_alpha: float = 0.3,
    zeta_uniform: float = 0.0,
) -> KnmSpec:
    """Build the canonical Paper 27 Knm with exponential distance decay.

    K[i,j] = K_base · exp(−K_alpha · |i − j|),  diag(K) kept for
    intra-layer sync (unlike the inter-oscillator Knm which zeros diag).

    Calibration anchors and cross-hierarchy boosts from SCPN calibration.
    """
    idx = np.arange(L)
    dist = np.abs(idx[:, None] - idx[None, :])
    K = K_base * np.exp(-K_alpha * dist)

    # Calibration anchors — Paper 27 Table 2
    anchors = [(0, 1, 0.302), (1, 2, 0.201), (2, 3, 0.252), (3, 4, 0.154)]
    for i, j, val in anchors:
        if i < L and j < L:
            K[i, j] = val
            K[j, i] = val

    # Cross-hierarchy boosts — Paper 27 §4.3
    if L >= 16:
        K[0, 15] = max(K[0, 15], 0.05)
        K[15, 0] = max(K[15, 0], 0.05)
    if L >= 7:
        K[4, 6] = max(K[4, 6], 0.15)
        K[6, 4] = max(K[6, 4], 0.15)

    zeta = np.full(L, zeta_uniform, dtype=np.float64) if zeta_uniform != 0.0 else None

    return KnmSpec(K=K, zeta=zeta)
