"""End-to-end inverse design of a 50 um SiN taper (200 nm tip -> 1 um waveguide) at 1310 nm.

Self-contained: stack + geometry, adjoint (FDTD/autograd) shape optimization, and an
independent EME verification of both the linear start and the optimized result.

Platform: Si substrate / 2 um BOX / 400 nm SiN (n=2.0) / 2 um SiO2 cladding / air.
Design variables: taper half-width at 8 control points, clustered toward the tip,
fed through a cubic interpolating spline (zero end slopes). Widths are bounded at
>= 200 nm and a curvature penalty (min radius 200 nm) is a safety net, so no
sub-200 nm feature can appear.

Expected result, reproducing the published run exactly (verified on tidy3d 2.12.0):
    FDTD objective      0.5869 dB -> 0.2351 dB over 35 adjoint steps
    EME verification    0.5029 dB -> 0.1804 dB   (0.32 dB, 2.7x less lost power)
    min width 200.0 nm, min curvature radius 23.4 um

Cost: ~1.9 FlexCredits per adjoint step (~67 FC for 35 steps) + ~12 FC for the two
EME checks. Each step prints as it lands.

    python taper_inverse_design.py                # full run: optimize, then verify
    python taper_inverse_design.py --steps 3      # short pipeline check
    python taper_inverse_design.py --verify-only  # EME-check a saved result
"""

import argparse
import json
import os

import autograd.numpy as anp
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import tidy3d as td
import tidy3d.web as web
from tidy3d.plugins.autograd import (
    adam, interpolate_spline, make_curvature_penalty, optimize,
)

# ---------------------------------------------------------------- platform ---
LDA0 = 1.31
FREQ0 = td.C_0 / LDA0
N_SIN, N_OX, N_SI = 2.0, 1.444, 3.504  # SiN, SiO2, Si at 1310 nm
T_SIN, T_BOX, T_CLAD = 0.40, 2.0, 2.0

SIN = td.Medium(permittivity=N_SIN**2)
OX = td.Medium(permittivity=N_OX**2)
SI = td.Medium(permittivity=N_SI**2)
Z_BOX_BOT = -(T_SIN / 2 + T_BOX)
Z_CLAD_TOP = T_SIN / 2 + T_CLAD

# ------------------------------------------------------------------- taper ---
L_TAPER = 50.0
W_TIP, W_WG = 0.20, 1.00
SIZE_Y, SIZE_Z = 12.0, 10.0  # transverse window, shared by FDTD and EME
NUM_PTS = 101                # boundary points along the taper
X_PAD_FDTD = 3.0             # straight lead; source/monitor sit 1.5 um clear of the PML
X_PAD_EME = 2.0              # straight lead = one EME cell at each end

# Control points cluster toward the tip: that is where n_eff and the mode size change
# fastest with width, so that is where the shape needs freedom.
S_CTRL = np.array([0.0, 0.02, 0.05, 0.10, 0.18, 0.30, 0.45, 0.62, 0.80, 1.0])
Y_TIP, Y_OUT = W_TIP / 2, W_WG / 2  # fixed half-widths at the two ends
Y_MIN, Y_MAX = 0.10, 0.60           # >= 200 nm width everywhere
MIN_RADIUS = 0.20                   # um

curvature_penalty = make_curvature_penalty(min_radius=MIN_RADIUS)
HIST_FILE = "ide_history.json"


def boundary(params, l_taper=L_TAPER, num_pts=NUM_PTS):
    """8 control half-widths -> smooth boundary sampled at num_pts points."""
    x_ctrl = -l_taper / 2 + l_taper * S_CTRL
    y_ctrl = anp.concatenate([anp.array([Y_TIP]), params, anp.array([Y_OUT])])
    _, ys = interpolate_spline(
        x_ctrl, y_ctrl, num_points=num_pts, order=3, endpoint_derivatives=(0.0, 0.0)
    )
    return ys


def params_linear():
    """The linear taper in the control-point basis: the optimizer's starting design.

    The zero-end-slope spline bows away from a true straight line by up to 13 nm,
    worth 0.001 dB, so `linear_ys` below is used for the reported baseline.
    """
    return (Y_TIP + (Y_OUT - Y_TIP) * S_CTRL)[1:-1]


def linear_ys():
    """An exact straight taper, the reference the optimized design is measured against."""
    return np.array([Y_TIP, Y_OUT])


def taper_structure(ys, x_end, pad_out):
    """PolySlab whose half-widths `ys` are the (differentiable) design variables."""
    xs = anp.linspace(-L_TAPER / 2, L_TAPER / 2, len(ys))
    # straight leads run out through the domain boundary at both ends
    xs_full = anp.concatenate([anp.array([-x_end - pad_out]), xs,
                               anp.array([x_end + pad_out])])
    ys_full = anp.concatenate([anp.array([ys[0]]), ys, anp.array([ys[-1]])])
    verts = anp.concatenate([
        anp.column_stack((xs_full, ys_full)),
        anp.column_stack((xs_full[::-1], -ys_full[::-1])),
    ])
    return td.Structure(
        geometry=td.PolySlab(vertices=verts, slab_bounds=(-T_SIN / 2, T_SIN / 2), axis=2),
        medium=SIN,
    )


def stack():
    """Oxide half-space up to the cladding top, plus the Si substrate below the BOX."""
    return [
        td.Structure(
            geometry=td.Box.from_bounds((-td.inf, -td.inf, -50), (td.inf, td.inf, Z_CLAD_TOP)),
            medium=OX),
        td.Structure(
            geometry=td.Box.from_bounds((-td.inf, -td.inf, -50), (td.inf, td.inf, Z_BOX_BOT)),
            medium=SI),
    ]


# ------------------------------------------------------- FDTD forward model ---
def make_fdtd(ys, min_steps=15):
    x_end = L_TAPER / 2 + X_PAD_FDTD
    # PML on the mode planes: without it the solver locks onto Si-substrate modes.
    # The ends need different targets -- the tip mode sits at n_eff 1.446 and the
    # 1 um output at 1.702, so one target would pick a radiation mode at one end.
    src = td.ModeSource(
        center=(-x_end + 1.5, 0, 0), size=(0, SIZE_Y, SIZE_Z),
        source_time=td.GaussianPulse(freq0=FREQ0, fwidth=FREQ0 / 20), direction="+",
        mode_spec=td.ModeSpec(num_modes=1, target_neff=1.45, num_pml=(12, 12)),
        mode_index=0)
    mon = td.ModeMonitor(
        center=(x_end - 1.5, 0, 0), size=(0, SIZE_Y, SIZE_Z), freqs=[FREQ0],
        mode_spec=td.ModeSpec(num_modes=1, target_neff=1.75, num_pml=(12, 12)), name="out")
    dl_sub = 0.08 * 15 / min_steps  # coarse deep-substrate mesh, tied to min_steps
    t_transit = 2 * x_end * 1.8 / td.C_0  # n_g ~ 1.8 upper bound
    return td.Simulation(
        size=(2 * x_end, SIZE_Y, SIZE_Z), center=(0, 0, 0),
        medium=td.Medium(),  # air above the cladding
        structures=[*stack(), taper_structure(ys, x_end, 2.0)],
        sources=[src], monitors=[mon],
        run_time=max(1.2e-12, 2.5 * t_transit),
        symmetry=(0, -1, 0),  # fundamental TE (Ey-dominant)
        boundary_spec=td.BoundarySpec.all_sides(boundary=td.PML()),
        grid_spec=td.GridSpec.auto(
            wavelength=LDA0, min_steps_per_wvl=min_steps,
            override_structures=[td.MeshOverrideStructure(
                geometry=td.Box.from_bounds((-td.inf,) * 2 + (-50,), (td.inf, td.inf, -3.5)),
                dl=(dl_sub,) * 3)]),
    )


def transmission(sim_data):
    amp = sim_data["out"].amps.sel(direction="+", mode_index=0, f=FREQ0)
    return anp.sum(anp.abs(amp) ** 2)


def objective(params):
    """TE0 transmission minus the curvature penalty. Maximized."""
    ys = boundary(params)
    data = web.run(make_fdtd(ys), task_name="ide_opt", folder_name="taper",
                   verbose=False, local_gradient=False)
    xs = anp.linspace(-L_TAPER / 2, L_TAPER / 2, len(ys))
    return transmission(data) - curvature_penalty(anp.stack([xs, ys], axis=-1))


# --------------------------------------------------- EME verification model ---
def make_eme(ys, num_cells=100, num_modes=40):
    x_end = L_TAPER / 2 + X_PAD_EME
    ys = np.asarray(ys, dtype=float)
    mode_spec = td.EMEModeSpec(num_modes=num_modes, target_neff=1.55, num_pml=(12, 12))
    return td.EMESimulation(
        size=(2 * x_end, SIZE_Y, SIZE_Z), center=(0, 0, 0),
        medium=td.Medium(),
        structures=[*stack(), taper_structure(ys, x_end, 5.0)],
        axis=0, freqs=[FREQ0],
        eme_grid_spec=td.EMECompositeGrid(  # 1 cell per lead, num_cells across the taper
            subgrids=[td.EMEUniformGrid(num_cells=1, mode_spec=mode_spec),
                      td.EMEUniformGrid(num_cells=num_cells, mode_spec=mode_spec),
                      td.EMEUniformGrid(num_cells=1, mode_spec=mode_spec)],
            subgrid_boundaries=[-L_TAPER / 2, L_TAPER / 2]),
        grid_spec=td.GridSpec.auto(
            wavelength=LDA0, min_steps_per_wvl=15,
            override_structures=[
                td.MeshOverrideStructure(
                    geometry=td.Box(center=(0, 0, 0), size=(td.inf, 1.6, 0.9)),
                    dl=(None, 0.02, 0.02)),
                td.MeshOverrideStructure(
                    geometry=td.Box.from_bounds((-td.inf,) * 2 + (-50,), (td.inf, td.inf, -3.5)),
                    dl=(None, 0.06, 0.06))]),
        symmetry=(0, -1, 0),
        store_port_modes=False,  # the S-matrix is all we need
    )


def eme_loss_db(params_opt):
    """Run both designs through EME in one batch. -> (linear dB, optimized dB)."""
    sims = {"ide_eme_linear": make_eme(linear_ys()),
            "ide_eme_optimized": make_eme(boundary(params_opt))}
    ids = {}
    for name, sim in sims.items():
        sim.validate_pre_upload()
        ids[name] = web.upload(sim, task_name=name, folder_name="taper", verbose=False)
        print(f"  {name:20s} {web.estimate_cost(ids[name], verbose=False):5.2f} FC", flush=True)
    for tid in ids.values():
        web.start(tid)
    out = []
    for name, tid in ids.items():
        web.monitor(tid, verbose=False)
        d = web.load(tid, path=f"data/{name}.hdf5", verbose=False)
        s21 = d.smatrix.S21.isel(f=0, sweep_index=0).sel(mode_index_in=0, mode_index_out=0)
        out.append(float(-10 * np.log10(np.abs(s21.values) ** 2)))
        print(f"  {name:20s} {out[-1]:.4f} dB", flush=True)
    return out


# ------------------------------------------------------------------- report ---
def min_radius_um(ys):
    xs = np.linspace(-L_TAPER / 2, L_TAPER / 2, len(ys))
    p = np.stack([xs, np.asarray(ys)], axis=1)
    a = np.linalg.norm(p[1:-1] - p[:-2], axis=1)
    b = np.linalg.norm(p[2:] - p[1:-1], axis=1)
    c = np.linalg.norm(p[2:] - p[:-2], axis=1)
    cross = np.abs(np.cross(p[1:-1] - p[:-2], p[2:] - p[1:-1]))
    return float(np.min(np.where(cross > 0, a * b * c / (2 * np.maximum(cross, 1e-30)), np.inf)))


def report(hist, params_opt, eme):
    ys = np.array(boundary(params_opt))
    xs = np.linspace(-L_TAPER / 2, L_TAPER / 2, len(ys))
    lin, opt = eme
    ratio = (1 - 10 ** (-lin / 10)) / (1 - 10 ** (-opt / 10))
    print("\n" + "=" * 62)
    print(f"{'FDTD objective':<22} {-10*np.log10(hist['J'][0]):.4f} dB -> "
          f"{-10*np.log10(hist['J'][-1]):.4f} dB   ({len(hist['J'])} steps)")
    print(f"{'EME verification':<22} {lin:.4f} dB -> {opt:.4f} dB")
    print(f"{'improvement':<22} {lin - opt:.4f} dB   ({ratio:.2f}x less lost power)")
    print(f"{'min width':<22} {2e3*ys.min():.1f} nm")
    print(f"{'min curvature radius':<22} {min_radius_um(ys):.2f} um")
    print("=" * 62)

    fig, ax = plt.subplots(1, 2, figsize=(11, 4), tight_layout=True)
    ax[0].plot(xs, 2e3 * ys, label=f"optimized ({opt:.3f} dB)")
    ax[0].plot([-L_TAPER / 2, L_TAPER / 2], [1e3 * W_TIP, 1e3 * W_WG], "--",
               label=f"linear ({lin:.3f} dB)")
    ax[0].set(xlabel="x (um)", ylabel="taper width (nm)", title=f"L = {L_TAPER:.0f} um")
    ax[0].grid(alpha=0.3); ax[0].legend()
    ax[1].plot(-10 * np.log10(np.array(hist["J"])), "o-", ms=3)
    ax[1].set(xlabel="adjoint step", ylabel="FDTD objective (dB)", title="optimization")
    ax[1].grid(alpha=0.3)
    plt.savefig("taper_inverse_design.png", dpi=130)
    print("wrote taper_inverse_design.png")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--steps", type=int, default=35)
    ap.add_argument("--lr", type=float, default=0.01)
    ap.add_argument("--verify-only", action="store_true")
    args = ap.parse_args()
    print(f"tidy3d {td.__version__}")

    if args.verify_only:
        hist = json.load(open(HIST_FILE))
        p_opt = np.array(hist["best"])
    else:
        hist = {"J": [], "p": []}

        def cb(p, g, state, i, J):
            hist["J"].append(float(J))
            hist["p"].append(np.array(p).tolist())
            json.dump(hist, open(HIST_FILE, "w"))
            print(f"step {i+1:3d}  J = {J:.5f}  loss = {-10*np.log10(max(J,1e-9)):.4f} dB  "
                  f"|g| = {np.linalg.norm(g):.2e}", flush=True)

        p_opt, _, _ = optimize(
            objective, params0=params_linear(), optimizer=adam(learning_rate=args.lr),
            num_steps=args.steps, bounds=(Y_MIN, Y_MAX), direction="max", callback=cb)
        p_opt = np.array(p_opt)
        json.dump({"best": p_opt.tolist(), **hist}, open(HIST_FILE, "w"))
        print("optimized control half-widths (um):", np.round(p_opt, 4))

    print("\nEME verification:")
    report(hist, p_opt, eme_loss_db(p_opt))
