// E2E_Report.typ — Streaming-chain E2E report.
//
// Renders report_data.json (produced by report_build.py) into a single PDF:
// headline KPIs, per-field progression/regression vs the previous run, unit-test
// suites, code coverage, performance (CPU/peak-RSS/throughput), per-scenario
// waveform fidelity, trend plots, and embedded per-scenario waveform images.
//
// Compile from the directory holding report_data.json + the *.png artifacts:
//   typst compile E2E_Report.typ E2E_Report.pdf
// Missing inputs degrade to "n/a" / skipped sections so a partial run still
// renders.

#let data = json("report_data.json")
#let meta = data.meta
#let e2e = data.e2e
#let ut = data.unit_tests
#let cov = data.coverage
#let reg = data.regression
#let hl = data.headline

// ── helpers ──────────────────────────────────────────────────────────────────
#let fmt(v, suffix: "") = if v == none { "n/a" } else { str(v) + suffix }
#let fnum(v, digits: 3, suffix: "") = {
  if v == none { "n/a" } else {
    [#calc.round(v, digits: digits)] + suffix
  }
}
#let ok_color = rgb("#1a7f37")
#let bad_color = rgb("#cf222e")
#let neutral = rgb("#57606a")
#let fail_bg = rgb("#ffebe9")  // light-red row background for FAIL rows in scenario tables
#let fail_row_fill(is_fail) = (x, y) => if y > 0 and is_fail(y - 1) { fail_bg } else { none }

#let warn_color = rgb("#9a6700")   // XFAIL — expected/known failure
#let xpass_color = rgb("#8250df")  // XPASS — stale marker, needs attention
#let status_badge(s) = {
  let c = if s == "PASS" { ok_color }
    else if s == "FAIL" { bad_color }
    else if s == "XFAIL" { warn_color }
    else if s == "XPASS" { xpass_color }
    else { neutral }
  box(fill: c, inset: (x: 6pt, y: 2pt), radius: 3pt, text(fill: white, weight: "bold", size: 8pt)[#s])
}

#let yesno(b) = {
  if b == true { text(fill: ok_color)[✓] }
  else if b == false { text(fill: bad_color)[✗] }
  else { text(fill: neutral)[—] }
}

// progression arrow for a regression row
#let trend_arrow(r) = {
  if r.delta == none or r.delta == 0 { text(fill: neutral)[—] }
  else {
    let up = r.delta > 0
    let good = r.better == true
    let c = if good { ok_color } else { bad_color }
    let arrow = if up { "▲" } else { "▼" }
    text(fill: c)[#arrow #calc.round(r.delta, digits: 4)]
  }
}

// ── page setup ───────────────────────────────────────────────────────────────
#set page(
  paper: "a4", margin: (x: 1.8cm, y: 1.8cm),
  header: align(right, text(size: 8pt, fill: neutral)[Streaming-chain E2E · #meta.git_sha]),
  footer: context align(center, text(size: 8pt, fill: neutral)[#counter(page).display("1 / 1", both: true)]),
)
#set text(font: "DejaVu Sans", size: 9.5pt)
#set heading(numbering: "1.1")
#show heading.where(level: 1): it => block(above: 14pt, below: 8pt, text(size: 14pt, weight: "bold", it))
#show heading.where(level: 2): it => block(above: 10pt, below: 6pt, text(size: 11pt, weight: "bold", it))

// ── title ────────────────────────────────────────────────────────────────────
#align(center)[
  #text(size: 20pt, weight: "bold")[MARTe2 Streaming-Chain E2E Report] \
  #v(2pt)
  #text(size: 10pt, fill: neutral)[
    #meta.timestamp · commit #raw(meta.git_sha) · target #meta.target ·
    run \##data.history_len
  ]
]

#v(6pt)
#align(center, status_badge(e2e.overall) + h(8pt) + text(size: 11pt)[
  E2E #hl.e2e_pass/#hl.e2e_total · Units #hl.unit_pass/#hl.unit_total
  #if hl.at("e2e_xfail", default: 0) > 0 [ · #hl.e2e_xfail xfail]
  #if hl.at("e2e_xpass", default: 0) > 0 [ · #text(fill: xpass_color)[#hl.e2e_xpass xpass!]]
])

// ── headline KPIs ────────────────────────────────────────────────────────────
#v(10pt)
#let kpi(label, value) = box(width: 100%, inset: 8pt, radius: 4pt, fill: rgb("#f6f8fa"))[
  #align(center)[
    #text(size: 14pt, weight: "bold")[#value] \
    #text(size: 8pt, fill: neutral)[#label]
  ]
]
#grid(columns: 4, gutter: 6pt,
  kpi("E2E passed", [#hl.e2e_pass/#hl.e2e_total]),
  kpi("Unit passed", [#hl.unit_pass/#hl.unit_total]),
  kpi("Mean sine corr", fnum(hl.mean_corr, digits: 4)),
  kpi("Throughput", fnum(hl.mean_throughput_sps, digits: 0, suffix: " sp/s")),
  kpi("Python cov", fmt(hl.cov_python, suffix: "%")),
  kpi("Go cov", fmt(hl.cov_go, suffix: "%")),
  kpi("Mean peak RSS", fnum(hl.mean_peak_rss_mb, digits: 1, suffix: " MB")),
  kpi("Mean CPU", fnum(hl.mean_cpu_s, digits: 2, suffix: " s")),
)

// ── progression / regression ─────────────────────────────────────────────────
= Progression / regression
#if data.is_first_run [
  _First recorded run — baseline established; no previous run to compare against._
] else [
  Comparison against the previous run (#text(fill: ok_color)[▲] better,
  #text(fill: bad_color)[▼] worse, — unchanged/unavailable).
  #v(4pt)
  #table(
    columns: (1.6fr, 1fr, 1fr, 1.2fr),
    align: (left, right, right, right),
    stroke: 0.4pt + rgb("#d0d7de"),
    inset: 5pt,
    table.header([*Metric*], [*Current*], [*Previous*], [*Δ (trend)*]),
    ..reg.map(r => (
      [#r.name],
      fnum(r.current, digits: 4),
      fnum(r.previous, digits: 4),
      trend_arrow(r),
    )).flatten()
  )
]

// ── unit tests ───────────────────────────────────────────────────────────────
= Unit tests
#table(
  columns: (2fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr),
  align: (left, center, right, right, right, right, right),
  stroke: 0.4pt + rgb("#d0d7de"),
  inset: 5pt,
  table.header([*Suite*], [*Status*], [*Total*], [*Pass*], [*Fail*], [*Skip*], [*Time (s)*]),
  ..ut.suites.map(s => (
    [#s.name],
    if not s.avail { status_badge("SKIP") } else if s.ok { status_badge("PASS") } else { status_badge("FAIL") },
    [#s.total], [#s.passed], [#s.failed], [#s.skipped], fnum(s.time_s, digits: 2),
  )).flatten(),
  table.cell(colspan: 2)[*Totals*],
  [#ut.totals.total], [#ut.totals.passed], [#ut.totals.failed], [#ut.totals.skipped], [],
)

// ── coverage ─────────────────────────────────────────────────────────────────
= Code coverage
#table(
  columns: (1fr, 1fr, 2fr),
  align: (left, right, left),
  stroke: 0.4pt + rgb("#d0d7de"),
  inset: 5pt,
  table.header([*Language*], [*Coverage*], [*Source*]),
  ..cov.languages.map(l => (
    [#l.name],
    if l.pct == none { text(fill: neutral)[n/a] } else { [#l.pct%] },
    text(size: 8pt, fill: neutral)[#l.note],
  )).flatten()
)

// per-file C++ detail — the streaming chain's most critical code, so the report
// shows line coverage for every project C++ source (worst-covered first).
#let cpp = cov.languages.find(l => l.name == "C++")
#if cpp != none and cpp.at("files", default: ()).len() > 0 [
  == C++ source detail
  #let cf = cpp.files
  #text(size: 8pt, fill: neutral)[
    #cpp.at("lines_hit", default: 0) of #cpp.at("lines_found", default: 0)
    lines covered across #cf.len() project files (worst-covered first;
    green ≥75%, amber 50–75%, red under 50%).
  ]
  #v(4pt)
  #let covcell(p) = {
    let c = if p == none { neutral }
      else if p >= 75 { ok_color }
      else if p >= 50 { warn_color }
      else { bad_color }
    text(fill: c, weight: "bold")[#if p == none { "n/a" } else [#p%]]
  }
  #table(
    columns: (3.2fr, 1fr, 1fr, 1.1fr),
    align: (left, right, right, right),
    stroke: 0.4pt + rgb("#d0d7de"),
    inset: 4pt,
    table.header([*Source file*], [*Lines*], [*Hit*], [*Coverage*]),
    ..cf.map(f => (
      text(size: 8pt)[#f.path],
      [#f.lines_found],
      [#f.lines_hit],
      covcell(f.pct),
    )).flatten()
  )
]

// ── performance ──────────────────────────────────────────────────────────────
= Performance
Per-scenario CPU time and peak resident memory (VmHWM) of the StreamHub and
MARTe2 processes, plus sustained client throughput (recorded samples ÷ duration).
#v(4pt)
#table(
  columns: (1.8fr, 1fr, 1fr, 1fr, 1fr, 1.1fr),
  align: (left, right, right, right, right, right),
  stroke: 0.4pt + rgb("#d0d7de"),
  inset: 5pt,
  fill: fail_row_fill(i => e2e.scenarios.at(i).status == "FAIL"),
  table.header([*Scenario*], [*Hub CPU (s)*], [*Hub RSS (MB)*],
               [*MARTe CPU (s)*], [*MARTe RSS (MB)*], [*Throughput (sp/s)*]),
  ..e2e.scenarios.map(sc => {
    let h = sc.perf.at("hub", default: (:))
    let m = sc.perf.at("marte", default: (:))
    (
      [#sc.id],
      fnum(h.at("cpu_s", default: none), digits: 2),
      fnum(h.at("peak_rss_mb", default: none), digits: 1),
      fnum(m.at("cpu_s", default: none), digits: 2),
      fnum(m.at("peak_rss_mb", default: none), digits: 1),
      fnum(sc.throughput_sps, digits: 0),
    )
  }).flatten()
)

// ── per-scenario waveform fidelity ───────────────────────────────────────────
= Scenarios
#for sc in e2e.scenarios [
  == #sc.id #h(6pt) #status_badge(sc.status)
  #if sc.at("desc", default: none) != none [
    #v(1pt)
    #text(size: 9pt, fill: neutral, style: "italic")[#sc.desc]
    #v(2pt)
  ]
  #if sc.at("known_issue", default: none) != none [
    #v(2pt)
    #box(fill: rgb("#fff8c5"), inset: 5pt, radius: 3pt, width: 100%,
      text(size: 8pt, fill: rgb("#7d4e00"))[*Known issue:* #sc.known_issue])
  ]
  #let rb = sc.rollup
  Client checks:
  live #yesno(rb.at("live_ok", default: none)) ·
  zoom #yesno(rb.at("zoom_ok", default: none)) ·
  window #yesno(rb.at("window_ok", default: none)) ·
  trigger #yesno(rb.at("trigger_ok", default: none))
  #if sc.live_frames != none [ · #sc.live_frames live frames]
  #v(3pt)
  #table(
    columns: (1.6fr, 0.6fr, 0.8fr, 0.8fr, 1fr, 0.9fr, 0.9fr, 0.8fr, 0.8fr),
    align: (left, center, left, left, right, right, right, center, center),
    stroke: 0.4pt + rgb("#d0d7de"),
    inset: 4pt,
    fill: fail_row_fill(i => sc.signals.at(i).pass == false),
    table.header([*Signal*], [*Pass*], [*Type*], [*Quant*], [*Max abs err*],
                 [*Corr*], [*nRMSE*], [*Fidelity*], [*Shape*]),
    ..sc.signals.map(g => (
      raw(g.key),
      yesno(g.pass),
      text(size: 8pt)[#g.type],
      text(size: 8pt)[#g.quant],
      fnum(g.max_abs_err, digits: 6),
      fnum(g.corr, digits: 4),
      fnum(g.nrmse, digits: 4),
      yesno(g.fidelity_ok),
      yesno(g.shape_ok),
    )).flatten()
  )
  // zoom / window / trigger behavioural detail (real WS-driven results)
  #let zooms = sc.at("zoom", default: ())
  #let win = sc.at("window", default: (:))
  #let trigs = sc.at("trigger", default: ())
  #if zooms.len() > 0 or win.len() > 0 [
    #v(3pt)
    #text(size: 8.5pt, weight: "bold")[Zoom / window queries]
    #table(
      columns: (1fr, 1.2fr, 1fr, 0.8fr, 0.8fr),
      align: (left, left, right, right, center),
      stroke: 0.4pt + rgb("#d0d7de"),
      inset: 4pt,
      table.header([*Query*], [*Signal*], [*Span (s)*], [*Pts*], [*OK*]),
      ..zooms.enumerate().map(((i, z)) => {
        let r = z.at("range", default: (0, 0))
        (
          [zoom #str(i + 1)],
          raw(z.at("key", default: "")),
          fnum(r.at(1) - r.at(0), digits: 4),
          [#z.at("returned", default: 0)],
          yesno(z.at("inrange", default: none)),
        )
      }).flatten(),
      ..(if win.len() > 0 and win.at("returned", default: 0) > 0 {(
        [window #fnum(win.at("windowSec", default: 0), digits: 2)],
        raw(win.at("key", default: "")),
        fnum(win.at("span", default: 0), digits: 4),
        [#win.at("returned", default: 0)],
        yesno(win.at("ok", default: none)),
      )} else {()}),
    )
  ]
  #if trigs.len() > 0 [
    #v(3pt)
    #text(size: 8.5pt, weight: "bold")[Trigger captures] #h(4pt)
    #text(size: 8pt, fill: neutral)[(signal #raw(trigs.at(0).at("key", default: "")))]
    #table(
      columns: (1fr, 1fr, 0.8fr, 1fr, 1fr, 0.8fr, 0.8fr),
      align: (left, left, center, right, right, center, center),
      stroke: 0.4pt + rgb("#d0d7de"),
      inset: 4pt,
      table.header([*Edge*], [*Mode*], [*Fired*], [*Pre/Post (s)*],
                   [*Capture pts*], [*Win OK*], [*Edge OK*]),
      ..trigs.map(t => (
        text(size: 8pt)[#t.at("edge", default: "")],
        text(size: 8pt)[#t.at("mode", default: "")],
        yesno(t.at("fired", default: none)),
        text(size: 8pt)[#fnum(t.at("preSec", default: 0), digits: 3) / #fnum(t.at("postSec", default: 0), digits: 3)],
        [#t.at("capturePts", default: 0)],
        yesno(t.at("windowOk", default: none)),
        yesno(t.at("edgeOk", default: none)),
      )).flatten()
    )
  ]
  // embed the waveform overview if report_build.py found one in the artifacts
  #if sc.at("wave_img", default: none) != none [
    #v(3pt)
    #align(center, image(sc.wave_img, width: 92%))
  ]
  #v(6pt)
]

// ── per-kind sections (direct/recorder/debug/tcplogger) ─────────────────────
// Raw scenario records here come straight from results.json (not e2e.scenarios'
// reshaping), so they only carry id/kind/status/known_issue/metrics — no
// waveform-fidelity breakdown (already covered, where applicable, in the
// Scenarios section above for chain-kind scenarios; these kinds run through
// dedicated non-chain harnesses in run_e2e.sh).
#let kind_table(title, block) = {
  [= #title]
  [#block.n_pass/#block.n_total passed.]
  v(4pt)
  if block.n_total == 0 {
    text(fill: neutral)[_No scenarios of this kind in this run._]
  } else {
    table(
      columns: (1.4fr, 0.8fr, 2fr),
      align: (left, center, left),
      stroke: 0.4pt + rgb("#d0d7de"),
      inset: 5pt,
      fill: fail_row_fill(i => block.scenarios.at(i).status == "FAIL"),
      table.header([*Scenario*], [*Status*], [*Known issue*]),
      ..block.scenarios.map(s => (
        [#s.id],
        status_badge(s.status),
        if s.at("known_issue", default: none) != none {
          text(size: 8pt, fill: rgb("#7d4e00"))[#s.known_issue]
        } else { text(fill: neutral)[—] },
      )).flatten()
    )
  }
}

#kind_table("Direct Round-Trip (UDPStreamer ↔ UDPStreamerClient)", data.direct)
#kind_table("Recorder (BinaryRecorder disk output)", data.recorder)
#kind_table("Debug Service E2E", data.debug)
#kind_table("TCPLogger E2E", data.tcplogger)
#kind_table("Debug Service PAUSE/RESUME E2E", data.debug_pause_resume)

// ── stress tests ─────────────────────────────────────────────────────────────
= Stress Tests
#if data.stress == none [
  _Not run this session._
] else [
  #align(center, status_badge(data.stress.overall) + h(8pt) + text(size: 11pt)[
    #hl.at("stress_pass", default: 0) passed ·
    #hl.at("stress_fail", default: 0) failed
  ])
  #v(4pt)
  #for (axis, cases) in data.stress.by_axis [
    == Axis: #raw(axis)
    #table(
      columns: (0.8fr, 0.8fr, 1fr, 1fr, 1fr, 1fr),
      align: (right, center, right, right, right, right),
      stroke: 0.4pt + rgb("#d0d7de"),
      inset: 4pt,
      table.header([*Level*], [*Status*], [*Hub RSS (MB)*], [*MARTe RSS (MB)*],
                   [*Zoom p50 (ms)*], [*Zoom p95 (ms)*]),
      ..cases.map(c => (
        [#c.at("level", default: "n/a")],
        status_badge(c.at("status", default: "?")),
        fnum(c.at("hub_rss_mb", default: none), digits: 1),
        fnum(c.at("marte_rss_mb", default: none), digits: 1),
        fnum(c.at("zoom_p50_ms", default: none), digits: 1),
        fnum(c.at("zoom_p95_ms", default: none), digits: 1),
      )).flatten()
    )
  ]
  #if data.stress_plots.len() > 0 [
    #v(4pt)
    == Scaling curves
    #grid(columns: 2, gutter: 8pt,
      ..data.stress_plots.map(p => image(p, width: 100%))
    )
  ]
]

// ── trend plots ──────────────────────────────────────────────────────────────
#if data.trend_plots.len() > 0 [
  = Trends over runs
  #grid(columns: 2, gutter: 8pt,
    ..data.trend_plots.map(p => image(p, width: 100%))
  )
]
