<script lang="ts">
  import { page } from "$app/stores";
  import { SvelteMap } from "svelte/reactivity";
  import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
  import debounce from "$lib/util/debounce";
  import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
  import { shuffleInPlace } from "$lib/util/shuffle";
  import { fade, fly, slide } from "svelte/transition";
  import TrackChords from "$lib/charrecorder/TrackChords.svelte";
  import ChordHud from "$lib/charrecorder/ChordHud.svelte";
  import type { InferredChord } from "$lib/charrecorder/core/types";
  import TrackText from "$lib/charrecorder/TrackText.svelte";
  import { browser } from "$app/environment";
  import { expoOut } from "svelte/easing";
  import { goto } from "$app/navigation";
  import { untrack } from "svelte";
  import {
    type PageParam,
    SENTENCE_TRAINER_PAGE_PARAMS,
  } from "./configuration";
  import {
    AVG_WORD_LENGTH,
    MILLIS_IN_SECOND,
    SECONDS_IN_MINUTE,
  } from "./constants";
  import { pickNextWord } from "./word-selector";

  /**
   * Resolves parameter from search URL or returns default
   * @param param {@link PageParam} generic parameter that can be provided
   * in search url
   * @return Value of the parameter converted to its type or default value
   * if parameter is not present in the URL.
   */
  function getParamOrDefault<T>(param: PageParam<T>): T {
    if (browser) {
      const value = $page.url.searchParams.get(param.key);
      if (null !== value) {
        return param.parse ? param.parse(value) : (value as unknown as T);
      }
    }
    return param.default;
  }

  function viaLocalStorage<T>(key: string, initial: T) {
    try {
      return JSON.parse(localStorage.getItem(key) ?? "");
    } catch {
      return initial;
    }
  }

  // Delay to ensure cursor is visible after focus is set.
  // it is a workaround for conflict between goto call on sentence update
  // and cursor focus when next word is selected.
  const CURSOR_FOCUS_DELAY_MS = 10;

  let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
    viaLocalStorage("mastery-thresholds", [
      [1500, 1050, "Words"],
      [3000, 2500, "Pairs"],
      [5000, 3500, "Trios"],
    ]),
  );

  function reset() {
    localStorage.removeItem("mastery-thresholds");
    localStorage.removeItem("idle-timeout");
    window.location.reload();
  }

  const inputSentence = $derived(
    getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence),
  );

  const wpmTarget = $derived(
    getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
  );

  const devTools = $derived(
    getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
  );

  let chordInputContainer: HTMLDivElement | null = null;

  let sentenceWords = $derived(inputSentence.trim().split(/\s+/));

  let inputSentenceLength = $derived(inputSentence.length);
  let msPerChar = $derived(
    (1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) *
      MILLIS_IN_SECOND,
  );
  let totalMs = $derived(inputSentenceLength * msPerChar);
  let msPerWord = $derived(
    (inputSentenceLength * msPerChar) / sentenceWords.length,
  );
  let currentWord = $state("");
  let wordStats = new SvelteMap<string, number[]>();
  let wordMastery = new SvelteMap<string, number>();
  let text = $state("");
  let level = $state(0);
  let bestWPM = $state(0);
  let wpm = $state(0);
  let chords: InferredChord[] = $state([]);
  let recorder = $state(new ReplayRecorder());
  let idle = $state(true);
  let idleTime = $state(viaLocalStorage("idle-timeout", 100));

  let idleTimeout: ReturnType<typeof setTimeout> | null = null;

  $effect(() => {
    if (wpm > bestWPM) {
      bestWPM = wpm;
    }
  });

  $effect(() => {
    if (browser && $page.url.searchParams) {
      selectNextWord();
    }
  });

  $effect(() => {
    localStorage.setItem("idle-timeout", idleTime.toString());
  });

  $effect(() => {
    localStorage.setItem(
      "mastery-thresholds",
      JSON.stringify(masteryThresholds),
    );
  });

  let words = $derived.by(() => {
    const words = sentenceWords;
    switch (level) {
      case 0: {
        shuffleInPlace(words);
        return words;
      }
      case 1: {
        const pairs = [];
        for (let i = 0; i < words.length - 1; i++) {
          pairs.push(`${words[i]} ${words[i + 1]}`);
        }
        shuffleInPlace(pairs);
        return pairs;
      }
      case 2: {
        const trios = [];
        for (let i = 0; i < words.length - 2; i++) {
          trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
        }
        shuffleInPlace(trios);
        return trios;
      }
      default: {
        return [inputSentence];
      }
    }
  });

  $effect(() => {
    for (const [word, speeds] of wordStats.entries()) {
      const level = word.split(" ").length - 1;
      const masteryThreshold = masteryThresholds[level];
      if (masteryThreshold === undefined) continue;
      const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
      wordMastery.set(
        word,
        1 -
          Math.min(
            1,
            Math.max(
              0,
              (averageSpeed - masteryThreshold[1]) /
                (masteryThreshold[0] - masteryThreshold[1]),
            ),
          ),
      );
    }
  });

  let progress = $derived(
    level === masteryThresholds.length
      ? Math.min(1, Math.max(0, bestWPM / wpmTarget))
      : words.length > 0
        ? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
          words.length
        : 0,
  );
  let mastered = $derived(
    words.length > 0
      ? words.filter((it) => wordMastery.get(it) === 1).length / words.length
      : 0,
  );

  $effect(() => {
    if (progress === 1 && level < masteryThresholds.length) {
      level++;
    }
  });

  function selectNextWord() {
    const nextWord = pickNextWord(
      words,
      wordMastery,
      untrack(() => currentWord),
    );
    currentWord = nextWord;
    recorder = new ReplayRecorder(nextWord);
    setTimeout(() => {
      chordInputContainer?.focus();
    }, CURSOR_FOCUS_DELAY_MS);
  }

  function checkInput() {
    if (recorder.player.stepper.challenge.length === 0) return;
    const replay = recorder.finish(false);
    const elapsed = replay.finish - replay.start! - idleTime;
    if (elapsed < masteryThresholds[level]![0]) {
      const prevStats = wordStats.get(currentWord) ?? [];
      prevStats.push(elapsed);
      wordStats.set(currentWord, prevStats.slice(-10));
    }

    text = "";
    setTimeout(() => {
      selectNextWord();
    });
  }

  $effect(() => {
    if (!idle || !text) return;
    if (text.trim() !== currentWord.trim()) return;
    if (level === masteryThresholds.length) {
      const replay = recorder.finish();
      const elapsed = replay.finish - replay.start!;
      text = "";
      recorder = new ReplayRecorder(currentWord);
      console.log(elapsed, totalMs);
      wpm = (totalMs / elapsed) * wpmTarget;
    } else {
      checkInput();
    }
  });

  function onkey(event: KeyboardEvent) {
    if (idleTimeout) {
      clearTimeout(idleTimeout);
    }
    idle = false;
    recorder.next(event);
    idleTimeout = setTimeout(() => {
      idle = true;
    }, idleTime);
  }

  function updateSentence(event: Event) {
    const params = new URLSearchParams(window.location.search);
    params.set(
      SENTENCE_TRAINER_PAGE_PARAMS.sentence.key,
      (event.target as HTMLInputElement).value,
    );
    goto(`?${params.toString()}`);
  }

  const debouncedUpdateSentence = debounce(
    updateSentence,
    getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis),
  );

  function handleInputAreaKeyDown(event: KeyboardEvent) {
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault(); // Prevent new line.
      debouncedUpdateSentence.cancel(); // Cancel any pending debounced update
      updateSentence(event); // Update immediately
    }
  }
</script>

<div>
  <h1>Sentence Trainer</h1>
  <textarea
    rows="7"
    cols="80"
    oninput={debouncedUpdateSentence}
    onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
  >

  <div class="levels">
    {#each masteryThresholds as [, , title], i}
      <button
        class:active={level === i}
        class:mastered={i < level || progress === 1}
        class="threshold"
        onclick={() => {
          level = i;
          selectNextWord();
        }}
      >
        {title}
      </button>
    {/each}
    <button
      class:active={level === masteryThresholds.length}
      class:mastered={masteryThresholds.length < level || progress === 1}
      class="threshold"
      onclick={() => {
        level = masteryThresholds.length;
        selectNextWord();
      }}
    >
      {wpmTarget} WPM
    </button>
    {#each masteryThresholds as _, i}
      <div
        class="progress"
        style:--progress="{-100 *
          (1 - (level === i ? progress : i < level ? 1 : 0))}%"
        style:--mastered="{-100 *
          (1 - (level === i ? mastered : i < level ? 1 : 0))}%"
        class:active={level === i}
      ></div>
    {/each}
    <div
      class="progress"
      style:--progress="-100%"
      style:--mastered="{-100 *
        (1 -
          (level === masteryThresholds.length
            ? progress
            : masteryThresholds.length < level
              ? 1
              : 0))}%"
      class:active={level === masteryThresholds.length}
    ></div>
  </div>
  <div class="sentence">
    {#each sentenceWords as _, i}
      {#if i !== sentenceWords.length - 1}
        {@const word = sentenceWords.slice(i, i + 2).join(" ")}
        {@const mastery = wordMastery.get(word) ?? 0}
        <div
          class="arch"
          class:mastered={mastery === 1}
          style:opacity={mastery}
          style:grid-row={(i % 2) + 1}
          style:grid-column="{i + 1} / span 2"
          style:border-bottom="none"
        ></div>
      {/if}
    {/each}
    {#each sentenceWords as word, i}
      {@const mastery = wordMastery.get(word)}
      <div
        class="word"
        class:mastered={mastery === 1}
        style:opacity={mastery ?? 0}
        style:grid-row={3}
        style:grid-column={i + 1}
      >
        {word}
      </div>
    {/each}
    {#each sentenceWords as _, i}
      {#if i < sentenceWords.length - 2}
        {@const word = sentenceWords.slice(i, i + 3).join(" ")}
        {@const mastery = wordMastery.get(word) ?? 0}
        <div
          class="arch"
          class:mastered={mastery === 1}
          style:opacity={mastery}
          style:grid-row={(i % 3) + 4}
          style:grid-column="{i + 1} / span 3"
          style:border-top="none"
        ></div>
      {/if}
    {/each}
  </div>
  {#if level === masteryThresholds.length}
    {@const maxDigits = 4}
    {@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
    {@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
    <div class="finish" transition:slide>
      <div
        class="wpm"
        style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
        style:opacity={progress}
        style:font-size="3rem"
        style:color="var(--md-sys-color-{progress === 1
          ? 'primary'
          : 'on-background'})"
        style:scale={(progress + 0.5) / 2}
      >
        {#each indices as i}
          {@const char = wpmString[i]}
          {#key char}
            <div
              style:grid-column={i + 1}
              in:fly={{ y: 20, duration: 1000, easing: expoOut }}
              out:fly={{ y: -20, duration: 1000, easing: expoOut }}
            >
              {char}
            </div>
          {/key}
        {/each}
        <div style:grid-column={maxDigits + 3} style:justify-self="start">
          WPM
        </div>
      </div>
      <div
        class="wpm"
        style:grid-template-columns="4ch 1ch auto"
        style:font-size="1.5rem"
      >
        {#key wpm}
          <div
            style:grid-column={1}
            style:justify-self="end"
            transition:fade={{ duration: 200 }}
          >
            {Math.floor(wpm)}
          </div>
        {/key}
        <div style:grid-column={3} style:justify-self="start">WPM</div>
      </div>
    </div>
  {/if}
  <ChordHud {chords} />
  <div class="container">
    <div
      bind:this={chordInputContainer}
      class="input-section"
      onkeydown={onkey}
      onkeyup={onkey}
      tabindex="0"
      role="textbox"
    >
      {#key recorder}
        <div class="input" transition:fade={{ duration: 200 }}>
          <CharRecorder replay={recorder.player} cursor={true} keys={true}>
            <TrackText bind:text />
            <TrackChords bind:chords />
          </CharRecorder>
        </div>
      {/key}
    </div>
  </div>
  {#if devTools}
    <div>Dev Tools</div>
    <button onclick={reset}>Reset</button>
    <label>Idle Time <input bind:value={idleTime} /></label>
    <table>
      <tbody>
        <tr>
          <th>Total</th>
          <td
            ><span style:color="var(--md-sys-color-tertiary)"
              >{Math.round(totalMs)}</span
            >ms
          </td>
        </tr>
        <tr>
          <th>Char</th>
          <td
            ><span style:color="var(--md-sys-color-tertiary)"
              >{Math.round(msPerChar)}</span
            >ms
          </td>
        </tr>
        <tr>
          <th>Word</th>
          <td
            ><span style:color="var(--md-sys-color-tertiary)"
              >{Math.round(msPerWord)}</span
            >ms
          </td>
        </tr>
      </tbody>
    </table>
    <table>
      <tbody>
        {#each masteryThresholds as _, i}
          <tr>
            <th>L{i + 1}</th>
            <td><input bind:value={masteryThresholds[i]![0]} /></td>
            <td><input bind:value={masteryThresholds[i]![1]} /></td>
            <td><input bind:value={masteryThresholds[i]![2]} /></td>
          </tr>
        {/each}
      </tbody>
    </table>
    <table>
      <tbody>
        {#each wordStats.entries() as [word, stats]}
          {@const mastery = wordMastery.get(word) ?? 0}
          <tr>
            <th>{word}</th>
            <td
              style:color="var(--md-sys-color-{mastery === 1
                ? 'primary'
                : 'tertiary'})"
              >{Math.round(mastery * 100)}%
            </td>
            {#each stats as stat}
              <td>{stat}</td>
            {/each}
          </tr>
        {/each}
      </tbody>
    </table>
  {/if}
</div>

<style lang="scss">
  .levels {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 2px;

    button {
      margin: 0;
      font-size: 1rem;
    }
  }

  .wpm {
    width: min-content;
    display: grid;
    transition: scale 0.2s ease;

    * {
      grid-row: 1;
    }
  }

  .finish {
    display: grid;
    grid-template-rows: repeat(2, 1fr);
    font-weight: bold;
    justify-items: center;
    align-items: center;
  }

  .sentence {
    display: grid;
    width: min-content;
    gap: 4px 1ch;
    grid-template-rows: repeat(4, auto);
    margin-block: 1rem;

    .word,
    .arch {
      transition: opacity 0.2s ease;

      &.mastered {
        color: var(--md-sys-color-primary);
        border-color: var(--md-sys-color-primary);
      }
    }

    .arch {
      border: 2px solid var(--md-sys-color-outline);
      height: 8px;
    }
  }

  .progress {
    position: relative;
    height: 1rem;
    width: auto;
    background: var(--md-sys-color-outline-variant);
    border: none;
    overflow: hidden;
    grid-row: 2;

    &::before,
    &::after {
      position: absolute;
      content: "";
      display: block;
      height: 100%;
      width: 100%;
      transition: transform 0.2s;
    }

    &::before {
      background: var(--md-sys-color-outline);
      transform: translateX(var(--progress));
    }

    &::after {
      background: var(--md-sys-color-primary);
      transform: translateX(var(--mastered));
    }
  }

  .threshold {
    width: auto;
    justify-self: center;
    opacity: 0.5;
    transition: opacity 0.2s;
    grid-row: 1;

    &.mastered,
    &.active {
      opacity: 1;
    }

    &.mastered {
      color: var(--md-sys-color-primary);
    }
  }

  .input-section {
    display: grid;
    cursor: text;

    :global(.cursor) {
      opacity: 0;
    }
  }

  .input {
    display: flex;
    grid-row: 1;
    grid-column: 1;
    font-size: 1.5rem;
    padding: 1rem;
    max-width: 16cm;
    outline: 2px dashed transparent;
    border-radius: 0.25rem;
    margin-block: 1rem;
    transition:
      outline 0.2s ease,
      border-radius 0.2s ease;
  }

  .input-section:focus-within {
    outline: none;

    .input {
      outline-color: var(--md-sys-color-primary);
      border-radius: 1rem;
    }

    :global(.cursor) {
      opacity: 1;
    }
  }
</style>
