"""Domain-wall melting in the clean open XXZ chain.

Method: exact propagation of the initial product state inside its conserved
total-Z sector (sparse Hamiltonian built from the instance's Pauli terms,
Chebyshev expansion of exp(-iHt) between requested times, spectrum bounded by
Gershgorin discs). When the sector is too large
for the budget (the 32-site runtime/sanity run), a second-order sweep-TEBD
matrix-product-state integrator with bounded bond dimension is used instead.

No offline training, fitting, preprocessing or reference lookup: the method
only reads the instance it is given.
"""
import time as _time
from itertools import combinations

import numpy as np

PRIORS = {
    "family": "exact-sector-chebyshev-with-tebd-fallback",
    "parameters": {
        "exact_max_sector_dim": 2_500_000,
        "chebyshev_coef_cutoff": 1e-16,
        "chebyshev_max_scaled_step": 60.0,
        "tebd_dt_max": 0.05,
        "tebd_trotter_order": 2,
        "tebd_max_bond_dim": 64,
        "tebd_svd_cutoff": 1e-10,
        "tebd_wall_clock_guard_s": 150.0,
    },
    "notes": (
        "Physics-only method; no training, fitting or use of reference traces. "
        "Hamiltonian is parsed from instance['terms'] (1-body Z, 2-body XX/YY/ZZ). "
        "If the initial product state's fixed-magnetisation sector has dimension <= "
        "exact_max_sector_dim, the state is propagated exactly in that sector with a "
        "Chebyshev expansion of exp(-iHt) (Bessel coefficients kept down to 1e-16; "
        "measured agreement with the public references ~1e-14). Otherwise (only the "
        "large runtime/sanity run) a symmetric sweep-TEBD MPS integrator is used with the "
        "stated step, bond-dimension cap and SVD cutoff; it is approximate at late times "
        "once entanglement exceeds the cap. Hybrid rule: the switch depends only on sector "
        "size. If TEBD exceeds the wall-clock guard, the bond cap is halved for the "
        "remaining steps (adaptive, declared)."
    ),
}


def _parse(instance):
    n = int(instance["n_sites"])
    two = {}  # (i, j) -> {"XX": a, "YY": b, "ZZ": c}
    field = np.zeros(n)
    const = 0.0
    for coef, ops in instance["terms"]:
        coef = float(coef)
        items = sorted((int(k), str(v).upper()) for k, v in ops.items())
        items = [(s, p) for s, p in items if p != "I"]
        if not items:
            const += coef
        elif len(items) == 1 and items[0][1] == "Z":
            field[items[0][0]] += coef
        elif len(items) == 2 and items[0][1] == items[1][1] and items[0][1] in ("X", "Y", "Z"):
            key = (items[0][0], items[1][0])
            d = two.setdefault(key, {"XX": 0.0, "YY": 0.0, "ZZ": 0.0})
            d[items[0][1] * 2] += coef
        else:
            raise ValueError("unsupported term %r" % (ops,))
    return n, two, field


def _initial_bits(instance, n):
    st = instance["initial_state"]
    if "bits" in st:
        return [int(b) for b in st["bits"]]
    if st.get("type") == "domain_wall":
        return [0] * (n // 2) + [1] * (n - n // 2)
    raise ValueError("unsupported initial state %r" % (st.get("type"),))


def _sector_dim(n, k):
    from math import comb
    return comb(n, k)


# ---------------------------------------------------------------- exact ----
def _exact(n, two, field, bits, times):
    import scipy.sparse as sp

    for d in two.values():
        if abs(d["XX"] - d["YY"]) > 1e-14:
            raise ValueError("XX != YY breaks total-Z conservation")
    k = sum(bits)
    states = np.zeros(_sector_dim(n, k), dtype=np.int64)
    for idx, occ in enumerate(combinations(range(n), k)):
        v = 0
        for s in occ:
            v |= 1 << s
        states[idx] = v
    states.sort()
    dim = states.size
    zsite = 1 - 2 * ((states[:, None] >> np.arange(n)[None, :]) & 1)  # dim x n, int
    diag = zsite.astype(float) @ field
    rows, cols, vals = [], [], []
    for (i, j), d in two.items():
        zi, zj = zsite[:, i], zsite[:, j]
        diag += d["ZZ"] * zi * zj
        hop = d["XX"] + d["YY"]
        if hop != 0.0:
            anti = np.nonzero(zi != zj)[0]
            flipped = states[anti] ^ ((1 << i) | (1 << j))
            tgt = np.searchsorted(states, flipped)
            rows.append(tgt)
            cols.append(anti)
            vals.append(np.full(anti.size, hop))
    rows.append(np.arange(dim))
    cols.append(np.arange(dim))
    vals.append(diag)
    H = sp.csr_matrix(
        (np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(dim, dim)
    )
    v0 = 0
    for s, b in enumerate(bits):
        if b:
            v0 |= 1 << s
    psi = np.zeros(dim, dtype=np.complex128)
    psi[np.searchsorted(states, v0)] = 1.0
    zf = zsite.astype(float)

    # Chebyshev propagator; spectrum bounded by Gershgorin discs of real H.
    absH = abs(H)
    rad = np.asarray(absH.sum(axis=1)).ravel() - np.abs(diag)
    emin, emax = float(np.min(diag - rad)), float(np.max(diag + rad))
    a = 0.5 * (emax - emin) * (1 + 1e-9) + 1e-12
    b = 0.5 * (emax + emin)
    Hs = ((H - b * sp.identity(dim, format="csr")) / a).tocsr()

    def cheb_step(v, dt):
        from scipy.special import jv
        x = a * dt
        kmax = int(x + 10 * x ** (1 / 3) + 30)
        c = jv(np.arange(kmax + 1), x)
        # keep terms until coefficients are negligible
        tail = np.nonzero(np.abs(c) > 1e-16)[0]
        kmax = int(tail[-1]) + 2 if tail.size else 2
        V = np.stack([v.real, v.imag], axis=1)
        t0v, t1v = V, Hs @ V
        res = c[0] * (t0v[:, 0] + 1j * t0v[:, 1])
        ph = -1j
        res = res + 2 * ph * c[1] * (t1v[:, 0] + 1j * t1v[:, 1])
        for k in range(2, min(kmax, c.size - 1) + 1):
            t2v = 2 * (Hs @ t1v) - t0v
            ph *= -1j
            res = res + 2 * ph * c[k] * (t2v[:, 0] + 1j * t2v[:, 1])
            t0v, t1v = t1v, t2v
        return np.exp(-1j * b * dt) * res

    times = np.asarray(times, dtype=float)
    out = np.zeros((times.size, n))
    order = np.argsort(times, kind="stable")
    t_cur = 0.0
    for idx in order:
        t = times[idx]
        if t != t_cur:
            dt = t - t_cur
            nsub = max(1, int(np.ceil(abs(dt) * a / 60.0)))
            for _ in range(nsub):
                psi = cheb_step(psi, dt / nsub)
            psi /= np.linalg.norm(psi)
            t_cur = t
        out[idx] = (np.abs(psi) ** 2) @ zf
    return out


# ----------------------------------------------------------------- TEBD ----
_X = np.array([[0, 1], [1, 0]], dtype=complex)
_Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
_Z = np.array([[1, 0], [0, -1]], dtype=complex)


def _bond_h(n, two, field, i):
    d = two.get((i, i + 1), {"XX": 0.0, "YY": 0.0, "ZZ": 0.0})
    h = d["XX"] * np.kron(_X, _X) + d["YY"] * np.kron(_Y, _Y) + d["ZZ"] * np.kron(_Z, _Z)
    I2 = np.eye(2)
    # split on-site fields between neighbouring bonds
    wl = 1.0 if i == 0 else 0.5
    wr = 1.0 if i + 1 == n - 1 else 0.5
    h = h + wl * field[i] * np.kron(_Z, I2) + wr * field[i + 1] * np.kron(I2, _Z)
    return h


def _gate(h, dt):
    w, v = np.linalg.eigh(h)
    return (v * np.exp(-1j * w * dt)) @ v.conj().T


def _apply(M, i, g, chi, cutoff, absorb_right):
    a, b = M[i], M[i + 1]
    l, r = a.shape[0], b.shape[2]
    th = (a.reshape(l * 2, -1) @ b.reshape(b.shape[0], 2 * r)).reshape(l, 4, r)
    th = (g @ th).reshape(l * 2, 2 * r)  # g: (4, 4) acting on the physical pair
    u, s, vh = np.linalg.svd(th, full_matrices=False)
    keep = max(1, min(chi, int(np.sum(s > cutoff * s[0]))))
    u, s, vh = u[:, :keep], s[:keep], vh[:keep]
    s = s / np.linalg.norm(s)
    if absorb_right:
        M[i] = u.reshape(l, 2, keep)
        M[i + 1] = (s[:, None] * vh).reshape(keep, 2, r)
    else:
        M[i] = (u * s[None, :]).reshape(l, 2, keep)
        M[i + 1] = vh.reshape(keep, 2, r)


def _measure_z(M, n):
    # After the backward sweep every tensor right of site 0 is right-canonical,
    # so only a left environment is needed.
    L = np.ones((1, 1), dtype=complex)
    z = np.zeros(n)
    for i in range(n):
        A = M[i]
        LA = np.tensordot(L, A, axes=(1, 0))  # (a', s, b)
        up = np.tensordot(A[:, 0, :].conj(), LA[:, 0, :], axes=(0, 0))  # (b*, b)
        dn = np.tensordot(A[:, 1, :].conj(), LA[:, 1, :], axes=(0, 0))
        z[i] = np.real(np.trace(up) - np.trace(dn))
        L = up + dn
    norm = np.real(np.trace(L))
    return np.clip(z / norm, -1.0, 1.0)


def _tebd(n, two, field, bits, times, t0):
    p = PRIORS["parameters"]
    chi, cutoff, dtmax = p["tebd_max_bond_dim"], p["tebd_svd_cutoff"], p["tebd_dt_max"]
    M = []
    for b in bits:
        t = np.zeros((1, 2, 1), dtype=complex)
        t[0, b, 0] = 1.0
        M.append(t)
    hs = [_bond_h(n, two, field, i) for i in range(n - 1)]
    times = np.asarray(times, dtype=float)
    out = np.zeros((times.size, n))
    order = np.argsort(times, kind="stable")
    t_cur = 0.0
    gates_cache = {}
    for idx in order:
        t = times[idx]
        if t > t_cur:
            nst = int(np.ceil((t - t_cur) / dtmax - 1e-12))
            dt = (t - t_cur) / nst
            key = round(dt, 14)
            if key not in gates_cache:
                gates_cache[key] = [_gate(h, dt / 2) for h in hs]
            gs = gates_cache[key]
            for _ in range(nst):
                if _time.time() - t0 > p["tebd_wall_clock_guard_s"] and chi > 8:
                    chi = max(8, chi // 2)
                    t0 = _time.time()
                # orthogonality centre at site 0 before the forward sweep
                for i in range(n - 1):
                    _apply(M, i, gs[i], chi, cutoff, absorb_right=True)
                for i in range(n - 2, -1, -1):
                    _apply(M, i, gs[i], chi, cutoff, absorb_right=False)
            t_cur = t
        out[idx] = _measure_z(M, n)
    return out


def simulate(instance, times):
    t0 = _time.time()
    n, two, field = _parse(instance)
    bits = _initial_bits(instance, n)
    if _sector_dim(n, sum(bits)) <= PRIORS["parameters"]["exact_max_sector_dim"]:
        out = _exact(n, two, field, bits, times)
    else:
        out = _tebd(n, two, field, bits, times, t0)
    return np.clip(np.asarray(out, dtype=float), -1.0, 1.0)
