/**
 * Client-side view state. Everything here is either received from Rust or is
 * purely presentational (which screen is showing, which toast is up).
 */
import { save } from '@tauri-apps/plugin-dialog';
import { api, subscribe, type ControllerInput, type ControllerStatus } from './bridge';
import { History } from './history';
import type {
  DeviceList,
  InputAck,
  LapSummary,
  Notice,
  RideFrame,
  RideState,
  RideSummary,
  SampleProfile,
} from './types';

export type Screen = 'connect' | 'ride' | 'summary';

let toastSeq = 0;

class AppStore {
  screen = $state<Screen>('connect');
  frame = $state<RideFrame | null>(null);
  ride = $state<RideState | null>(null);
  devices = $state<DeviceList>({ scanning: false, devices: [] });
  samples = $state<SampleProfile[]>([]);
  toasts = $state<(Notice & { id: number })[]>([]);
  lastAck = $state<(InputAck & { at: number }) | null>(null);
  lastLap = $state<LapSummary | null>(null);
  /** The finished ride behind the summary screen (FR-9.13). */
  summary = $state<RideSummary | null>(null);
  /** True while a save dialog is open, so the button cannot be double-fired. */
  saving = $state(false);
  showHelp = $state(false);
  showProfiles = $state(false);
  /** Both Click pods, tracked separately: each has its own link, its own
   *  battery and its own way of going missing (FR-1.4). */
  controller = $state<ControllerStatus | null>(null);
  /** Bumped on every snapshot so charts know to redraw without deep tracking. */
  revision = $state(0);

  /** Bounded chart history — raw power, rolling power. */
  readonly power = new History(2);
  /** Bounded chart history — commanded gradient. */
  readonly grade = new History(1);

  private lastElapsed = -1;

  /** An FTMS trainer that is connected *and* has accepted the control point. */
  get trainerReady(): boolean {
    return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired);
  }

  /**
   * Enter the ride screen.
   *
   * There is no trainer-less ride: without a trainer under control the screen
   * would show zeros and record a session that never happened. The gate lives
   * here rather than on the button that opens the screen, so the keyboard and
   * the Click cannot walk around it.
   *
   * A ride already under way is always let back in — a trainer that drops
   * mid-effort must not lock the rider out of their own ride.
   */
  goToRide(): boolean {
    const riding = this.ride?.status === 'running' || this.ride?.status === 'paused';
    if (!this.trainerReady && !riding) {
      this.toast({
        level: 'warn',
        message: 'Connect a trainer and acquire FTMS control before riding.',
      });
      return false;
    }
    this.screen = 'ride';
    return true;
  }

  /**
   * Controller input is routed by the caller, not here: `App.svelte` owns the
   * keyboard map, and the Click must land on the *same* intents rather than a
   * parallel set that can drift.
   */
  async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
    const [ride, devices, samples, summary, recovered, controller] = await Promise.all([
      api.rideState(),
      api.deviceList(),
      api.sampleProfiles(),
      api.rideSummary(),
      api.recoveredRides(),
      // Asked for rather than waited for: the status event only fires on a
      // *change*, so a webview reload with both pods already connected would
      // otherwise show two empty slots.
      api.controllerStatus(),
    ]);
    this.ride = ride;
    this.devices = devices;
    this.samples = samples;
    this.summary = summary;
    this.controller = controller;
    // A ride already in progress (a reload, or an autostart) belongs on screen
    // immediately — nobody wants to click past a device list mid-effort.
    if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
    // A reload during the summary lands back on it rather than on the device
    // list, where an unsaved ride would look like no ride at all.
    else if (ride.status === 'finished' && summary) this.screen = 'summary';

    // FR-8.4. Reported here rather than from Rust because the setup hook runs
    // before this webview is listening, and a recovered ride announced to
    // nobody is the same as one silently discarded.
    for (const r of recovered) {
      this.toast({
        level: 'warn',
        message: `Recovered an interrupted ride — saved to ${r.fitPath}`,
      });
    }

    await subscribe({
      onFrame: (f) => this.onFrame(f),
      onSummary: (s) => {
        this.summary = s;
        this.screen = 'summary';
      },
      onRideState: (s) => {
        this.ride = s;
      },
      onDevices: (d) => {
        this.devices = d;
      },
      onLap: (l) => {
        this.lastLap = l;
      },
      onNotice: (n) => this.toast(n),
      onInputAck: (a) => {
        this.lastAck = { ...a, at: performance.now() };
      },
      onControllerInput: hooks.onControllerInput,
      onControllerStatus: (s) => {
        this.controller = s;
      },
    });
  }

  private onFrame(f: RideFrame): void {
    this.frame = f;
    const t = f.snapshot.elapsed_ms / 1000;
    // The ride clock only advances while running; a paused ride should not
    // stack duplicate points onto the charts.
    if (t > this.lastElapsed) {
      this.lastElapsed = t;
      this.power.push(t, [f.snapshot.telemetry.power_w ?? 0, f.derived.rollingPowerW]);
      this.grade.push(t, [f.snapshot.gradient_pct]);
    } else if (t < this.lastElapsed) {
      this.clearHistory();
      this.lastElapsed = t;
    }
    this.revision++;
  }

  clearHistory(): void {
    this.power.clear();
    this.grade.clear();
    this.lastElapsed = -1;
  }

  toast(n: Notice): void {
    const entry = { ...n, id: ++toastSeq };
    this.toasts = [...this.toasts, entry];
    const ttl = n.level === 'error' ? 8000 : 4000;
    setTimeout(() => {
      this.toasts = this.toasts.filter((t) => t.id !== entry.id);
    }, ttl);
  }

  dismiss(id: number): void {
    this.toasts = this.toasts.filter((t) => t.id !== id);
  }

  /**
   * Ask where to put the finished activity, and put it there (FR-9.14).
   *
   * The ride is already on disk before this runs, so cancelling the dialog
   * costs nothing — which is why this can be a plain "Save a copy" rather than
   * a save the rider must not get wrong.
   */
  async saveFit(): Promise<void> {
    const summary = this.summary;
    if (!summary || this.saving) return;
    this.saving = true;
    try {
      const suggested = summary.fitPath.split(/[/\\]/).pop() ?? 'ride.fit';
      const dest = await save({
        defaultPath: suggested,
        filters: [{ name: 'FIT activity', extensions: ['fit'] }],
      });
      if (dest === null) return; // cancelled — the automatic copy still stands
      const written = await api.saveFit(dest);
      // Rust confirms the path it actually wrote; trust that over `dest`.
      this.summary = { ...summary, savedPath: written };
    } catch (e) {
      this.toast({ level: 'error', message: String(e) });
    } finally {
      this.saving = false;
    }
  }

  /** Leave the summary and set up for another ride. */
  async newRide(): Promise<void> {
    await this.run(() => api.reset());
    this.summary = null;
    this.clearHistory();
    this.screen = 'ride';
  }

  /** Run a command and surface any rejection as a toast rather than silently. */
  async run<T>(fn: () => Promise<T>): Promise<T | undefined> {
    try {
      return await fn();
    } catch (e) {
      this.toast({ level: 'error', message: String(e) });
      return undefined;
    }
  }
}

export const app = new AppStore();
