"""Synthesis script for BBATop → re-bba-rb interposer board (iCE40UP5K SG48).

Run from workspace root:
    python -m exi_bba.synth              # synthesize only
    python -m exi_bba.synth --flash      # synthesize and flash

This file re-declares IceBreakerPlatform inline so that importing
rebbarb/rebbarb.py (which has a module-level platform.build() call) is avoided.
"""

import glob
import os
import re
import subprocess
import sys

from amaranth import *
from amaranth.build import *
from amaranth.vendor import LatticeICE40Platform

from exi_bba.bba_top import BBATop


# ── Platform definition ───────────────────────────────────────────────────
# Real re-bba-rb board pin map, pulled directly from the schematic netlist
# (U9 = iCE40UP5K-SG48ITR) — not iCEbreaker PMOD placeholders.
#
# EXI (GC side)   : CLK=44(G6)  MOSI=4(IOB_8a)  MISO=3(IOB_9b)  CS=45  INT=46
# W5100 bus (IOT bank, indirect parallel):
#   A0=42  A1=38   D0=34 D1=32 D2=31 D3=28 D4=27 D5=26 D6=25 D7=23
#   CS_N=43  RD_N=37  WR_N=36  RST_N=2 (net /ethernet/ETH_RST, NOT on the IOT
#   bank — separate pin)
# Board ties the W5100's upper address lines A[14:2] to 0 (only A[1:0]
# wired); DATA[7:0] is bidirectional (SB_IO tristate, shared output-enable).
#
# INT_N=13 — MOVED off the schematic's original pin 35: pin 35 physically
# conflicts with the iCE40UP5K's PLL hard macro (nextpnr: "PLL bel
# 'X12/Y31/pll_3' cannot be used... conflicts with input... on pin 35"),
# and this design needs the PLL for the 54 MHz capture-domain clock. Pin 35
# cannot be used as regular I/O at all while the PLL is instantiated,
# regardless of what drives it — this is a fixed silicon constraint, not a
# routing choice. Pin 13 was picked because it's the ONLY spare GPIO on this
# exact SG48 package/board combo: the 5k-sg48 package has 39 usable I/O pins
# total (per icestorm's icebox.py pin database), and every other one is
# already assigned to a real signal on this board — pin 13 shows up in the
# schematic netlist as an unrouted U9 pad, nothing else was available.
# DONE on the board: the schematic + PCB now carry /ethernet/ETH_INT on U9
# pin 13, and pin 35 is left unconnected (net
# "unconnected-(U9A-IOT_46b_G0-Pad35)") so the PLL hard macro can claim it.
# Verified against the schematic netlist 2026-07-31 — do not "restore" pin 35.
#
# Debug (J4 header, unpopulated): DBG0=9 DBG1=10 DBG2=11 DBG3=12 DBG4=6
# UART (FT2232H channel B): net UART_TXD is the FTDI's OUTPUT, so it is the
#   FPGA's RX input, and vice versa for UART_RXD — FPGA RX=18(UART_TXD net),
#   FPGA TX=19(UART_RXD net). Do not swap by "TXD means transmit" instinct.
# Status LEDs: D6(green,heartbeat)=LED_G=pin47, D7(red,EXI-activity)=LED_R=
#   pin48 — both wired ACTIVE-HIGH (LED anode toward the FPGA pin via its
#   series resistor, cathode to GND), unlike the iCEbreaker's own onboard
#   LEDs which are active-low. No physical button exists on this board (the
#   iCEbreaker's BTN_N was dev-board-only); panel_btn is tied idle instead.
# RGB status LED (D11=red/rx, D12=green/tx, D13=yellow/ready) is on the
#   iCE40UP5K's dedicated SB_RGBA_DRV pins 39/40/41 — fixed by the chip
#   package on any board, not board-specific, so no resource needed here.

# nextpnr P&R options.  The binding constraint is the isolated 54 MHz capture
# domain (the SPI Mode-3 bit engine); the 24 MHz sync domain has wide margin.
#
# TRIED (2026-08 shell build) and REVERTED — prioritising routing toward the
# critical capture paths did NOT help: `--router router2 --tmg-ripup
# --placer-heap-timingweight 30 --placer-heap-critexp 4` left capture at
# 41-49 MHz (best 49.07, still < 54.02) across seeds AND eroded the slow-clock
# margin (24.4-25.9 MHz vs the 28-33 that plain --opt-timing gives).  The
# capture shortfall is congestion + inherent path delay at this LC level, not a
# routing-priority tuning problem, so weighting P&R toward it only robs the
# sync domain.  Plain --opt-timing is the better baseline.
_PNR_TIMING_OPTS = "--opt-timing"


class IceBreakerPlatform(LatticeICE40Platform):
    device      = "iCE40UP5K"
    package     = "SG48"
    default_clk = "clk12"

    resources = [
        Resource("clk12", 0,
                 Pins("20", dir="i"),
                 Clock(12e6),
                 Attrs(GLOBAL=True, IO_STANDARD="SB_LVCMOS")),

        # EXI interface (GC side, SPI Mode 3)
        Resource("exi", 0,
                 Subsignal("clk",   Pins("44", dir="i")),
                 Subsignal("mosi",  Pins("4",  dir="i")),
                 Subsignal("miso",  Pins("3",  dir="o")),
                 Subsignal("cs_n",  Pins("45", dir="i")),
                 Subsignal("int_n", Pins("46", dir="o")),
                 Attrs(IO_STANDARD="SB_LVCMOS")),

        # W5100 indirect parallel bus
        Resource("w5100", 0,
                 Subsignal("addr",  Pins("42 38", dir="o")),
                 Subsignal("data",  Pins("34 32 31 28 27 26 25 23", dir="io")),
                 Subsignal("cs_n",  Pins("43", dir="o")),
                 Subsignal("rd_n",  Pins("37", dir="o")),
                 Subsignal("wr_n",  Pins("36", dir="o")),
                 Subsignal("int_n", Pins("13", dir="i")),
                 Subsignal("rst_n", Pins("2",  dir="o")),
                 Attrs(IO_STANDARD="SB_LVCMOS")),

        # Bring-up status panel: D6/D7 discrete LEDs (active-high on this
        # board). RGB (pins 39/40/41) is driven via SB_RGBA_DRV — not
        # declared here as a platform resource (see BBATopSynth.elaborate).
        # No onboard button on this board (see note above).
        Resource("ledr", 0, Pins("48", dir="o"), Attrs(IO_STANDARD="SB_LVCMOS")),
        Resource("ledg", 0, Pins("47", dir="o"), Attrs(IO_STANDARD="SB_LVCMOS")),

        # UART debug console → FT2232H Channel B.
        # On the PC: open the second USB serial port at 115200 8N1.
        Resource("uart", 0,
                 Subsignal("tx", Pins("19", dir="o")),
                 Subsignal("rx", Pins("18", dir="i")),
                 Attrs(IO_STANDARD="SB_LVCMOS")),
    ]

    connectors = []

    def toolchain_program(self, products, name):
        iceprog = os.environ.get("ICEPROG", "iceprog")
        with products.extract(f"{name}.bin") as bitstream_filename:
            subprocess.check_call([iceprog, bitstream_filename])


# ── BBATop with platform resource wiring ─────────────────────────────────

class BBATopSynth(BBATop):
    """BBATop with platform pin connections added in elaborate()."""

    def elaborate(self, platform):
        m = super().elaborate(platform)

        if platform is not None:
            exi   = platform.request("exi",   0)
            w5100 = platform.request("w5100", 0)

            m.d.comb += [
                self.exi_clk  .eq(exi.clk.i),
                self.exi_mosi .eq(exi.mosi.i),
                self.exi_cs_n .eq(exi.cs_n.i),
                exi.miso.o    .eq(self.exi_miso),
                exi.int_n.o   .eq(self.int_n),

                # W5100 parallel bus (DATA[7:0] bidirectional via SB_IO)
                w5100.addr.o     .eq(self.w5100_addr),
                w5100.data.o     .eq(self.w5100_data_o),
                w5100.data.oe    .eq(self.w5100_data_oe),
                self.w5100_data_i.eq(w5100.data.i),
                w5100.cs_n.o     .eq(self.w5100_cs_n),
                w5100.rd_n.o     .eq(self.w5100_rd_n),
                w5100.wr_n.o     .eq(self.w5100_wr_n),
                self.w5100_int_n .eq(w5100.int_n.i),
                w5100.rst_n.o    .eq(self.w5100_rst_n),
            ]

            # ── Bring-up status panel → onboard LEDs ────────────────────────
            # All 5 panel LEDs mapped:
            #   LEDG (pin 47) = led[0] heartbeat
            #   LEDR (pin 48) = led[1] EXI activity
            #   RGB  (pins 39/40/41) = led[2] rx / led[3] tx / led[4] ready
            # No physical button on this board — panel_btn tied idle/released.
            if self._status_panel:
                ledr = platform.request("ledr", 0)
                ledg = platform.request("ledg", 0)
                led  = self.panel_led

                m.d.comb += [
                    ledg.o.eq(led[0]),      # heartbeat    (active-high LED)
                    ledr.o.eq(led[1]),      # EXI activity (active-high LED)
                    # all 3 bits idle/released (active-low idle = 1) — no
                    # physical button exists on this board to read
                    self.panel_btn.eq(C(0b111, 3)),
                ]

                # iCEbreaker RGB LED has no series resistors — must use
                # SB_RGBA_DRV (raw pad driver with built-in current source).
                # RGB0=red→rx_act  RGB1=green→tx_act  RGB2=blue→ready
                # Verify colour-to-element mapping against schematic at bring-up.
                m.submodules.rgb_drv = Instance("SB_RGBA_DRV",
                    p_CURRENT_MODE="0b1",
                    p_RGB0_CURRENT="0b000001",
                    p_RGB1_CURRENT="0b000001",
                    p_RGB2_CURRENT="0b000001",
                    i_CURREN=Const(1, 1),
                    i_RGBLEDEN=Const(1, 1),
                    i_RGB0PWM=led[2],
                    i_RGB1PWM=led[3],
                    i_RGB2PWM=led[4],
                    o_RGB0=Signal(name="rgb_r"),
                    o_RGB1=Signal(name="rgb_g"),
                    o_RGB2=Signal(name="rgb_b"),
                )

            # ── UART debug console/shell → FT2232H Channel B ───────────────
            # Both the event-log console and the interactive command shell use
            # the same two UART pins (only one may be enabled at a time).
            if self._uart_console or self._uart_shell:
                uart = platform.request("uart", 0)
                m.d.comb += [
                    uart.tx.o       .eq(self.uart_tx),
                    self.uart_rx    .eq(uart.rx.i),
                ]

        return m


# ── Entry point ───────────────────────────────────────────────────────────
#
# Seed sweep: nextpnr placement is stochastic.  With ~22% LC utilisation
# routing dominates timing, so different seeds can vary fmax by ±20%.
# Pass --seeds N to try N seeds (default 1, i.e. seed 1 only).
# The build directory is reused across seeds; the final artefact in
# build/top.bin is the result of the last (or best) seed tried.

if __name__ == "__main__":
    import os
    do_flash  = "--flash"  in sys.argv
    # The interactive UART shell + UDP bring-up test is the default build;
    # pass --console to flash the old event-log console instead (they share the
    # UART pins, so only one can be built).
    use_shell = "--console" not in sys.argv
    n_seeds   = next((int(sys.argv[i+1]) for i, a in enumerate(sys.argv)
                      if a == "--seeds"), 1)

    # UDP bring-up test config (only used with --shell).  Set these env vars to
    # match your LAN — src_ip/subnet/gateway are the board's own identity for
    # the W5100 IP stack; the unicast destination is typed at runtime.
    udp_kw = dict(
        udp_socket   = int(os.environ.get("UDP_SOCKET", "3")),
        udp_src_ip   = os.environ.get("UDP_SRC_IP",  "192.168.1.123"),
        udp_subnet   = os.environ.get("UDP_SUBNET",  "255.255.255.0"),
        udp_gateway  = os.environ.get("UDP_GATEWAY", "192.168.1.1"),
        udp_dst_ip   = os.environ.get("UDP_DST_IP",  "192.168.1.100"),
        udp_src_port = int(os.environ.get("UDP_SRC_PORT", "40000")),
        udp_dst_port = int(os.environ.get("UDP_DST_PORT", "6464")),
    )

    def make_dut():
        if use_shell:
            return BBATopSynth(status_panel=True, uart_shell=True, **udp_kw)
        return BBATopSynth(status_panel=True, uart_console=True)

    print(f"Synthesizing BBATop for {IceBreakerPlatform.device}-"
          f"{IceBreakerPlatform.package}  "
          f"(do_program={do_flash}, seeds=1..{n_seeds}, "
          f"{'shell+UDP' if use_shell else 'console'})")
    if use_shell:
        print(f"  UDP: socket {udp_kw['udp_socket']}, "
              f"src {udp_kw['udp_src_ip']}:{udp_kw['udp_src_port']} "
              f"gw {udp_kw['udp_gateway']} mask {udp_kw['udp_subnet']} "
              f"dst-port {udp_kw['udp_dst_port']}")

    best_seed = 1
    best_fmax = 0.0
    results = []          # (seed, fmax_clk, fmax_capture, verdict), scored only
    for seed in range(1, n_seeds + 1):
        print(f"\n{'='*60}")
        print(f"  Seed {seed}/{n_seeds}")
        print(f"{'='*60}")
        opts = f"{_PNR_TIMING_OPTS} --seed {seed} --timing-allow-fail"

        # A Platform instance can only be built ONCE — amaranth's
        # TemplatedPlatform.prepare() does `assert not self._prepared`. Reusing
        # one platform across the sweep made seeds 2..N raise a bare
        # AssertionError (empty message, so the handler below printed nothing),
        # after which the fmax parser re-read the PREVIOUS seed's build/top.tim
        # and reported identical numbers for every seed — a sweep that looked
        # like it ran but never did. Build a fresh platform per seed.
        platform = IceBreakerPlatform()

        # Each seed gets its OWN build directory. Sharing one `build/top.tim`
        # across the sweep is not safe on this workspace: /workspace is a WSL2
        # drvfs (Windows drive) mount, and a re-read of a file just rewritten
        # by a child process can return stale or partially-flushed content.
        # That silently mis-scored the sweep — seeds were credited with other
        # seeds' numbers, including nextpnr's PRE-routing placement estimates
        # (which run ~8 MHz optimistic), so a 53.08 MHz FAIL got reported as a
        # 61.94 MHz PASS. Deleting the stale file first did NOT fix it; only
        # not sharing the path does. Do not "simplify" this back to build/.
        build_dir = f"build/seed{seed}"
        build_ok = True

        try:
            platform.build(make_dut(), do_program=False,
                           verbose=True, nextpnr_opts=opts, build_dir=build_dir)
        except Exception as exc:
            # nextpnr exits non-zero even with --timing-allow-fail on some
            # versions; treat as non-fatal timing failure.
            build_ok = False
            print(f"  [seed {seed}] build exception (timing?): "
                  f"{type(exc).__name__}: {exc}")

        # Parse fmax from nextpnr log in build/top.tim (if present). Domain
        # names are 'clk' (exi/sync, 24 MHz target) and 'capture_clk'
        # (54 MHz target, the tighter constraint) — NOT 'exi', which never
        # matched any real log line (this regex silently reported 0.0 MHz
        # for every seed until fixed).
        # nextpnr reports each domain TWICE: once post-placement (an estimate)
        # and once post-routing. Only the post-route number is real, so take
        # the LAST occurrence of each domain — never the first.
        #
        # A seed is only scored if BOTH domains were actually found. Defaulting
        # a missing value to 0.0 and scoring it anyway is how this script once
        # declared "Best seed: 8 ... PASS" while seed 8 in fact failed at
        # 49.66 MHz: unparsed seeds silently mixed with real ones. An
        # unparseable seed must be reported and skipped, never ranked.
        fmax = {}
        for tf in sorted(set(glob.glob(f"{build_dir}/*.tim"))):
            try:
                # errors="replace": a decode hiccup must not abort the parse
                # and leave the seed looking like a legitimate 0.0 MHz result.
                with open(tf, errors="replace") as f:
                    for line in f:
                        m_ = re.search(
                            r"Max frequency for clock\s+'(\w+)':\s*([\d.]+)\s*MHz", line)
                        if m_:
                            fmax[m_.group(1)] = float(m_.group(2))
            except OSError as exc:
                print(f"  [seed {seed}] could not read {tf}: {exc}")

        if "clk" not in fmax or "capture_clk" not in fmax:
            print(f"  [seed {seed}] NO USABLE TIMING REPORT "
                  f"(found {sorted(fmax) or 'nothing'}) — NOT SCORED"
                  f"{'' if build_ok else '; build also reported an error'}")
            continue

        fmax_clk, fmax_capture = fmax["clk"], fmax["capture_clk"]
        verdict = ("PASS" if fmax_capture >= 54.02 and fmax_clk >= 24.0
                   else "FAIL")
        results.append((seed, fmax_clk, fmax_capture, verdict))
        print(f"  [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24)  "
              f"capture_clk fmax: {fmax_capture:.2f} MHz (target 54.02)  "
              f"{verdict}"
              f"{'' if build_ok else '  [build reported an error]'}")
        # capture_clk is the binding constraint (tighter target, historically
        # the one that swings ±20% with seed) — rank seeds by it.
        if fmax_capture > best_fmax:
            best_fmax = fmax_capture
            best_seed = seed

    # Per-seed summary. capture_clk swings hard with seed on this design, so
    # the pass RATE matters as much as the best number — a design that only
    # closes on a minority of seeds has no real margin.
    print(f"\n{'='*60}")
    print(f"  Seed sweep summary ({len(results)}/{n_seeds} seeds scored)")
    print(f"{'='*60}")
    print(f"  {'seed':>4}  {'clk (≥24)':>10}  {'capture_clk (≥54.02)':>21}  verdict")
    for seed, fc, fcap, verdict in results:
        print(f"  {seed:>4}  {fc:>10.2f}  {fcap:>21.2f}  {verdict}")
    n_pass = sum(1 for *_, v in results if v == "PASS")
    if results:
        print(f"\n  passing seeds: {n_pass}/{len(results)} "
              f"({[s for s, *_, v in results if v == 'PASS']})")

    overall = "PASS" if best_fmax >= 54.02 else "FAIL"
    print(f"\nBest seed: {best_seed}  capture_clk fmax: {best_fmax:.2f} MHz "
          f"(target 54.02) — {overall}")
    if overall == "PASS" and n_pass * 2 < len(results):
        print("  NOTE: a MINORITY of seeds close timing. The bitstream from "
              "the best seed is usable, but this design has little margin — "
              "treat any logic addition as likely to break timing.")

    # build/ now holds one subdirectory per seed; the flashable bitstream for
    # the best seed is build/seed<N>/top.bin. There is no top-level build/top.bin.
    if results:
        print(f"\nBitstream for best seed: build/seed{best_seed}/top.bin")

    if do_flash and overall == "FAIL":
        print("\nREFUSING TO FLASH: no seed met the capture-domain timing "
              "constraint. The EXI front-end samples a 27 MHz clock and will "
              "miss bits. Re-run with more seeds (--seeds 16) or reduce logic.")
    elif do_flash:
        print(f"\nFlashing with seed {best_seed}...")
        opts = f"{_PNR_TIMING_OPTS} --seed {best_seed} --timing-allow-fail"
        # Fresh platform again — the sweep above already consumed one per seed.
        # Reuse the best seed's own build dir so the flashed bitstream is the
        # one that was actually measured.
        IceBreakerPlatform().build(
            make_dut(), do_program=True,
            verbose=True, nextpnr_opts=opts, build_dir=f"build/seed{best_seed}")

    print("Done.")
