#!/usr/bin/env python3
"""Conceptual latency-model diagram for the KernBench paper (v5).

Generic naming (Requester Node A/B, Router, Destination Node), with
Router internals visible (two input ports -> switch -> output queue ->
output port) so the queuing delay can be located *at the out port*
rather than at the box edge.  The Destination Node shows queue -> drain
slot -> processing logic.

Highlights:
  * Same-size Routers, each with explicit switching logic + output queue.
  * Edge labels removed -- the wires speak for themselves.
  * Annotation lines are strictly vertical; arrow heads use a larger
    mutation_scale so no thin line protrudes past the tip; `shrinkA`
    pulls the tail away from the text so they no longer overlap.
  * "flit-level interleaving on wires" (not "shared FIFO").
  * Destination: queue -> drain -> processing logic (no "served").

Output:
    docs/report/1H-codesign-paper/figures/latency_model.png

Per /paper isolation: this is a report-only harness under scripts/paper/.
"""

import math
from pathlib import Path

import matplotlib.patches as patches
import matplotlib.pyplot as plt

OUT = Path(
    "/Users/ywkang/kernbench/docs/report/1H-codesign-paper/figures/latency_model.png"
)
OUT.parent.mkdir(parents=True, exist_ok=True)


# --- Colours --------------------------------------------------------------
C_A      = "#E07B5E"
C_B      = "#5E9BD1"
C_NODE   = "#FFFFFF"
C_BORD   = "#222222"
C_WIRE   = "#222222"
C_ANN    = "#333333"
C_DIM    = "#777777"
C_SWITCH = "#FAFAFA"
C_OQUEUE = "#EFEFEF"
C_PROC   = "#F4ECDC"
C_QUEUE  = "#3CB371"   # medium-sea-green: distinctive vs A/B flit colours


# --- Canvas ---------------------------------------------------------------
fig = plt.figure(figsize=(17.0, 5.0))
ax = fig.add_subplot(111)
ax.set_xlim(0, 33)
ax.set_ylim(2.2, 11.2)
ax.axis("off")


# --- Top header: end-to-end latency formula -----------------------------
ax.text(
    16.5, 10.6,
    r"End-to-end latency  =  $\Sigma$ per-node overhead  +  "
    r"$\Sigma$ per-edge transmission  +  drain  +  "
    r"queuing delay",
    ha="center", fontsize=12, color=C_ANN, weight="bold",
)
ax.plot([1.5, 31.5], [10.05, 10.05], color=C_DIM, lw=0.6)


# --- Helpers --------------------------------------------------------------
def box(cx, cy, w, h, label, fs=11, fweight="normal"):
    ax.add_patch(patches.FancyBboxPatch(
        (cx - w / 2, cy - h / 2), w, h,
        boxstyle="round,pad=0.05",
        facecolor=C_NODE, edgecolor=C_BORD, linewidth=1.6,
    ))
    if label:
        ax.text(cx, cy, label, ha="center", va="center",
                fontsize=fs, weight=fweight)


def draw_flit_aligned(cx, cy, angle_rad, w, h, color, label):
    """Draw a flit polygon rotated to align with the wire angle."""
    cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad)
    corners = []
    for dx, dy in [(-w / 2, -h / 2), (w / 2, -h / 2),
                   (w / 2, h / 2), (-w / 2, h / 2)]:
        rx = dx * cos_a - dy * sin_a
        ry = dx * sin_a + dy * cos_a
        corners.append((cx + rx, cy + ry))
    ax.add_patch(patches.Polygon(
        corners, facecolor=color, edgecolor="black", linewidth=0.5,
    ))
    ax.text(cx, cy, label, ha="center", va="center",
            fontsize=9, color="white", weight="bold")


def draw_wire_with_flits(x0, y0, x1, y1, n_flits, label_char,
                         color=None, flit_w=0.66, flit_h=0.95, gap=0.10):
    """Wire (line) + endpoint arrowhead + flits centred on the line.

    Wires are drawn in the neutral C_WIRE colour: the wire itself is
    the place where the *transmission* delay accumulates (flit_size /
    BW), not where flits queue. Queuing happens before the wire, at
    the FIFO at the egress side -- coloured separately.
    """
    head_back = 0.30
    dx, dy = x1 - x0, y1 - y0
    wire_len = math.hypot(dx, dy)
    ux, uy = dx / wire_len, dy / wire_len
    x_line_end = x1 - ux * head_back
    y_line_end = y1 - uy * head_back
    ax.plot([x0, x_line_end], [y0, y_line_end],
            color=C_WIRE, lw=1.6, zorder=1)
    head_len, head_half = 0.30, 0.16
    bx = x1 - ux * head_len
    by = y1 - uy * head_len
    px, py = -uy, ux
    tri = [
        (x1, y1),
        (bx + px * head_half, by + py * head_half),
        (bx - px * head_half, by - py * head_half),
    ]
    ax.add_patch(patches.Polygon(tri, facecolor=C_WIRE,
                                  edgecolor=C_WIRE, linewidth=0.0))
    # Flit train
    angle_rad = math.atan2(dy, dx)
    train_len = n_flits * flit_w + (n_flits - 1) * gap
    centre_p = 0.5
    start_p  = centre_p - (train_len / 2) / wire_len
    step_p   = (flit_w + gap) / wire_len
    if isinstance(label_char, str):
        labels = [label_char] * n_flits
        cols   = [color] * n_flits
    else:
        labels = [c[0] for c in label_char]
        cols   = [c[1] for c in label_char]
    for i in range(n_flits):
        p = start_p + (i + 0.5) * step_p
        cx = x0 + p * dx
        cy = y0 + p * dy
        draw_flit_aligned(cx, cy, angle_rad,
                          flit_w, flit_h, cols[i], labels[i])


def draw_router(cx, cy, w, h, two_inputs=True):
    """Same-size Router with explicit internal switching logic and an
    output queue.  Returns the wire-attachment (x,y) for each input
    port and the single output port:
      ((in1_x, in1_y), (in2_x, in2_y) or None, (out_x, out_y))
    """
    # Outer box
    box(cx, cy, w, h, "")
    ax.text(cx, cy + h / 2 - 0.32, "Router",
            ha="center", fontsize=10, weight="bold")

    # Input ports (small circles on the left edge)
    in_x = cx - w / 2
    in_x_internal = in_x + 0.25
    if two_inputs:
        in_y_top = cy + 0.55
        in_y_bot = cy - 0.55
        for iy in (in_y_top, in_y_bot):
            ax.add_patch(patches.Circle(
                (in_x_internal, iy), 0.12,
                facecolor="white", edgecolor=C_BORD, linewidth=0.9,
            ))
    else:
        in_y_top = None
        in_y_bot = cy
        ax.add_patch(patches.Circle(
            (in_x_internal, in_y_bot), 0.12,
            facecolor="white", edgecolor=C_BORD, linewidth=0.9,
        ))

    # Switch (small box, centre-left-ish). sw_cy = cy so that the
    # output queue and (single-input) input port are at the same y as
    # the router centre -- this keeps all router-to-router edges
    # strictly horizontal.
    sw_w, sw_h = 0.85, 1.10
    sw_cx = cx - 0.55
    sw_cy = cy
    # The switch is the router's processing logic -- colour it the
    # same as the Destination Node's processing-logic block so the
    # two read as the same "processing" concept.
    ax.add_patch(patches.Rectangle(
        (sw_cx - sw_w / 2, sw_cy - sw_h / 2), sw_w, sw_h,
        facecolor=C_PROC, edgecolor=C_BORD, linewidth=0.8,
    ))
    ax.text(sw_cx, sw_cy, "switch", ha="center", va="center",
            fontsize=7.5, style="italic")

    # Short feeder lines (no arrowhead) from input ports to the
    # switch.  We deliberately drop arrowheads here: the head + port
    # circle were too small at this scale and read as an overlap.
    sw_left_x = sw_cx - sw_w / 2
    if two_inputs:
        for iy in (in_y_top, in_y_bot):
            ax.plot(
                [in_x_internal + 0.12, sw_left_x],
                [iy, sw_cy + 0.25 * ((iy - cy) / 0.55)],
                color=C_DIM, lw=0.7, zorder=1,
            )
    else:
        ax.plot(
            [in_x_internal + 0.12, sw_left_x],
            [in_y_bot, sw_cy],
            color=C_DIM, lw=0.7, zorder=1,
        )

    # Output queue (small queue holding a couple of flits). This *is*
    # a real queueing location, so its fill takes the C_QUEUE family.
    oq_w, oq_h = 0.95, 0.55
    oq_cx = cx + 0.55
    oq_cy = sw_cy
    ax.add_patch(patches.Rectangle(
        (oq_cx - oq_w / 2, oq_cy - oq_h / 2), oq_w, oq_h,
        facecolor="#D9F0E1", edgecolor=C_QUEUE, linewidth=0.9,
    ))
    ax.text(oq_cx, oq_cy + oq_h / 2 + 0.18, "FIFO",
            ha="center", fontsize=7, color=C_DIM, style="italic")
    # Two small flits inside (A and B) hinting at the in-flight contents
    mini_w, mini_h = 0.22, 0.34
    for i, (col, lab) in enumerate([(C_A, "A"), (C_B, "B")]):
        mx = oq_cx - 0.30 + i * (mini_w + 0.06)
        ax.add_patch(patches.Rectangle(
            (mx, oq_cy - mini_h / 2), mini_w, mini_h,
            facecolor=col, edgecolor="black", linewidth=0.3,
        ))
        ax.text(mx + mini_w / 2, oq_cy, lab,
                ha="center", va="center",
                fontsize=5.5, color="white", weight="bold")

    # Short feeder line (no arrowhead) from switch into out queue
    ax.plot(
        [sw_cx + sw_w / 2, oq_cx - oq_w / 2],
        [sw_cy, oq_cy],
        color=C_DIM, lw=0.7, zorder=1,
    )
    # Short feeder line from out queue to out port
    out_x_internal = cx + w / 2 - 0.25
    ax.plot(
        [oq_cx + oq_w / 2, out_x_internal - 0.12],
        [oq_cy, oq_cy],
        color=C_DIM, lw=0.7, zorder=1,
    )
    # Output port circle on the right edge (a port marker, not a
    # queue -- the queue is on the wire that follows, not the port).
    ax.add_patch(patches.Circle(
        (out_x_internal, oq_cy), 0.12,
        facecolor="white", edgecolor=C_BORD, linewidth=0.9,
    ))

    return (
        (in_x_internal, in_y_top) if two_inputs else None,
        (in_x_internal, in_y_bot),
        (out_x_internal, oq_cy),
        oq_cx,   # also return the out-queue centre so callouts can target it
        oq_cy,
    )


# --- Box layout ---------------------------------------------------------
# Requester centres set to match Router 1's input-port y so that
# Edge 1A and Edge 1B are strictly horizontal. Box heights are kept
# small enough that a visible gap separates the two Requester boxes.
R1_TMP_CY = 7.0
ReqA = (2.8, R1_TMP_CY + 0.55)   # = 7.55, matches in_y_top of R1
ReqB = (2.8, R1_TMP_CY - 0.55)   # = 6.45, matches in_y_bot of R1
box(*ReqA, 3.0, 0.85, "Requester\nNode A", fs=10)
box(*ReqB, 3.0, 0.85, "Requester\nNode B", fs=10)

R_W, R_H = 3.0, 2.8
R1 = (10.5, 7.0)
R2 = (20.0, 7.0)

r1_in_top, r1_in_bot, r1_out, r1_oq_cx, r1_oq_cy = draw_router(
    *R1, R_W, R_H, two_inputs=True,
)
_, r2_in_bot, r2_out, r2_oq_cx, r2_oq_cy = draw_router(
    *R2, R_W, R_H, two_inputs=False,
)

# Destination Node (same height as Router; wide enough so queue +
# drain + processing-logic all fit on a single horizontal row).
Dst = (28.4, 7.0)
Dst_W, Dst_H = 6.0, R_H   # match the Router height
box(*Dst, Dst_W, Dst_H, "")
ax.text(Dst[0], Dst[1] + Dst_H / 2 - 0.25, "Destination Node",
        ha="center", fontsize=10, weight="bold")


# --- Edges: requester -> router 1 (Edge 1A & 1B, with flits) ------------
draw_wire_with_flits(
    ReqA[0] + 1.6, ReqA[1],
    r1_in_top[0] - 0.02, r1_in_top[1],
    n_flits=4, color=C_A, label_char="A",
)
draw_wire_with_flits(
    ReqB[0] + 1.6, ReqB[1],
    r1_in_bot[0] - 0.02, r1_in_bot[1],
    n_flits=4, color=C_B, label_char="B",
)

# Edge 2: router1 out -> router2 in (horizontal, interleaved)
labels_e2 = [("A", C_A) if i % 2 == 0 else ("B", C_B) for i in range(8)]
E2_y = r1_out[1]
draw_wire_with_flits(
    r1_out[0] + 0.02, r1_out[1],
    r2_in_bot[0] - 0.02, r2_in_bot[1],
    n_flits=8, label_char=labels_e2,
)

# Edge 3: router2 out -> destination (horizontal, interleaved)
labels_e3 = [("A", C_A) if i % 2 == 0 else ("B", C_B) for i in range(4)]
draw_wire_with_flits(
    r2_out[0] + 0.02, r2_out[1],
    Dst[0] - Dst_W / 2, r2_out[1],
    n_flits=4, label_char=labels_e3,
)


# --- Destination internals: queue -> drain -> processing logic ----------
# Light-green halo behind the queue area marks it as a queueing point.
C_QUEUE_BG = "#D9F0E1"

dy_main = Dst[1] - 0.10
Dst_left   = Dst[0] - Dst_W / 2

# Queue (4 flits, FIFO; rightmost is the next to drain, so the
# order is set so the serve sequence after the in-flight A alternates
# A (in drain) -> B -> A -> B -> A -- giving a clean BABA queue.
qW, qH, qGap = 0.42, 0.62, 0.07
q_labels = ["A", "B", "A", "B"]
q_cols   = [C_A,  C_B,  C_A,  C_B]
q_total  = len(q_labels) * qW + (len(q_labels) - 1) * qGap
q_x_start = Dst_left + 0.35

# Green halo behind the queue boxes (the queue itself is a queueing
# location -- mark it with the C_QUEUE colour family)
ax.add_patch(patches.FancyBboxPatch(
    (q_x_start - 0.10, dy_main - qH / 2 - 0.08),
    q_total + 0.20, qH + 0.16,
    boxstyle="round,pad=0.02",
    facecolor=C_QUEUE_BG, edgecolor=C_QUEUE,
    linewidth=0.9, zorder=1.5,
))

for i, (lab, col) in enumerate(zip(q_labels, q_cols)):
    qx = q_x_start + i * (qW + qGap)
    ax.add_patch(patches.Rectangle(
        (qx, dy_main - qH / 2), qW, qH,
        facecolor=col, edgecolor="black", linewidth=0.4,
        zorder=2,
    ))
    ax.text(qx + qW / 2, dy_main, lab,
            ha="center", va="center",
            fontsize=8, color="white", weight="bold", zorder=3)
# Common y for the "queue" / "drain" labels. Matches the FIFO label
# spacing inside the Router (0.18 above the box top) and uses the same
# font size for visual consistency.
DST_LABEL_Y = dy_main + qH / 2 + 0.18
ax.text(q_x_start + q_total / 2, DST_LABEL_Y,
        "queue",
        ha="center", fontsize=7, style="italic", color=C_DIM)

# Drain slot (height matched to queue boxes; font matched to FIFO/queue
# labels for visual consistency)
dr_W, dr_H = 0.85, qH
dr_x = q_x_start + q_total + 0.35
dr_cx = dr_x + dr_W / 2
dr_cy = dy_main
ax.add_patch(patches.FancyBboxPatch(
    (dr_x, dr_cy - dr_H / 2), dr_W, dr_H,
    boxstyle="round,pad=0.03",
    facecolor=C_A, edgecolor="black", linewidth=1.0,
))
ax.text(dr_cx, dr_cy, "A", ha="center", va="center",
        fontsize=9, color="white", weight="bold")
ax.text(dr_cx, DST_LABEL_Y,
        "drain",
        ha="center", fontsize=7, style="italic", color=C_DIM)

# Drain-time double-arrow underneath the drain slot. Move the "drain
# time" text a touch further down so the descender does not collide
# with the arrowhead.
dt_y = dr_cy - dr_H / 2 - 0.28
ax.annotate(
    "", xy=(dr_x + dr_W, dt_y), xytext=(dr_x, dt_y),
    arrowprops=dict(arrowstyle="<->", lw=0.9, color=C_ANN),
)
ax.text(dr_cx, dt_y - 0.42, "drain time",
        ha="center", fontsize=8.5, color=C_ANN, style="italic")

# Processing-logic block (after drain) — wider so the two-line text
# does not collide with the box edges
pl_W, pl_H = 1.75, 1.00
pl_x = dr_x + dr_W + 0.40
pl_cx = pl_x + pl_W / 2
pl_cy = dr_cy
ax.add_patch(patches.FancyBboxPatch(
    (pl_x, pl_cy - pl_H / 2), pl_W, pl_H,
    boxstyle="round,pad=0.03",
    facecolor=C_PROC, edgecolor=C_BORD, linewidth=1.0,
))
ax.text(pl_cx, pl_cy, "processing\nlogic",
        ha="center", va="center", fontsize=9)

# Small gray arrows along the queue -> drain -> processing chain
ax.annotate(
    "", xy=(dr_x - 0.04, dr_cy),
    xytext=(q_x_start + q_total + 0.13, dr_cy),
    arrowprops=dict(arrowstyle="-|>", lw=0.7,
                    color=C_DIM, mutation_scale=7),
)
ax.annotate(
    "", xy=(pl_x - 0.04, dr_cy),
    xytext=(dr_x + dr_W + 0.04, dr_cy),
    arrowprops=dict(arrowstyle="-|>", lw=0.7,
                    color=C_DIM, mutation_scale=7),
)


# --- Annotations (strictly vertical, no diagonals, no overlap) ---------

def vcallout(text, xy, x_text_top_y, fs=10, clearance=0.55,
             marker_color=None):
    """Vertical dotted callout. Optional `marker_color` paints a small
    coloured circle just left of the text -- used to tie the label to
    a colour code in the diagram.
    """
    text_y = x_text_top_y
    if text_y < xy[1]:
        line_top_y = text_y + clearance
    else:
        line_top_y = text_y - clearance
    ax.plot([xy[0], xy[0]], [xy[1], line_top_y],
            color=C_ANN, lw=0.9, linestyle=":", zorder=2)
    if marker_color is not None:
        # Text-width estimate at fs=10 (~0.18 data units per char) so
        # the marker is placed clearly to the left of the text.
        text_w = len(text) * 0.18
        marker_x = xy[0] - text_w / 2 - 0.30
        ax.add_patch(patches.Circle(
            (marker_x, text_y), 0.14,
            facecolor=marker_color, edgecolor=C_BORD, linewidth=0.6,
            zorder=3,
        ))
    ax.text(xy[0], text_y, text,
            ha="center", va="center",
            fontsize=fs, color=C_ANN, zorder=3)


# Transmission delay -> flit on Edge 2 (text BELOW the wire).
# Extra clearance ~ 2 x text height so the line stops well clear of
# the label.
e2_total = 8 * 0.66 + 7 * 0.10
e2_centre = (r1_out[0] + r2_in_bot[0]) / 2
e2_start = e2_centre - e2_total / 2
trans_idx = 5
trans_cx  = e2_start + (trans_idx + 0.5) * (0.66 + 0.10)
vcallout(
    "transmission delay  =  flit_size / BW",
    xy=(trans_cx, E2_y - 0.55),
    x_text_top_y=E2_y - 2.6,
    clearance=0.45,
)

# Flit-level interleaving -> ABOVE the wire, anchored on a flit near
# the centre/right of the wire. Text y matches the queuing-delay
# callout so the two labels sit on the same horizontal row, separated
# horizontally so they don't collide.
int_idx = 4
int_cx  = e2_start + (int_idx + 0.5) * (0.66 + 0.10)
vcallout(
    "flit-level interleaving on wires",
    xy=(int_cx, E2_y + 0.55),
    x_text_top_y=R1[1] + R_H / 2 + 0.85,
)

# Queuing delay -> at Router 1 out-queue. Text positioned just above
# the Router-1 box.
vcallout(
    "queuing delay",
    xy=(r1_oq_cx, r1_oq_cy + 0.30),
    x_text_top_y=R1[1] + R_H / 2 + 0.85,
    marker_color=C_QUEUE,
)

# Drain -> below the drain slot (text is below).
# Extra clearance ~ 2.5 x text height so the line stops further from
# the label.
vcallout(
    "drain  =  per-flit service occupancy",
    xy=(dr_cx, dt_y - 0.66),   # just *inside* the Destination Node
                                  # bottom edge -- the dotted line then
                                  # penetrates the box slightly rather
                                  # than hanging below it
    x_text_top_y=E2_y - 2.6,
    clearance=0.60,
)

# Per-node overhead -> points at Router 1's switch block (where the
# component's fixed processing cost lives). Text on the same row as
# transmission delay and drain so the three "below the wire" callouts
# sit on one horizontal baseline.
R1_SW_CX = R1[0] - 0.55         # sw_cx for Router 1
R1_SW_BOTTOM_Y = R1[1] - 0.55   # bottom of sw box
vcallout(
    "per-node overhead",
    xy=(R1_SW_CX, R1_SW_BOTTOM_Y),
    x_text_top_y=E2_y - 2.6,
    clearance=0.60,
    marker_color=C_PROC,
)


# --- Legend (close to the diagram) --------------------------------------
LX, LY = 1.0, 2.6
ax.add_patch(patches.Rectangle((LX, LY), 0.7, 0.45,
                                facecolor=C_A, edgecolor="black",
                                linewidth=0.4))
ax.text(LX + 0.95, LY + 0.22, "Transaction A flit",
        va="center", fontsize=10)

ax.add_patch(patches.Rectangle((LX + 4.8, LY), 0.7, 0.45,
                                facecolor=C_B, edgecolor="black",
                                linewidth=0.4))
ax.text(LX + 5.75, LY + 0.22, "Transaction B flit",
        va="center", fontsize=10)


# --- Save ----------------------------------------------------------------
fig.savefig(OUT, dpi=140, bbox_inches="tight", facecolor="white")
print(f"Wrote {OUT}")
