<script lang="ts">
  /**
   * The ride screen, route-led.
   *
   * The hierarchy is deliberate and is the whole design: the *route* is the
   * hero, then the numbers that answer "how much longer" — ETA, distance
   * remaining, speed, gradient, elevation. Power, cadence and heart rate are
   * real but subordinate; they live in a single quiet strip.
   */
  import { app } from '../lib/app.svelte';
  import { api } from '../lib/bridge';
  import {
    clock,
    duration,
    finishAt,
    km,
    MODE_LABEL,
    num,
    signed,
    targetText,
  } from '../lib/format';
  import { viewport } from '../lib/viewport.svelte';
  import ControlBar from './ControlBar.svelte';
  import Readout from './Readout.svelte';
  import RouteChart from './RouteChart.svelte';
  import StreamChart from './StreamChart.svelte';

  /**
   * What this screen can afford to show, measured rather than assumed. See
   * ui/src/lib/viewport.ts for the reasoning behind each drop — the short
   * version is that a phone gets fewer numbers, not smaller ones.
   */
  const show = $derived(viewport.plan.show);
  const compact = $derived(viewport.plan.sizeClass === 'compact');

  const frame = $derived(app.frame);
  const snap = $derived(frame?.snapshot ?? null);
  const d = $derived(frame?.derived ?? null);
  const ride = $derived(app.ride);
  const profile = $derived(ride?.profile ?? null);

  /**
   * The trainer chip (FR-1.8, FR-9.3). The rider must never be left reading
   * zeros without being told why, so every non-controlling state gets words.
   */
  const trainerChip = $derived.by(() => {
    const t = ride?.trainer;
    if (!t) return null;
    const state = t.state;
    if (typeof state === 'object' && 'Lost' in state) {
      return { tone: 'tone-bad', label: `Trainer unavailable — ${state.Lost.reason}` };
    }
    switch (state) {
      case 'Idle':
        return { tone: 'tone-warn', label: 'No trainer — open Devices to connect' };
      case 'Scanning':
        return { tone: 'tone-warn', label: 'Scanning for the trainer…' };
      case 'Connecting':
        return { tone: 'tone-warn', label: 'Connecting to the trainer…' };
      case 'Reconnecting':
        return { tone: 'tone-warn', label: 'Trainer lost — reconnecting' };
      case 'Connected':
        return { tone: 'tone-warn', label: 'Connected, control not acquired' };
      case 'Controlling':
        if (t.stale) return { tone: 'tone-warn', label: 'Trainer silent — pedal to wake it' };
        if (t.error) return { tone: 'tone-bad', label: t.error };
        return null;
      default:
        return null;
    }
  });

  /**
   * The Click pods, mid-ride (FR-1.4).
   *
   * Silent when both are connected — the chip row is for things that need
   * attention, and two working pods do not. A pod that has dropped or was
   * never connected is named individually, because they do different jobs:
   * lose the `−` pod and the D-pad and shift-down go with it.
   */
  const podChip = $derived.by(() => {
    const c = app.controller;
    if (!c) return null;
    const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
    if (missing.length === 0) return null;
    const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
    const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
    return {
      tone: searching ? 'tone-warn' : 'tone-idle',
      label: searching ? `Looking for the ${names}…` : `No ${names}`,
      // The keyboard is always the fallback, which is what keeps a missing pod
      // an annoyance rather than the end of the ride.
      title: 'Open Devices to connect, or use the keyboard — press ? for the list',
    };
  });

  /**
   * The selected gear (FR-4.1).
   *
   * Read from the snapshot rather than from `ride`, so what is shown is the
   * gear the engine actually rode this tick, not the intent the shell recorded.
   * Development is the subtitle because "8 of 12" alone says nothing about how
   * hard the pedals will be, whereas 6.5 m per crank turn does.
   */
  const gear = $derived.by(() => {
    const count = snap?.gear_count ?? 0;
    if (!snap || count === 0) return { value: '—', sub: null, dim: true };
    return {
      value: `${snap.gear}`,
      // Development says how far the gear carries you; pedal force says what it
      // costs to turn it. The second is the one that tells you, without
      // pedalling, whether the gear you just selected is rideable.
      sub: `of ${count} · ${num(snap.development_m, 1)} m · ${num(snap.pedal_force_n, 0)} N`,
      dim: false,
    };
  });

  /**
   * A speed the engine could not compute is not a slow ride, it is a broken
   * one, and saying "0.0 km/h" without saying why would be the readout lying by
   * omission. Cadence arrives on the trainer's Zwift channel, not over FTMS.
   */
  const speedFault = $derived(
    snap?.speed_source === 'NoCadence' ? 'No cadence from the trainer — speed unavailable' : null,
  );

  /**
   * Cadence, with what the selected gear is asking for. The difference is the
   * whole feedback the rider gets on whether they are in the right gear:
   * turning well above it is spinning out, well below it is grinding.
   */
  const cadenceSub = $derived.by(() => {
    const target = snap?.target_cadence_rpm ?? 0;
    if (!target || target < 20) return null;
    return `gear wants ${num(target, 0)}`;
  });

  const gradient = $derived(snap?.gradient_pct ?? 0);
  const gradeColour = $derived(
    gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
  );

  /** ETA presentation, driven entirely by the kind Rust reported (FR-9.15). */
  const eta = $derived.by(() => {
    if (!d) return { label: 'Time to go', value: '—', sub: null, dim: true };
    switch (d.etaKind) {
      case 'exact':
        return {
          label: 'Time to go',
          value: duration(d.timeRemainingS),
          sub: `ends ${finishAt(d.timeRemainingS)}`,
          dim: false,
        };
      case 'estimated':
        return {
          label: 'ETA',
          value: duration(d.timeRemainingS),
          sub: `arrive ${finishAt(d.timeRemainingS)}`,
          dim: false,
        };
      case 'held':
        return {
          label: 'ETA',
          value: duration(d.timeRemainingS),
          sub: 'held — not moving',
          dim: true,
        };
      case 'looping':
        return {
          label: 'Lap',
          value: String(d.loopIndex ?? 1),
          sub: 'looping profile',
          dim: false,
        };
      default:
        return { label: 'ETA', value: '—', sub: 'no route loaded', dim: true };
    }
  });

  const remaining = $derived.by(() => {
    if (!d || d.distanceRemainingM == null) return null;
    return d.distanceRemainingM;
  });

  /**
   * FR-7.5 — the trainer's own speed reading is diagnostic, so it sits beside
   * the virtual speed rather than replacing it. A trainer that reports nothing
   * (or, as on the D100, in units we have not confirmed) is then visible as a
   * mismatch instead of silently absent.
   */
  const speedSub = $derived.by(() => {
    if (speedFault) return speedFault;
    const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
    const trainer = snap?.telemetry.speed_kph;
    const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
    return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
  });

  const statusChip = $derived.by(() => {
    switch (ride?.status) {
      case 'running':
        return { label: 'Riding', tone: 'tone-ok' };
      case 'paused':
        return { label: 'Paused', tone: 'tone-warn' };
      case 'finished':
        return { label: 'Finished', tone: 'tone-idle' };
      default:
        return { label: 'Ready', tone: 'tone-idle' };
    }
  });

  /**
   * A ride that has not started yet. Before the first pedal stroke almost
   * everything on this screen reads zero, so the screen's job is not to show
   * numbers — it is to show the one action that matters.
   */
  const preRide = $derived(ride?.status !== 'running' && ride?.status !== 'paused');

  /** Route length and climbing, said plainly, so "what is loaded" is obvious. */
  const routeSummary = $derived.by(() => {
    if (!profile) return null;
    const bits: string[] = [];
    if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
    if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
    if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
    if (profile.looping) bits.push('loops');
    return bits.join(' · ');
  });

  const openRoutes = () => (app.showProfiles = true);
</script>

<div class="ride">
  <!-- Header: what is loaded, what mode, what target (FR-9.8). -->
  <header>
    <div class="who">
      <h1>{profile?.name ?? 'No route loaded'}</h1>
      {#if routeSummary}
        <p>{routeSummary}{profile?.description ? ` — ${profile.description}` : ''}</p>
      {:else}
        <p>Manual control — choose a route to ride real terrain.</p>
      {/if}
    </div>

    <div class="chips">
      {#if trainerChip}
        <span class="chip {trainerChip.tone}"><span class="dot"></span>{trainerChip.label}</span>
      {/if}
      <span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
      <!--
        Both pods, mid-ride, in one chip. A Click that has quietly dropped is
        indistinguishable from a Click nobody has touched, and the first press
        that does nothing is a bad moment to find out (FR-1.4, FR-9.2).
      -->
      {#if podChip}
        <span class="chip {podChip.tone}" title={podChip.title}>
          <span class="dot"></span>{podChip.label}
        </span>
      {/if}
      <span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
      <span class="chip target">Target {targetText(ride?.target ?? null)}</span>
      <button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
      <button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
    </div>
  </header>

  <!-- The hero. -->
  {#if show.routeChart}
    <section class="route">
      <RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
    </section>
  {/if}

  {#if preRide}
    <!-- The whole screen before a ride begins: one obvious action. -->
    <section class="launch">
      {#if profile}
        <button class="start" onclick={() => app.run(() => api.start())}>
          {ride?.status === 'finished' ? 'Ride again' : 'Start ride'}
          <span class="kbd">␣</span>
        </button>
        <div class="launch-aside">
          <span class="launch-route">{profile.name}</span>
          <span class="launch-sub">{routeSummary}</span>
          <button class="btn ghost" onclick={openRoutes}>Choose a different route</button>
        </div>
      {:else}
        <button class="start" onclick={openRoutes}>Choose a route</button>
        <div class="launch-aside">
          <span class="launch-sub">
            Pick a bundled route or open your own GPX. Or start now and ride on manual gradient.
          </span>
          <button class="btn ghost" onclick={() => app.run(() => api.start())}>
            Start without a route
          </button>
        </div>
      {/if}
    </section>
  {/if}

  <!-- Primary readouts. -->
  <section class="primary">
    <!-- On a two-column compact grid the hero takes the whole first row: at
         40 px it does not share a row with anything and stay readable. -->
    <div class="hero-cell">
      <Readout
        label={eta.label}
        value={eta.value}
        size="hero"
        colour="var(--route)"
        sub={eta.sub}
        dim={eta.dim}
      />
    </div>
    <Readout
      label="To go"
      value={remaining != null ? km(remaining, 2) : '—'}
      unit={remaining != null ? 'km' : ''}
      size="big"
      sub={d?.distanceTotalM ? `of ${km(d.distanceTotalM, 1)} km` : null}
      dim={remaining == null}
    />
    <Readout
      label="Speed"
      value={num(d?.displaySpeedKph ?? 0, 1)}
      unit="km/h"
      size="big"
      sub={speedSub}
    />
    <Readout
      label="Gradient"
      value={signed(gradient, 1)}
      unit="%"
      size="big"
      colour={gradeColour}
      sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
    />
    <Readout
      label="Gear"
      value={gear.value}
      size="big"
      colour="var(--power)"
      sub={gear.sub}
      dim={gear.dim}
    />
  </section>

  <!-- Route detail. -->
  {#if show.detailRow}
    <section class="detail">
      <Readout
        label="Elevation"
        value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
        unit={d?.elevationM != null ? 'm' : ''}
        colour="var(--climb)"
      />
      <Readout
        label="Climbing left"
        value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
        unit={d?.ascentRemainingM != null ? 'm' : ''}
      />
      <!--
        Compact keeps three of the five, and elapsed time is one of them: it is
        the number a rider on an interval is actually watching. Covered and
        ascended are the ones that go, because the primary row already answers
        "how far" ("of 42.0 km") and the summary screen answers the rest.
      -->
      {#if !compact}
        <Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
        <Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
      {/if}
      <Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
    </section>
  {/if}

  <!-- Effort: present, readable, subordinate. -->
  <section class="effort">
    <Readout
      label={`Power · ${num(d?.rollingPowerWindowS ?? 10, 0)}s`}
      value={num(d?.rollingPowerW ?? 0, 0)}
      unit="W"
      size="mid"
      colour="var(--power)"
      sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
    />
    <Readout
      label="Cadence"
      value={num(snap?.telemetry.cadence_rpm ?? 0, 0)}
      unit="rpm"
      size="mid"
      sub={cadenceSub}
    />
    <Readout
      label="Heart rate"
      value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
      unit="bpm"
      size="mid"
    />
    <!--
      Averages, normalised power, work and calories are what the summary screen
      exists to report (FR-9.13). Mid-ride they are the first thing a small
      screen can do without — nothing about them changes what the rider does in
      the next thirty seconds.
    -->
    {#if show.secondaryEffort}
      <Readout label="Avg power" value={num(d?.avgPowerW ?? 0, 0)} unit="W" size="small" />
      <Readout
        label="Normalised"
        value={d?.normalisedPowerW != null ? num(d.normalisedPowerW, 0) : '—'}
        unit="W"
        size="small"
      />
      <Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
      <!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
      <Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
      <div class="spacer"></div>
    {/if}
    {#if show.streamCharts}
      <div class="charts">
        <div class="chart">
          <span class="label">Power</span>
          <StreamChart
            history={app.power}
            revision={app.revision}
            series={[
              { stroke: 'var(--power-raw)', width: 1 },
              { stroke: 'var(--power)', width: 2 },
            ]}
          />
        </div>
        <div class="chart">
          <span class="label">Gradient</span>
          <StreamChart
            history={app.grade}
            revision={app.revision}
            zeroLine
            series={[{ stroke: 'var(--climb)', width: 2, fill: 'rgba(255, 154, 60, 0.14)' }]}
          />
        </div>
      </div>
    {/if}
  </section>

  <ControlBar />
</div>

<style>
  /*
   * A column, not a fixed grid.
   *
   * This used to be `grid-template-rows` with pixel minimums that together
   * exceeded a short window. When they did, the tracks overflowed and the
   * chart section was painted straight over the control bar — and because
   * uPlot positions its canvas and its `.u-over` overlay, both of which are
   * `position: relative/absolute`, they painted *above* the unpositioned
   * buttons and swallowed every click on them. The Start ride button was
   * visible, looked enabled, and did nothing.
   *
   * Flex items cannot overlap, the flexible sections absorb the slack, and
   * `overflow: hidden` on the chart area means a canvas that has not yet been
   * resized cannot escape it either. The control bar keeps its own stacking
   * context as a final guarantee.
   */
  .ride {
    display: flex;
    flex-direction: column;
    height: 100%;
    min-height: 0;
    overflow: hidden;
  }

  .ride > :global(*) {
    flex: none;
  }

  header {
    display: flex;
    align-items: flex-start;
    gap: var(--gap);
    padding: 0.9rem var(--edge) 0.6rem;
  }

  .who {
    min-width: 0;
  }

  h1 {
    margin: 0;
    font-size: clamp(1.05rem, 1.6vw, 1.5rem);
    font-weight: 600;
    letter-spacing: -0.015em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .who p {
    margin: 0.15rem 0 0;
    font-size: 0.85rem;
    color: var(--ink-dim);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .chips {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin-left: auto;
    flex-wrap: wrap;
    justify-content: flex-end;
  }

  .chip.mode {
    color: var(--ink);
    background: #131b26;
  }

  .chip.target {
    color: var(--route);
    background: rgba(69, 208, 255, 0.1);
  }

  .route {
    flex: 2 1 0;
    min-height: var(--route-min);
    padding: 0 var(--edge);
    overflow: hidden;
  }

  /* ---- pre-ride launch panel ---------------------------------------- */

  .launch {
    display: flex;
    align-items: center;
    gap: var(--gap);
    flex-wrap: wrap;
    padding: 1.1rem var(--edge);
    margin: 0.9rem var(--edge) 0.2rem;
    border-radius: 0.7rem;
    background: var(--bg-lift);
  }

  /* The single most important control on the screen before a ride, and it must
     look like it. */
  .start {
    display: inline-flex;
    align-items: center;
    gap: 0.7em;
    padding: 0.85em 2em;
    border-radius: 0.6rem;
    background: var(--route);
    color: #04121a;
    font-size: clamp(1.1rem, 1.5vw, 1.45rem);
    font-weight: 700;
    letter-spacing: -0.01em;
    transition:
      background 120ms ease,
      transform 90ms ease;
  }

  .start:hover {
    background: #6cdcff;
  }

  .start:active {
    transform: translateY(1px);
  }

  .start :global(.kbd) {
    background: rgba(0, 0, 0, 0.2);
    color: #04121a;
  }

  .launch-aside {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.2rem;
    min-width: 0;
  }

  .launch-route {
    font-size: 1.05rem;
    font-weight: 600;
  }

  .launch-sub {
    font-size: 0.88rem;
    color: var(--ink-dim);
    max-width: 34rem;
  }

  .launch-aside .btn {
    margin-top: 0.3rem;
    padding-left: 0;
  }

  /*
   * Column counts come from the measurement (ui/src/lib/viewport.ts) rather
   * than from breakpoints restated here. Two places deciding what "narrow"
   * means is how a layout ends up correct on a laptop and broken on a phone.
   */
  .primary {
    display: grid;
    grid-template-columns: repeat(var(--cols-primary), minmax(0, 1fr));
    gap: var(--gap);
    padding: 1.1rem var(--edge) 0.9rem;
    align-items: end;
  }

  /* The hero owns its own row wherever the grid is too narrow to give it a
     column of its own — below three columns, sharing a row with a `big`
     readout clips one or the other. */
  .hero-cell {
    grid-column: span 1;
    min-width: 0;
  }

  :global([data-size='compact']) .hero-cell {
    grid-column: 1 / -1;
  }

  .detail {
    display: grid;
    grid-template-columns: repeat(var(--cols-detail), minmax(0, 1fr));
    gap: var(--gap);
    padding: 0 var(--edge) 1rem;
    border-bottom: 1px solid var(--hairline);
  }

  .effort {
    flex: 1 1 0;
    display: grid;
    grid-template-columns: repeat(var(--cols-effort), minmax(0, 1fr));
    grid-template-rows: auto minmax(0, 1fr);
    align-items: start;
    gap: var(--gap);
    padding: 0.9rem var(--edge) 0.4rem;
    min-height: 0;
    /* Nothing inside may be painted outside: see the note on `.ride`. */
    overflow: hidden;
  }

  /* With the sparklines gone there is no second row to reserve, and the effort
     numbers should sit against the control bar rather than float above a gap. */
  :global([data-size='compact']) .effort {
    flex: 0 0 auto;
    grid-template-rows: auto;
  }

  .spacer {
    display: none;
  }

  .charts {
    grid-column: 1 / -1;
    /* `align-items: start` above would otherwise leave this at its content
       height — which is whatever size uPlot happened to pick — instead of the
       height the track actually has. That is what let the canvas grow past the
       bottom of the section. */
    align-self: stretch;
    display: grid;
    grid-template-columns: 1.6fr 1fr;
    gap: var(--gap);
    min-height: 0;
    overflow: hidden;
  }

  .chart {
    display: grid;
    grid-template-rows: auto 1fr;
    gap: 0.15rem;
    min-height: 0;
    overflow: hidden;
  }

  /*
   * Header, on a phone. The chip row and the two buttons cannot sit beside a
   * route title on 412 px, so the title takes the first line and the chips wrap
   * under it, left-aligned — `margin-left: auto` would push a wrapped row into
   * the right-hand gutter.
   */
  :global([data-size='compact']) header {
    flex-direction: column;
    align-items: stretch;
    gap: 0.5rem;
    padding: 0.6rem var(--edge) 0.4rem;
  }

  :global([data-size='compact']) .chips {
    margin-left: 0;
    justify-content: flex-start;
  }

  /* The route summary line is the first thing to go: it repeats what the route
     chart shows, and on a phone it costs a whole line of the ride screen. */
  :global([data-size='compact']) .who p {
    display: none;
  }

  :global([data-size='compact']) .launch {
    flex-direction: column;
    align-items: stretch;
    margin: 0.6rem var(--edge) 0.2rem;
    padding: 0.9rem 1rem;
  }

  /* Full width, because on a phone this is the only thing on screen worth
     tapping and a thumb should not have to find it. */
  :global([data-size='compact']) .start {
    justify-content: center;
    width: 100%;
  }
</style>
