"""Draw the four figures of lesson 31 from tiny_server.py, so that no number in a figure is typed by hand.

    python3 make_figs.py          rewrite ../figs/*.svg
    python3 make_figs.py --check  exit 1 if a figure on disk differs (test_tiny_server.py runs the same check)

All four figures are 600 units wide. Text is at least 22 units (20 for axis ticks) in ink or grey on white or a light fill; orange and green are used on marks
only, and every mark that colour tells apart is also named in words.
"""
import sys
from pathlib import Path
from xml.sax.saxutils import escape

import tiny_server as ts
from show import WALL_MOVES, WALLS, ms_text, seconds_text

FIGS = Path(__file__).resolve().parent.parent / "figs"
INK, GREY, ORANGE, GREEN, FILL, PANEL = "#0a1626", "#44566b", "#b36200", "#00855F", "#d2dae4", "#ebf0f6"
DASH = "10 8"
WINDOW = 3.0                                                                   # seconds of the heavy run that figure 2 shows


def num(x: float) -> str:
    return f"{x:.2f}".rstrip("0").rstrip(".")


# The SVG namespace is a name that every standalone .svg file must carry, not an address this program visits. It is written in two pieces so that a plain
# search of code/ for the start of a web address finds nothing (test_tiny_server.py runs that search).
NAMESPACE = "ht" + "tp://www.w3.org/2000/svg"


def frame(width: int, height: int, title: str, desc: str, body: list[str], defs: str = "") -> str:
    head = (f'<svg xmlns="{NAMESPACE}" viewBox="0 0 {width} {height}" width="{width}" height="{height}" role="img" '
            f'font-family="system-ui, sans-serif" font-size="22" fill="{GREY}">')
    return "\n".join([head, f"  <title>{escape(title)}</title>", f"  <desc>{escape(desc)}</desc>", *([f"  {defs}"] if defs else []),
                      '  <rect width="100%" height="100%" fill="#ffffff"/>', *("  " + line for line in body), "</svg>", ""])


def text(x: float, y: float, content: str, anchor: str = "start", color: str = INK, bold: bool = False, size: int = 22) -> str:
    extra = (f' text-anchor="{anchor}"' if anchor != "start" else "") + (' font-weight="bold"' if bold else "") + (f' font-size="{size}"' if size != 22 else "")
    return f'<text x="{num(x)}" y="{num(y)}"{extra} fill="{color}">{escape(content)}</text>'


ARROW = (f'<defs><marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
         f'<path d="M0 0L10 5L0 10z" fill="{INK}"/></marker></defs>')


def arrow(x1: float, y1: float, x2: float, y2: float, both: bool = False) -> str:
    start = ' marker-start="url(#head)"' if both else ""
    return f'<path d="M{num(x1)} {num(y1)}L{num(x2)} {num(y2)}" stroke="{INK}" stroke-width="3"{start} marker-end="url(#head)"/>'


def box(x: float, y: float, w: float, h: float, badge: int, lines: list[str]) -> list[str]:
    out = [f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{PANEL}" stroke="{INK}" stroke-width="2"/>',
           f'<circle cx="{x + 26}" cy="{y + 28}" r="16" fill="{INK}"/>', text(x + 26, y + 36, str(badge), "middle", "#ffffff", True)]
    out.append(text(x + 52, y + 36, lines[0], bold=True))
    out.extend(text(x + 16, y + 36 + 30 * k, line, color=GREY) for k, line in enumerate(lines[1:], 1))
    return out


# --- figure 1: the server as a kitchen -----------------------------------------------------------------------------------------

def fig_kitchen() -> str:
    body = box(20, 16, 560, 82, 6, [f"load generator: {ts.HEAVY} chats a second", "the dining room sends the orders"])
    body.append(arrow(135, 98, 135, 142))
    body += box(20, 146, 230, 136, 1, ["Queue", "the order rail", "chats wait here", "queue"])
    body += box(350, 146, 230, 136, 3, ["Batcher", "the cook", f"{ts.LIMIT} chats at most", "serve()"])
    body += box(350, 346, 230, 136, 4, ["Cache", "the shelf", f"{ts.PAGES} pages", "Pages"])
    body += box(20, 346, 230, 136, 5, ["Metrics", "the clipboard", "p50, p99, queue", "report()"])
    body.append(arrow(250, 214, 350, 214))
    body.append(text(300, 196, "2 admit", "middle"))
    body.append(arrow(465, 282, 465, 346, both=True))
    body.append(text(449, 322, "pages", "end"))
    body.append(arrow(372, 282, 250, 360))
    desc = (f"A map of the server as a kitchen, six numbered parts. The load generator (6), the dining room, sends {ts.HEAVY} chats a second into the queue, the order rail (1). The batcher, "
            f"the cook (3), admits a chat when a place and pages are free (2) and steps up to {ts.LIMIT} chats at once. The cache, the shelf (4), holds {ts.PAGES} pages of "
            f"{ts.PAGE_TOKENS} tokens, taken and returned by the batcher. The metrics, the clipboard (5), record each step.")
    return frame(600, 498, "A tiny model server as a kitchen: queue, batcher, cache and metrics", desc, body, ARROW)


# --- figure 2: the cache fills ------------------------------------------------------------------------------------------------

def fig_cache_fills() -> str:
    run = ts.serve(ts.WORKLOAD, ts.HEAVY)
    steps = [s for s in run.steps if s.start <= WINDOW]
    full = next(s.start for s in run.steps if "cache" in s.blocked)
    x0, x1 = 84, 580
    px = lambda t: x0 + t / WINDOW * (x1 - x0)
    top, base = 84, 270
    py = lambda pages: base - pages / ts.PAGES * (base - top)
    qtop, qbase, qmax = 356, 506, 50
    qy = lambda chats: qbase - chats / qmax * (qbase - qtop)
    body = [text(20, 40, "cache pages promised", color=GREY)]
    for tick in (0, ts.PAGES // 2, ts.PAGES):
        body.append(f'<path d="M{x0} {num(py(tick))}H{x1}" stroke="{FILL}" stroke-width="2"/>')
        body.append(text(x0 - 8, py(tick) + 7, str(tick), "end", GREY, size=20))
    body.append(f'<path d="M{x0} {num(py(ts.PAGES))}H{x1}" stroke="{ORANGE}" stroke-width="4" stroke-dasharray="{DASH}"/>')
    body.append(text(x0 + 8, py(ts.PAGES) - 12, f"{ts.PAGES} pages: the cache is full", bold=True))
    points = " L".join(f"{num(px(s.start))} {num(py(s.held + s.owed))}" for s in steps)
    body.append(f'<path d="M{points}" fill="none" stroke="{INK}" stroke-width="3"/>')
    body.append(f'<path d="M{num(px(full))} {base}V{top}" stroke="{GREEN}" stroke-width="4"/>')
    body.append(text(px(full) + 12, base - 30, f"cache full at {seconds_text(full)} s", bold=True))
    body.append(text(20, 326, "chats waiting in the queue", color=GREY))
    for tick in (0, qmax // 2, qmax):
        body.append(f'<path d="M{x0} {num(qy(tick))}H{x1}" stroke="{FILL}" stroke-width="2"/>')
        body.append(text(x0 - 8, qy(tick) + 7, str(tick), "end", GREY, size=20))
    points = " L".join(f"{num(px(s.start))} {num(qy(s.queue))}" for s in steps)
    body.append(f'<path d="M{points}" fill="none" stroke="{INK}" stroke-width="3"/>')
    body.append(f'<path d="M{num(px(full))} {qbase}V{qtop}" stroke="{GREEN}" stroke-width="4"/>')
    last = steps[-1]
    body.append(text(x1 - 8, qy(last.queue) - 14, f"{last.queue} waiting at {WINDOW:g} s", "end", bold=True))
    for base_y in (base, qbase):
        body.append(f'<path d="M{x0} {base_y}H{x1}" stroke="{GREY}" stroke-width="2"/>')
    for tick in range(int(WINDOW) + 1):
        body.append(text(px(tick), qbase + 30, str(tick), "middle", GREY, size=20))
    body.append(text((x0 + x1) / 2, qbase + 62, "seconds since the first chat", "middle", GREY))
    desc = (f"The first {WINDOW:g} seconds of the heavy run, {ts.HEAVY} chats a second. Top: cache pages promised (held plus owed) climb to the {ts.PAGES}-page limit and stay at it from "
            f"{seconds_text(full)} s, marked by a green line. Bottom: the queue is empty until then and has {last.queue} chats waiting at {WINDOW:g} s.")
    return frame(600, 576, f"The cache is full at {seconds_text(full)} s and the queue grows", desc, body)


# --- figure 3: the first word against the words between ------------------------------------------------------------------------

def fig_two_waits() -> str:
    light, heavy = ts.load_test(ts.LIGHT), ts.load_test(ts.HEAVY)
    x0 = 20
    panels = (("p99 wait for the first word", "ttft_p99", 3.5, 480, lambda v: f"{seconds_text(v)} s"),
              ("p99 time between words", "tbt_p99", 0.035, 480, lambda v: f"{ms_text(v)} ms"))
    body, y, facts = [], 36, []
    for title, key, top_value, longest, show in panels:
        body.append(text(20, y, title, bold=True))
        for k, (name, m, color) in enumerate(((f"{ts.LIGHT} chats a second", light, GREEN), (f"{ts.HEAVY} chats a second", heavy, ORANGE))):
            top = y + 40 + k * 84
            width = max(m[key] / top_value * longest, 3)
            body.append(text(20, top - 8, name, color=GREY))
            body.append(f'<rect x="{x0}" y="{top}" width="{num(width)}" height="34" fill="{PANEL}" stroke="{color}" stroke-width="4"/>')
            body.append(text(x0 + width + 12, top + 25, show(m[key]), bold=True))
        facts.append(f"{title}: {show(light[key])} at {ts.LIGHT} chats a second, {show(heavy[key])} at {ts.HEAVY}")
        y += 214
    desc = ("Two bar charts, each on its own scale, for the same toy server at a light and a heavy rate. " + "; ".join(facts) +
            f". At {ts.PAGES} pages and {ts.LIMIT} chats, the wait for the first word grows about {round(heavy['ttft_p99'] / light['ttft_p99'], -1):.0f} times; the time between words, every gap of every chat pooled, grows by about half.")
    return frame(600, y - 28, "At the heavy rate the first word waits far longer and the gaps between words grow by half", desc, body)


# --- figure 4: the wall moves ------------------------------------------------------------------------------------------------

def fig_walls() -> str:
    rows = [(pages, limit, ts.load_test(ts.HEAVY, pages, limit)) for pages, limit in WALL_MOVES]
    longest, top_value, x0 = 290, 3.5, 20
    body, y, facts = [], 36, []
    for pages, limit, m in rows:
        wall = WALLS[ts.bottleneck(m)]
        body.append(text(20, y, f"{pages} pages, up to {limit} chats", bold=True))
        width = max(m["ttft_p99"] / top_value * longest, 3)
        body.append(f'<rect x="{x0}" y="{y + 16}" width="{num(width)}" height="60" fill="{PANEL}" stroke="{INK}" stroke-width="2"/>')
        body.append(text(x0 + width + 14, y + 44, f"{seconds_text(m['ttft_p99'])} s", bold=True))
        body.append(text(x0 + width + 14, y + 72, f"wall: {wall}"))
        facts.append(f"{pages} pages and {limit} chats: {seconds_text(m['ttft_p99'])} s, wall {wall}")
        y += 118
    desc = (f"The p99 wait for the first word at {ts.HEAVY} chats a second, for four settings of cache pages and batch limit, on one scale. " + "; ".join(facts) +
            ". More cache moves the wall from the cache to the batch limit; more cache again changes nothing.")
    return frame(600, y - 8, "Changing one setting moves the wall", desc, body)


FIGURES = {
    "kitchen.svg": fig_kitchen,
    "cache-fills.svg": fig_cache_fills,
    "two-waits.svg": fig_two_waits,
    "wall-moves.svg": fig_walls,
}


def main() -> int:
    stale = []
    for name, draw in FIGURES.items():
        path, svg = FIGS / name, draw()
        if "--check" in sys.argv:
            if not path.exists() or path.read_text(encoding="utf-8") != svg:
                stale.append(name)
        else:
            FIGS.mkdir(exist_ok=True)
            path.write_text(svg, encoding="utf-8")
    if "--check" in sys.argv:
        print("figures differ from tiny_server.py: " + ", ".join(stale) if stale else "figures match tiny_server.py")
        return 1 if stale else 0
    print("wrote " + ", ".join(FIGURES))
    return 0


if __name__ == "__main__":
    sys.exit(main())
