import { player } from './store.svelte';
import { api } from '$lib/api/client';
import { getOrCreateClientId } from './clientId';
import type { PlayStartedResponse, EventOkResponse } from '$lib/api/types';

// useEventsDispatcher installs $effect-based watchers that emit play events
// to /api/events. Call once from +layout.svelte's mount.
//
// The dispatcher is a small state machine over (current track id, state):
//   - First transition into 'playing' for a new track → POST play_started.
//   - Track id changes while a play is open → close the prior row with
//     play_ended carrying the actual position played to. Server applies
//     its skip rule (skip_max_completion_ratio + skip_max_duration_played_ms,
//     default 50%/30s) and decides was_skipped from that.
//   - Natural completion (state moves from 'playing' to 'paused' AND
//     position >= duration > 0) → POST play_ended on the open row.
//   - pagehide with a live row → navigator.sendBeacon a play_ended with
//     the last position. The server's rule still classifies skip-vs-not.
//
// Earlier shape made the skip-vs-ended call client-side via a "within 3s
// of duration" rule, which left the server's threshold dead code and
// treated every "next" tap as a skip even after most of the track played.
// Backflow of the Android fix (commit d82e744d, 2026-06-01); see Scribe
// task 519. Flutter client is deprecated and not updated.

export function useEventsDispatcher(): void {
  let openPlayEventId: string | null = null;
  let openTrackId: string | null = null;
  // openLastPositionMs tracks the *open row's* track and is updated
  // ONLY while that track is current — so a track change (which
  // synchronously resets the store's position to 0 and duration to 0)
  // can't clobber it before the close branch reads it. This is the
  // #426 race fix. The pagehide + natural-end branches can read
  // player.position directly because their current track is still the
  // open one at fire time.
  let openLastPositionMs = 0;
  let prevTrackId: string | null = null;
  let prevState: string | null = null;
  const clientId = getOrCreateClientId();

  $effect(() => {
    if (openTrackId && player.current?.id === openTrackId) {
      openLastPositionMs = Math.round(player.position * 1000);
    }
  });

  // Main state machine. Reacts only to (current track id, state) transitions.
  $effect(() => {
    const t = player.current;
    const s = player.state;
    const tid = t?.id ?? null;

    // Track changed: close the prior open row. Always send play_ended
    // with the actual position played; the server's skip rule decides
    // whether to flag was_skipped.
    if (tid !== prevTrackId && openPlayEventId) {
      const id = openPlayEventId;
      const dur = openLastPositionMs;
      openPlayEventId = null;
      openTrackId = null;
      openLastPositionMs = 0;
      api.post<EventOkResponse>('/api/events', {
        type: 'play_ended',
        play_event_id: id,
        duration_played_ms: dur
      }).catch(() => {});
    }

    // Entered playing for a new track (no open row yet for this track).
    if (tid && s === 'playing' && openTrackId !== tid) {
      void startNew(tid);
    }

    // Natural end: state went playing -> paused while at/past duration.
    if (
      prevState === 'playing' &&
      s === 'paused' &&
      openPlayEventId &&
      player.duration > 0 &&
      player.position >= player.duration
    ) {
      const id = openPlayEventId;
      const dur = Math.round(player.position * 1000);
      openPlayEventId = null;
      openTrackId = null;
      openLastPositionMs = 0;
      api.post<EventOkResponse>('/api/events', {
        type: 'play_ended',
        play_event_id: id,
        duration_played_ms: dur
      }).catch(() => {});
    }

    prevTrackId = tid;
    prevState = s;
  });

  // Page close: sendBeacon a play_ended if an open play exists. The
  // server's skip rule decides classification from the duration we
  // actually played. Reads player.position directly rather than the
  // tracked lastPositionMs — the open track is still current at
  // pagehide time so the live read gives the freshest value, and the
  // $effect-tracked path isn't reliably reached in test fixtures.
  if (typeof window !== 'undefined') {
    const onPageHide = () => {
      if (!openPlayEventId) return;
      const posMs = Math.round(player.position * 1000);
      const payload = JSON.stringify({
        type: 'play_ended',
        play_event_id: openPlayEventId,
        duration_played_ms: posMs
      });
      navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' }));
    };
    window.addEventListener('pagehide', onPageHide);
  }

  async function startNew(trackId: string): Promise<void> {
    try {
      const res = await api.post<PlayStartedResponse>('/api/events', {
        type: 'play_started',
        track_id: trackId,
        client_id: clientId,
        // #415: tag the play with the system playlist it came from
        // (null for library / user-playlist / radio) so the server
        // can advance that playlist's rotation.
        source: player.queueSource ?? undefined,
        // #1551: device class for context conditioning. The web app is a
        // browser session, so it reports 'web'.
        device_class: 'web'
      });
      // The user may have moved on by the time the response arrives. Only
      // adopt the id if we're still on the same track and still playing.
      if (player.current?.id === trackId && player.state === 'playing') {
        openPlayEventId = res.play_event_id;
        openTrackId = trackId;
      }
    } catch {
      // Best-effort; missed events are OK in v1.
    }
  }
}
