/**
 * Every permalink parameter on this site, in one module.
 *
 * The demo pages are meant to be sent to someone: "look at step 4 of the
 * fine-tuned run". That only works if the URL is the state, and it only stays
 * readable if a clean state produces a clean URL. So the two rules here are:
 *
 *   1. Defaults are OMITTED. `/demos/wordle-five` and
 *      `/demos/wordle-five?step=0&speed=1&tab=play` are the same page, and only
 *      the first one is worth pasting into an email.
 *   2. Unknown params SURVIVE. Every write is built from the params that are
 *      already there, so a campaign tag or a future param added by another page
 *      is not silently eaten by a scrub of the timeline.
 */

import { useCallback, useMemo } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { isPlaybackSpeed, type PlaybackSpeed } from '@/lib/demo-kit/player';

export const PARAM = {
  run: 'run',
  step: 'step',
  tab: 'tab',
  seed: 'seed',
  speed: 'speed',
} as const;

/** The tab a demo page opens on when the URL says nothing. */
export const DEFAULT_TAB = 'play';

export interface DemoUrlState {
  /** Run id from the manifest. `null` means "the demo's first run". */
  run: string | null;
  /** Zero-based step index. */
  step: number;
  tab: string;
  /** `null` means "the demo's own default seed". */
  seed: number | null;
  speed: PlaybackSpeed;
}

export type DemoUrlPatch = Partial<DemoUrlState>;

export interface PatchOptions {
  /**
   * Replace the history entry instead of pushing one. Left unset, params that
   * change during playback (`step`, `speed`) replace and everything else
   * pushes — so Back leaves the tab you were on rather than rewinding the
   * scrubber one frame at a time.
   */
  replace?: boolean;
}

/** Params that move while the visitor is just watching, not navigating. */
const TRANSIENT_PARAMS = new Set<keyof DemoUrlState>(['step', 'speed']);

function parseIndex(raw: string | null): number {
  if (raw === null) return 0;
  const parsed = Number.parseInt(raw, 10);
  if (!Number.isFinite(parsed) || parsed < 0) return 0;
  return parsed;
}

function parseSeed(raw: string | null): number | null {
  if (raw === null || raw.trim() === '') return null;
  const parsed = Number.parseInt(raw, 10);
  return Number.isFinite(parsed) ? parsed : null;
}

function parseSpeed(raw: string | null): PlaybackSpeed {
  if (raw === null) return 1;
  if (raw === 'instant') return 'instant';
  const parsed = Number.parseInt(raw, 10);
  return isPlaybackSpeed(parsed) ? parsed : 1;
}

export function readUrlState(params: URLSearchParams, defaultTab = DEFAULT_TAB): DemoUrlState {
  const run = params.get(PARAM.run);
  const tab = params.get(PARAM.tab);
  return {
    run: run === null || run.trim() === '' ? null : run,
    step: parseIndex(params.get(PARAM.step)),
    tab: tab === null || tab.trim() === '' ? defaultTab : tab,
    seed: parseSeed(params.get(PARAM.seed)),
    speed: parseSpeed(params.get(PARAM.speed)),
  };
}

/**
 * Apply a patch to a set of params, dropping anything that is at its default.
 * Returns a NEW URLSearchParams; the input is never mutated.
 */
export function writeUrlState(
  params: URLSearchParams,
  patch: DemoUrlPatch,
  defaultTab = DEFAULT_TAB,
): URLSearchParams {
  const next = new URLSearchParams(params);

  const put = (key: string, value: string | null): void => {
    if (value === null) next.delete(key);
    else next.set(key, value);
  };

  if ('run' in patch) put(PARAM.run, patch.run ?? null);
  if ('step' in patch) {
    const step = patch.step ?? 0;
    put(PARAM.step, step > 0 ? String(Math.trunc(step)) : null);
  }
  if ('tab' in patch) {
    const tab = patch.tab ?? defaultTab;
    put(PARAM.tab, tab === defaultTab ? null : tab);
  }
  if ('seed' in patch) {
    const seed = patch.seed;
    put(PARAM.seed, seed === null || seed === undefined ? null : String(Math.trunc(seed)));
  }
  if ('speed' in patch) {
    const speed = patch.speed ?? 1;
    put(PARAM.speed, speed === 1 ? null : String(speed));
  }

  return next;
}

export interface UrlStateApi {
  state: DemoUrlState;
  patch: (changes: DemoUrlPatch, options?: PatchOptions) => void;
  /** Clear every param this module owns; anything else in the URL survives. */
  reset: (options?: PatchOptions) => void;
  /** `/demos/x?step=3` — path plus search, for a router `<Link to>`. */
  hrefFor: (changes?: DemoUrlPatch) => string;
  /** Absolute URL, for a copy-link button. */
  permalinkFor: (changes?: DemoUrlPatch) => string;
}

export function useUrlState(options?: { defaultTab?: string }): UrlStateApi {
  const defaultTab = options?.defaultTab ?? DEFAULT_TAB;
  const [params, setParams] = useSearchParams();
  const { pathname } = useLocation();

  const state = useMemo(() => readUrlState(params, defaultTab), [params, defaultTab]);

  const patch = useCallback(
    (changes: DemoUrlPatch, patchOptions?: PatchOptions) => {
      const keys = Object.keys(changes) as (keyof DemoUrlState)[];
      const replace = patchOptions?.replace ?? keys.every((key) => TRANSIENT_PARAMS.has(key));
      // The updater form matters: two patches in the same tick (the player
      // advancing a step while the visitor clicks a tab) would otherwise both
      // read the pre-render params and the second would undo the first.
      setParams((prev) => writeUrlState(prev, changes, defaultTab), {
        replace,
        preventScrollReset: true,
      });
    },
    [setParams, defaultTab],
  );

  const reset = useCallback(
    (patchOptions?: PatchOptions) => {
      setParams(
        (prev) => {
          const next = new URLSearchParams(prev);
          for (const key of Object.values(PARAM)) next.delete(key);
          return next;
        },
        { replace: patchOptions?.replace ?? false, preventScrollReset: true },
      );
    },
    [setParams],
  );

  const hrefFor = useCallback(
    (changes: DemoUrlPatch = {}) => {
      const search = writeUrlState(params, changes, defaultTab).toString();
      return search ? `${pathname}?${search}` : pathname;
    },
    [params, pathname, defaultTab],
  );

  const permalinkFor = useCallback(
    (changes: DemoUrlPatch = {}) => {
      const href = hrefFor(changes);
      // Prerendering runs this file in a browser too, but guard anyway: a
      // permalink is not worth throwing a page away for.
      const origin = typeof window === 'undefined' ? '' : window.location.origin;
      return `${origin}${href}`;
    },
    [hrefFor],
  );

  return { state, patch, reset, hrefFor, permalinkFor };
}

/* ------------------------------------------------------------------------- *
 * Single-param conveniences. Each is `[value, setValue]` and each writes
 * through the same omit-the-default path, so mixing them cannot produce a URL
 * that `useUrlState` reads back differently.
 * ------------------------------------------------------------------------- */

export function useRunParam(): [string | null, (run: string | null, options?: PatchOptions) => void] {
  const { state, patch } = useUrlState();
  const set = useCallback(
    (run: string | null, options?: PatchOptions) => {
      // A new run invalidates the step index: step 6 of a nine-turn rollout is
      // not step 6 of a three-turn one.
      patch({ run, step: 0 }, options);
    },
    [patch],
  );
  return [state.run, set];
}

export function useStepParam(): [number, (step: number, options?: PatchOptions) => void] {
  const { state, patch } = useUrlState();
  const set = useCallback(
    (step: number, options?: PatchOptions) => patch({ step }, options),
    [patch],
  );
  return [state.step, set];
}

export function useTabParam(
  defaultTab = DEFAULT_TAB,
): [string, (tab: string, options?: PatchOptions) => void] {
  const { state, patch } = useUrlState({ defaultTab });
  const set = useCallback(
    (tab: string, options?: PatchOptions) => patch({ tab }, options),
    [patch],
  );
  return [state.tab, set];
}

export function useSeedParam(): [number | null, (seed: number | null, options?: PatchOptions) => void] {
  const { state, patch } = useUrlState();
  const set = useCallback(
    (seed: number | null, options?: PatchOptions) => patch({ seed }, options),
    [patch],
  );
  return [state.seed, set];
}

export function useSpeedParam(): [PlaybackSpeed, (speed: PlaybackSpeed, options?: PatchOptions) => void] {
  const { state, patch } = useUrlState();
  const set = useCallback(
    (speed: PlaybackSpeed, options?: PatchOptions) => patch({ speed }, options),
    [patch],
  );
  return [state.speed, set];
}
