#!/usr/bin/env python3
"""
Entropy-based observable-diameter estimator on complex projective space CP^n.

Interpretation
--------------
We identify CP^n with the projective pure-state space of C^(n+1).  To define
an entanglement entropy observable we choose a factorization

    n + 1 = d_A * d_B,

so the projective space is CP^(d_A d_B - 1).  For a projective point [psi],
represented by a unit vector psi in C^(d_A d_B), define the observable

    S_A([psi]) = -Tr(rho_A log_2 rho_A),
    rho_A = Tr_B |psi><psi|.

The true observable diameter ObsDiam(X; -kappa) is the supremum over all
1-Lipschitz observables.  This script only uses the von Neumann entropy
observable, so it reports:

1) the partial diameter of the push-forward entropy distribution,
2) an optional Lipschitz-normalized proxy obtained by dividing by an empirical
   Lipschitz constant estimated with the Fubini-Study metric.

Hence the output is best interpreted as an entropy-based observable-diameter
proxy, not as the exact observable diameter of CP^n.

Hayden-inspired comparison
--------------------------
Hayden/Leung/Winter show that the entanglement entropy of a Haar-random pure
state is highly concentrated in high dimension.  The script overlays two
useful theoretical guides:

- a one-sided lower-tail cutoff derived from the standard Hayden bound,
- a Levy/Hayden scaling width of order (log d_A)/sqrt(d_A d_B), centered at
  the empirical median, to visualize concentration-of-measure decay.

Sampling method
---------------
A Haar-random pure state on C^(d_A d_B) can be generated by normalizing a
complex Gaussian vector.  Equivalently, we sample a complex Gaussian matrix
G in C^(d_A x d_B); then vec(G)/||G|| is Haar-random and
rho_A = G G^* / Tr(G G^*).

Outputs
-------
The script writes:
- a CSV summary table,
- per-system entropy histograms,
- a concentration summary plot across dimensions,
- a normalized observable-proxy plot if Lipschitz estimation is enabled,
- a tail plot for the largest system.
"""

from __future__ import annotations

import argparse
import csv
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Sequence, Tuple

import matplotlib.pyplot as plt
import numpy as np

from tqdm import tqdm

# A commonly used explicit constant in expositions of Hayden's concentration
# bound in natural logs.  We keep the entropy in bits, in which the same
# constant remains after the base conversion in the exponent.
HAYDEN_C = 1.0 / (8.0 * math.pi ** 2)


def parse_dims(spec: str) -> List[Tuple[int, int]]:
    dims: List[Tuple[int, int]] = []
    for item in spec.split(","):
        token = item.strip().lower()
        if not token:
            continue
        if "x" not in token:
            raise ValueError(f"Bad dimension token '{item}'. Use forms like 4x8,8x16.")
        a_str, b_str = token.split("x", 1)
        d_a = int(a_str)
        d_b = int(b_str)
        if d_a <= 1 or d_b <= 1:
            raise ValueError("Both subsystem dimensions must be >= 2.")
        if d_a > d_b:
            d_a, d_b = d_b, d_a
        dims.append((d_a, d_b))
    if not dims:
        raise ValueError("No dimensions were parsed.")
    return dims


def haar_matrix(d_a: int, d_b: int, rng: np.random.Generator) -> np.ndarray:
    real = rng.normal(size=(d_a, d_b))
    imag = rng.normal(size=(d_a, d_b))
    return (real + 1j * imag) / math.sqrt(2.0)


def reduced_density_from_matrix(g: np.ndarray) -> np.ndarray:
    rho = g @ g.conj().T
    tr = float(np.trace(rho).real)
    rho /= tr
    return rho


def entropy_bits_from_rho(rho: np.ndarray, tol: float = 1e-14) -> float:
    eigvals = np.linalg.eigvalsh(rho)
    eigvals = np.clip(eigvals.real, 0.0, 1.0)
    eigvals = eigvals[eigvals > tol]
    if eigvals.size == 0:
        return 0.0
    return float(-np.sum(eigvals * np.log2(eigvals)))


def random_state_and_entropy(
    d_a: int, d_b: int, rng: np.random.Generator
) -> Tuple[np.ndarray, float]:
    g = haar_matrix(d_a, d_b, rng)
    rho_a = reduced_density_from_matrix(g)
    entropy_bits = entropy_bits_from_rho(rho_a)
    psi = g.reshape(-1)
    psi /= np.linalg.norm(psi)
    return psi, entropy_bits


def partial_diameter(samples: np.ndarray, mass: float) -> Tuple[float, float, float]:
    if not 0.0 < mass <= 1.0:
        raise ValueError("mass must lie in (0, 1].")
    x = np.sort(np.asarray(samples, dtype=float))
    n = x.size
    if n == 0:
        raise ValueError("samples must be non-empty")
    if n == 1:
        return 0.0, float(x[0]), float(x[0])
    m = int(math.ceil(mass * n))
    if m <= 1:
        return 0.0, float(x[0]), float(x[0])
    widths = x[m - 1 :] - x[: n - m + 1]
    idx = int(np.argmin(widths))
    left = float(x[idx])
    right = float(x[idx + m - 1])
    return float(right - left), left, right


def fubini_study_distance(psi: np.ndarray, phi: np.ndarray) -> float:
    overlap = abs(np.vdot(psi, phi))
    overlap = min(1.0, max(0.0, float(overlap)))
    return float(math.acos(overlap))


def empirical_lipschitz_constant(
    states: Sequence[np.ndarray],
    values: np.ndarray,
    rng: np.random.Generator,
    num_pairs: int,
) -> Tuple[float, float]:
    n = len(states)
    if n < 2 or num_pairs <= 0:
        return float("nan"), float("nan")
    ratios = []
    values = np.asarray(values, dtype=float)
    for _ in range(num_pairs):
        i = int(rng.integers(0, n))
        j = int(rng.integers(0, n - 1))
        if j >= i:
            j += 1
        d_fs = fubini_study_distance(states[i], states[j])
        if d_fs < 1e-12:
            continue
        ratio = abs(values[i] - values[j]) / d_fs
        ratios.append(ratio)
    if not ratios:
        return float("nan"), float("nan")
    arr = np.asarray(ratios, dtype=float)
    return float(np.max(arr)), float(np.quantile(arr, 0.99))


def hayden_mean_lower_bound_bits(d_a: int, d_b: int) -> float:
    return math.log2(d_a) - d_a / (2.0 * math.log(2.0) * d_b)


def hayden_beta_bits(d_a: int, d_b: int) -> float:
    return d_a / (math.log(2.0) * d_b)


def hayden_alpha_bits(d_a: int, d_b: int, kappa: float) -> float:
    dim = d_a * d_b
    return (math.log2(d_a) / math.sqrt(HAYDEN_C * (dim - 1.0))) * math.sqrt(math.log(1.0 / kappa))


def hayden_one_sided_width_bits(d_a: int, d_b: int, kappa: float) -> float:
    return hayden_beta_bits(d_a, d_b) + hayden_alpha_bits(d_a, d_b, kappa)


def hayden_lower_cutoff_bits(d_a: int, d_b: int, kappa: float) -> float:
    return math.log2(d_a) - hayden_one_sided_width_bits(d_a, d_b, kappa)


def levy_hayden_scaling_width_bits(d_a: int, d_b: int, kappa: float) -> float:
    dim = d_a * d_b
    half_width = (math.log2(d_a) / math.sqrt(HAYDEN_C * (dim - 1.0))) * math.sqrt(math.log(2.0 / kappa))
    return 2.0 * half_width


def hayden_deficit_tail_bound_bits(d_a: int, d_b: int, deficits_bits: np.ndarray) -> np.ndarray:
    beta = hayden_beta_bits(d_a, d_b)
    dim = d_a * d_b
    log_term = math.log2(d_a)
    shifted = np.maximum(np.asarray(deficits_bits, dtype=float) - beta, 0.0)
    exponent = -(dim - 1.0) * HAYDEN_C * (shifted ** 2) / (log_term ** 2)
    bound = np.exp(exponent)
    bound[deficits_bits <= beta] = 1.0
    return np.clip(bound, 0.0, 1.0)


def page_average_entropy_bits(d_a: int, d_b: int) -> float:
    # Exact Page formula in bits for d_b >= d_a.
    harmonic_tail = sum(1.0 / k for k in range(d_b + 1, d_a * d_b + 1))
    nats = harmonic_tail - (d_a - 1.0) / (2.0 * d_b)
    return nats / math.log(2.0)


@dataclass
class SystemResult:
    d_a: int
    d_b: int
    projective_dim: int
    num_samples: int
    kappa: float
    mass: float
    entropy_bits: np.ndarray
    partial_diameter_bits: float
    interval_left_bits: float
    interval_right_bits: float
    mean_bits: float
    median_bits: float
    std_bits: float
    page_average_bits: float
    hayden_mean_lower_bits: float
    hayden_cutoff_bits: float
    hayden_one_sided_width_bits: float
    levy_scaling_width_bits: float
    empirical_lipschitz_max: float
    empirical_lipschitz_q99: float
    normalized_proxy_max: float
    normalized_proxy_q99: float


def simulate_system(
    d_a: int,
    d_b: int,
    num_samples: int,
    kappa: float,
    rng: np.random.Generator,
    lipschitz_pairs: int,
) -> Tuple[SystemResult, List[np.ndarray]]:
    entropies = np.empty(num_samples, dtype=float)
    states: List[np.ndarray] = []
    for idx in tqdm(range(num_samples),desc=f"Simulating system for {d_a}x{d_b} with kappa={kappa}", unit="samples"):
        psi, s_bits = random_state_and_entropy(d_a, d_b, rng)
        entropies[idx] = s_bits
        states.append(psi)

    mass = 1.0 - kappa
    width, left, right = partial_diameter(entropies, mass)
    lip_max, lip_q99 = empirical_lipschitz_constant(states, entropies, rng, lipschitz_pairs)

    normalized_proxy_max = width / lip_max if lip_max == lip_max and lip_max > 0 else float("nan")
    normalized_proxy_q99 = width / lip_q99 if lip_q99 == lip_q99 and lip_q99 > 0 else float("nan")

    result = SystemResult(
        d_a=d_a,
        d_b=d_b,
        projective_dim=d_a * d_b - 1,
        num_samples=num_samples,
        kappa=kappa,
        mass=mass,
        entropy_bits=entropies,
        partial_diameter_bits=width,
        interval_left_bits=left,
        interval_right_bits=right,
        mean_bits=float(np.mean(entropies)),
        median_bits=float(np.median(entropies)),
        std_bits=float(np.std(entropies, ddof=1)) if num_samples > 1 else 0.0,
        page_average_bits=page_average_entropy_bits(d_a, d_b),
        hayden_mean_lower_bits=hayden_mean_lower_bound_bits(d_a, d_b),
        hayden_cutoff_bits=hayden_lower_cutoff_bits(d_a, d_b, kappa),
        hayden_one_sided_width_bits=hayden_one_sided_width_bits(d_a, d_b, kappa),
        levy_scaling_width_bits=levy_hayden_scaling_width_bits(d_a, d_b, kappa),
        empirical_lipschitz_max=lip_max,
        empirical_lipschitz_q99=lip_q99,
        normalized_proxy_max=normalized_proxy_max,
        normalized_proxy_q99=normalized_proxy_q99,
    )
    return result, states


def write_summary_csv(results: Sequence[SystemResult], out_path: Path) -> None:
    fieldnames = [
        "d_a",
        "d_b",
        "projective_dim",
        "num_samples",
        "kappa",
        "mass",
        "partial_diameter_bits",
        "interval_left_bits",
        "interval_right_bits",
        "mean_bits",
        "median_bits",
        "std_bits",
        "page_average_bits",
        "hayden_mean_lower_bits",
        "hayden_cutoff_bits",
        "hayden_one_sided_width_bits",
        "levy_scaling_width_bits",
        "empirical_lipschitz_max_bits_per_rad",
        "empirical_lipschitz_q99_bits_per_rad",
        "normalized_proxy_max_rad",
        "normalized_proxy_q99_rad",
    ]
    with out_path.open("w", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=fieldnames)
        writer.writeheader()
        for r in results:
            writer.writerow(
                {
                    "d_a": r.d_a,
                    "d_b": r.d_b,
                    "projective_dim": r.projective_dim,
                    "num_samples": r.num_samples,
                    "kappa": r.kappa,
                    "mass": r.mass,
                    "partial_diameter_bits": r.partial_diameter_bits,
                    "interval_left_bits": r.interval_left_bits,
                    "interval_right_bits": r.interval_right_bits,
                    "mean_bits": r.mean_bits,
                    "median_bits": r.median_bits,
                    "std_bits": r.std_bits,
                    "page_average_bits": r.page_average_bits,
                    "hayden_mean_lower_bits": r.hayden_mean_lower_bits,
                    "hayden_cutoff_bits": r.hayden_cutoff_bits,
                    "hayden_one_sided_width_bits": r.hayden_one_sided_width_bits,
                    "levy_scaling_width_bits": r.levy_scaling_width_bits,
                    "empirical_lipschitz_max_bits_per_rad": r.empirical_lipschitz_max,
                    "empirical_lipschitz_q99_bits_per_rad": r.empirical_lipschitz_q99,
                    "normalized_proxy_max_rad": r.normalized_proxy_max,
                    "normalized_proxy_q99_rad": r.normalized_proxy_q99,
                }
            )


def plot_histogram(result: SystemResult, outdir: Path) -> Path:
    plt.figure(figsize=(8.5, 5.5))
    ent = result.entropy_bits
    plt.hist(ent, bins=40, density=True, alpha=0.75)
    plt.axvline(math.log2(result.d_a), linestyle="--", linewidth=2, label=r"$\log_2 d_A$")
    plt.axvline(result.mean_bits, linestyle="-.", linewidth=2, label="empirical mean")
    plt.axvline(result.page_average_bits, linestyle=":", linewidth=2, label="Page average")
    local_min = float(np.min(ent))
    local_max = float(np.max(ent))
    local_range = max(local_max - local_min, 1e-9)
    if result.hayden_cutoff_bits >= local_min - 0.15 * local_range:
        plt.axvline(result.hayden_cutoff_bits, linestyle="-", linewidth=2, label="Hayden cutoff")
    plt.axvspan(result.interval_left_bits, result.interval_right_bits, alpha=0.18, label=f"shortest {(result.mass):.0%} interval")
    plt.xlim(local_min - 0.12 * local_range, local_max + 0.35 * local_range)
    plt.xlabel("Entropy of entanglement S_A (bits)")
    plt.ylabel("Empirical density")
    plt.title(
        f"Entropy distribution on CP^{result.projective_dim} via C^{result.d_a} ⊗ C^{result.d_b}"
    )
    plt.legend(frameon=False)
    plt.tight_layout()
    out_path = outdir / f"entropy_histogram_{result.d_a}x{result.d_b}.png"
    plt.savefig(out_path, dpi=180)
    plt.close()
    return out_path


def plot_tail(result: SystemResult, outdir: Path) -> Path:
    deficits = math.log2(result.d_a) - np.sort(result.entropy_bits)
    n = deficits.size
    ccdf = 1.0 - (np.arange(1, n + 1) / n)
    ccdf = np.maximum(ccdf, 1.0 / n)
    x_grid = np.linspace(0.0, max(float(np.max(deficits)), result.hayden_one_sided_width_bits) * 1.05, 250)
    bound = hayden_deficit_tail_bound_bits(result.d_a, result.d_b, x_grid)

    plt.figure(figsize=(8.5, 5.5))
    plt.semilogy(deficits, ccdf, marker="o", linestyle="none", markersize=3, alpha=0.5, label="empirical tail")
    plt.semilogy(x_grid, bound, linewidth=2, label="Hayden lower-tail bound")
    plt.axvline(hayden_beta_bits(result.d_a, result.d_b), linestyle="--", linewidth=1.8, label=r"$\beta$")
    plt.xlabel(r"Entropy deficit $\log_2 d_A - S_A$ (bits)")
    plt.ylabel(r"Tail probability $\Pr[\log_2 d_A - S_A > t]$")
    plt.title(f"Entropy-deficit tail for C^{result.d_a} ⊗ C^{result.d_b}")
    plt.legend(frameon=False)
    plt.tight_layout()
    out_path = outdir / f"entropy_tail_{result.d_a}x{result.d_b}.png"
    plt.savefig(out_path, dpi=180)
    plt.close()
    return out_path


def plot_concentration_summary(results: Sequence[SystemResult], outdir: Path) -> Path:
    x = np.array([r.projective_dim for r in results], dtype=float)
    partial_width = np.array([r.partial_diameter_bits for r in results], dtype=float)
    std = np.array([r.std_bits for r in results], dtype=float)
    mean_deficit = np.array([math.log2(r.d_a) - r.mean_bits for r in results], dtype=float)

    plt.figure(figsize=(8.5, 5.5))
    plt.plot(x, partial_width, marker="o", linewidth=2, label=r"shortest $(1-\kappa)$ entropy interval")
    plt.plot(x, std, marker="s", linewidth=2, label="empirical standard deviation")
    plt.plot(x, mean_deficit, marker="^", linewidth=2, label=r"mean deficit $\log_2 d_A - \mathbb{E}S_A$")
    plt.xlabel(r"Projective dimension $n = d_A d_B - 1$")
    plt.ylabel(r"Bits")
    plt.title("Empirical concentration of the entropy observable on CP^n")
    plt.legend(frameon=False)
    plt.tight_layout()
    out_path = outdir / "entropy_partial_diameter_vs_projective_dimension.png"
    plt.savefig(out_path, dpi=180)
    plt.close()
    return out_path


def plot_normalized_proxy(results: Sequence[SystemResult], outdir: Path) -> Path | None:
    good = [r for r in results if r.normalized_proxy_q99 == r.normalized_proxy_q99]
    if not good:
        return None
    x = np.array([r.projective_dim for r in good], dtype=float)
    y_max = np.array([r.normalized_proxy_max for r in good], dtype=float)
    y_q99 = np.array([r.normalized_proxy_q99 for r in good], dtype=float)

    plt.figure(figsize=(8.5, 5.5))
    plt.plot(x, y_max, marker="o", linewidth=2, label="width / sampled Lipschitz max")
    plt.plot(x, y_q99, marker="s", linewidth=2, label="width / sampled Lipschitz q99")
    plt.xlabel(r"Projective dimension $n = d_A d_B - 1$")
    plt.ylabel("Empirical normalized proxy (radians)")
    plt.title("Lipschitz-normalized entropy proxy for observable diameter")
    plt.legend(frameon=False)
    plt.tight_layout()
    out_path = outdir / "normalized_entropy_proxy_vs_projective_dimension.png"
    plt.savefig(out_path, dpi=180)
    plt.close()
    return out_path


def print_console_summary(results: Sequence[SystemResult]) -> None:
    print("dA dB  CP^n     mean(bits)  part_diam(bits)  Page(bits)  Hayden_cutoff(bits)  L_emp_q99")
    for r in results:
        lip_q99 = f"{r.empirical_lipschitz_q99:.4f}" if r.empirical_lipschitz_q99 == r.empirical_lipschitz_q99 else "nan"
        print(
            f"{r.d_a:2d} {r.d_b:2d}  {r.projective_dim:5d}  "
            f"{r.mean_bits:10.6f}  {r.partial_diameter_bits:15.6f}  "
            f"{r.page_average_bits:10.6f}  {r.hayden_cutoff_bits:20.6f}  {lip_q99}"
        )


def build_argument_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--dims",
        default="4x4,8x8,12x12,16x16,32x32,64x64,128x128",
        help="Comma-separated subsystem sizes, e.g. 4x4,8x8,8x16",
    )
    parser.add_argument("--samples", type=int, default=10**6, help="Samples per system")
    parser.add_argument("--kappa", type=float, default=1e-3, help="Observable-diameter loss parameter kappa")
    parser.add_argument(
        "--lipschitz-pairs",
        type=int,
        default=6000,
        help="Number of random state pairs used for empirical Lipschitz estimation",
    )
    parser.add_argument("--seed", type=int, default=7, help="RNG seed")
    parser.add_argument(
        "--outdir",
        type=str,
        default="cpn_entropy_output",
        help="Output directory for CSV and plots",
    )
    return parser


def main() -> None:
    parser = build_argument_parser()
    args = parser.parse_args()

    if not 0.0 < args.kappa < 1.0:
        raise ValueError("kappa must lie in (0, 1)")
    if args.samples < 10:
        raise ValueError("Use at least 10 samples per system")

    dims = parse_dims(args.dims)
    rng = np.random.default_rng(args.seed)

    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)

    results: List[SystemResult] = []
    for d_a, d_b in dims:
        result, _states = simulate_system(
            d_a=d_a,
            d_b=d_b,
            num_samples=args.samples,
            kappa=args.kappa,
            rng=rng,
            lipschitz_pairs=args.lipschitz_pairs,
        )
        results.append(result)
        plot_histogram(result, outdir)

    results = sorted(results, key=lambda r: r.projective_dim)
    write_summary_csv(results, outdir / "entropy_observable_summary.csv")
    plot_concentration_summary(results, outdir)
    plot_normalized_proxy(results, outdir)
    plot_tail(results[-1], outdir)
    print_console_summary(results)
    print(f"\nWrote results to: {outdir.resolve()}")


if __name__ == "__main__":
    main()
