<svelte:options
  customElement={{
    props: {
      columns: { type: "Array" },
    },
    shadow: "none",
    tag: "table-viewer",
  }}
/>

<script lang="ts">
  import { z } from "zod";

  import {
    type Coords,
    coords_eq,
    get_box,
    offset_coords,
  } from "./coords.svelte";
  import {
    type Datum,
    datum_schema,
    parse_clipboard_value,
  } from "./datum.svelte";
  import DatumEditor from "./datum-editor.svelte";
  import { type Row, type FieldInfo, field_info_schema } from "./field.svelte";
  import FieldAdder from "./field-adder.svelte";
  import FieldHeader from "./field-header.svelte";
  import { get_empty_datum_for } from "./presentation.svelte";
  import TableCell from "./table-cell.svelte";

  type Props = {
    columns?: {
      name: string;
      regtype: string;
    }[];
  };

  let { columns = [] }: Props = $props();

  type CellDelta = {
    // Assumes that primary keys are immutable and that rows are only added or
    // removed upon a refresh.
    coords: Coords;
    value_initial: Datum;
    value_updated: Datum;
  };

  type Delta = {
    cells: CellDelta[];
  };

  type LazyData = {
    rows: Row[];
    fields: FieldInfo[];
  };

  type Selection = {
    coords: Coords;
    original_value: Datum;
  };

  let selections = $state<Selection[]>([]);
  // While the datum editor is focused and while updated values are being pushed
  // to the server, other actions such as changing the set of selected cells are
  // restricted.
  let editor_value = $state<Datum | undefined>(undefined);
  let deltas = $state<{
    commit_queued: Delta[];
    commit_pending: Delta[];
    committed: Delta[];
    revert_queued: Delta[];
    revert_pending: Delta[];
    reverted: Delta[];
  }>({
    commit_queued: [],
    commit_pending: [],
    committed: [],
    revert_queued: [],
    revert_pending: [],
    reverted: [],
  });
  let datum_editor = $state<DatumEditor | undefined>();
  let focus_cursor = $state<(() => unknown) | undefined>();
  let inserter_rows = $state<Row[]>([]);
  let lazy_data = $state<LazyData | undefined>();
  let dragged_header = $state<number | undefined>();

  // -------- Helper Functions -------- //

  function range(from: number, until: number): number[] {
    const arr: number[] = [];
    for (let n = from; n < until; n += 1) {
      arr.push(n);
    }
    return arr;
  }

  function arrow_key_direction(
    key: string,
  ): "Down" | "Left" | "Right" | "Up" | undefined {
    if (key === "ArrowDown") {
      return "Down";
    } else if (key === "ArrowLeft") {
      return "Left";
    } else if (key === "ArrowRight") {
      return "Right";
    } else if (key === "ArrowUp") {
      return "Up";
    } else {
      return undefined;
    }
  }

  function update_field_ordinality({
    field_index,
    beyond_index,
  }: {
    field_index: number;
    beyond_index: number;
  }) {
    if (!lazy_data) {
      console.warn("preconditions for update_field_ordinality() not met");
      return;
    }
    let target_ordinality: number | undefined;
    const ordinality_near = lazy_data.fields[beyond_index].field.ordinality;
    if (beyond_index > field_index) {
      // Field is moving towards the end.
      const ordinality_far =
        lazy_data.fields[beyond_index + 1]?.field.ordinality;
      if (ordinality_far) {
        target_ordinality = (ordinality_near + ordinality_far) / 2;
      } else {
        target_ordinality = ordinality_near + 1;
      }
    } else if (beyond_index < field_index) {
      // Field is moving towards the start.
      const ordinality_far =
        lazy_data.fields[beyond_index - 1]?.field.ordinality;
      if (ordinality_far) {
        target_ordinality = (ordinality_near + ordinality_far) / 2;
      } else {
        // Avoid setting ordinality <= 0.
        target_ordinality = ordinality_near / 2;
      }
    } else {
      // No movement.
      return;
    }

    // Imperatively submit HTML form.
    const form = document.createElement("form");
    form.setAttribute("action", "update-field-ordinality");
    form.setAttribute("method", "post");
    form.style.display = "none";
    const field_id_input = document.createElement("input");
    field_id_input.type = "hidden";
    field_id_input.name = "field_id";
    field_id_input.value = lazy_data.fields[field_index].field.id;
    const ordinality_input = document.createElement("input");
    ordinality_input.name = "ordinality";
    ordinality_input.type = "hidden";
    ordinality_input.value = `${target_ordinality}`;
    form.appendChild(field_id_input);
    form.appendChild(ordinality_input);
    document.body.appendChild(form);
    form.submit();
  }

  // -------- Updates and Effects -------- //

  function set_selections(arr: Coords[]) {
    selections = arr.map((coords) => {
      let cell_data: Datum | undefined;
      if (coords.region === "main") {
        cell_data = lazy_data?.rows[coords.row_idx].data[coords.field_idx];
      } else if (coords.region === "inserter") {
        cell_data = inserter_rows[coords.row_idx].data[coords.field_idx];
      } else {
        throw new Error(`invalid region: ${coords.region}`);
      }
      return { coords, original_value: cell_data! };
    });
    if (arr.length === 1) {
      const [coords] = arr;
      let cell_data: Datum | undefined;
      if (coords.region === "main") {
        cell_data = lazy_data?.rows[coords.row_idx].data[coords.field_idx];
      } else if (coords.region === "inserter") {
        cell_data = inserter_rows[coords.row_idx].data[coords.field_idx];
      }
      editor_value = cell_data;
    } else {
      editor_value = undefined;
    }
  }

  function move_cursor(
    new_cursor: Coords,
    { additive }: { additive?: boolean } = {},
  ) {
    if (!lazy_data || selections.length === 0) {
      console.warn("move_selection() preconditions not met");
      return;
    }

    const first_selection = selections[selections.length - 1];
    if (
      additive &&
      first_selection !== undefined &&
      !coords_eq(new_cursor, first_selection.coords)
    ) {
      // By convention, we keep the first selected cell at the end of the
      // selections array, and the current cursor at the beginning. Everything
      // in the bounded box should be populated in between.
      set_selections([
        new_cursor,
        ...get_box(first_selection.coords, new_cursor, {
          n_fields: lazy_data.fields.length,
          n_rows_main: lazy_data.rows.length,
          n_rows_inserter: inserter_rows.length,
        }).filter(
          (sel) =>
            !coords_eq(sel, new_cursor) &&
            !coords_eq(sel, first_selection.coords),
        ),
        first_selection.coords,
      ]);
    } else {
      set_selections([new_cursor]);
    }
  }

  function try_sync_edit_to_cells() {
    if (!lazy_data || selections.length === 0) {
      console.warn("preconditions for try_sync_edit_to_cells() not met");
      return;
    }
    if (editor_value === undefined) {
      return;
    }
    for (const sel of selections) {
      if (sel.coords.region === "main") {
        lazy_data.rows[sel.coords.row_idx].data[sel.coords.field_idx] =
          editor_value;
      } else if (sel.coords.region === "inserter") {
        inserter_rows[sel.coords.row_idx].data[sel.coords.field_idx] =
          editor_value;
      } else {
        throw new Error("Unknown region");
      }
    }
  }

  function try_queue_delta() {
    // Copy `editor_value` so that it can be used intuitively within closures.
    const editor_value_scoped = editor_value;
    if (editor_value_scoped === undefined) {
      console.debug("not a valid cell value");
      cancel_edit();
    } else {
      if (selections.length > 0) {
        deltas.commit_queued = [
          ...deltas.commit_queued,
          {
            cells: selections.map((sel) => ({
              coords: sel.coords,
              value_initial: sel.original_value,
              value_updated: editor_value_scoped,
            })),
          },
        ];
        console.debug("Commit queue:", deltas.commit_queued);
        selections = selections.map((sel) => ({
          ...sel,
          original_value: editor_value_scoped,
        }));
      }
    }
  }

  async function commit_delta(delta: Delta) {
    // Copy `lazy_data` so that it can be used intuitively within closures.
    const lazy_data_scoped = lazy_data;
    if (!lazy_data_scoped) {
      console.warn("sync_delta() preconditions not met");
      return;
    }
    deltas.commit_pending = [...deltas.commit_pending, delta];
    const resp = await fetch("update-values", {
      method: "post",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        cells: delta.cells
          .filter(({ coords: { region } }) => region === "main")
          .map((cell) => ({
            pkey: JSON.parse(
              lazy_data_scoped.rows[cell.coords.row_idx].key as string,
            ),
            column: lazy_data_scoped.fields[cell.coords.field_idx].field.name,
            value: cell.value_updated,
          })),
      }),
    });
    if (resp.status >= 200 && resp.status < 300) {
      deltas.commit_pending = deltas.commit_pending.filter((x) => x !== delta);
      deltas.committed = [...deltas.committed, delta];
    } else {
      // TODO display feedback to user
      console.error(resp);
      console.error(await resp.text());
    }
  }

  function tick_delta_queue() {
    const front_of_queue: Delta | undefined = deltas.commit_queued[0];
    if (front_of_queue) {
      deltas.commit_queued = deltas.commit_queued.filter(
        (x) => x !== front_of_queue,
      );
      commit_delta(front_of_queue).catch(console.error);
    }
  }

  function cancel_edit() {
    selections.forEach(({ coords, original_value }) => {
      if (coords.region === "main") {
        if (lazy_data) {
          lazy_data.rows[coords.row_idx].data[coords.field_idx] =
            original_value;
        }
      } else if (coords.region === "inserter") {
        inserter_rows[coords.row_idx].data[coords.field_idx] = original_value;
      } else {
        throw new Error("Unknown region");
      }
    });
    // Reset editor input value
    set_selections(selections.map(({ coords }) => coords));
  }

  // -------- Event Handlers -------- //

  function handle_cell_keydown(ev: KeyboardEvent) {
    if (!lazy_data) {
      console.warn("preconditions for handle_table_keydown() not met");
      return;
    }
    const arrow_direction = arrow_key_direction(ev.key);
    if (arrow_direction) {
      ev.preventDefault();
      const field_offset =
        arrow_direction === "Right" ? 1 : arrow_direction === "Left" ? -1 : 0;
      const row_offset =
        arrow_direction === "Down" ? 1 : arrow_direction === "Up" ? -1 : 0;
      const cursor = selections[0];
      const new_cursor = offset_coords(
        cursor.coords,
        row_offset,
        field_offset,
        {
          n_fields: lazy_data.fields.length,
          n_rows_main: lazy_data.rows.length,
          n_rows_inserter: inserter_rows.length,
        },
      );
      if (ev.shiftKey) {
        move_cursor(new_cursor, { additive: true });
      } else {
        move_cursor(new_cursor);
      }
    } else if (
      !ev.altKey &&
      !ev.ctrlKey &&
      !ev.metaKey &&
      /^[a-zA-Z0-9`~!@#$%^&*()_=+[\]{}\\|;:'",<.>/?-]$/.test(ev.key)
    ) {
      const sel = selections[0];
      if (sel) {
        editor_value = get_empty_datum_for(
          lazy_data.fields[sel.coords.field_idx].field.presentation,
        );
        datum_editor?.focus();
      }
    } else if (ev.key === "Enter") {
      if (ev.shiftKey) {
        if (selections[0]?.coords.region === "main") {
          set_selections([
            {
              region: "inserter",
              row_idx: 0,
              field_idx: selections[0]?.coords.field_idx ?? 0,
            },
          ]);
        } else {
          inserter_rows = [
            ...inserter_rows,
            {
              key: inserter_rows.length,
              data: lazy_data.fields.map(({ field: { presentation } }) =>
                get_empty_datum_for(presentation),
              ),
            },
          ];
        }
      } else {
        datum_editor?.focus();
      }
    }
  }

  function handle_restore_focus() {
    focus_cursor?.();
  }

  /**
   * When the user pastes a TSV formatted string into the table, it gets
   * projected onto the selected cells, with TSV values aligned to the top-left
   * of the current selection (that is, uppermost row of any selected cell, and
   * leftmost column of any selected cell, even if these cells are distinct), and
   * repeated down and to the right to fill all selections.
   *
   * If only one cell is selected, the one copy of the full TSV is pasted into
   * the cells in the down-and/or-to-the-right directions. If the TSV overflows
   * the available columns, extra columns are truncated. If it overflows the
   * available rows, additional rows are added to the bottom of the inserter
   * region and filled as needed.
   */
  function handle_cell_paste(ev: ClipboardEvent) {
    if (!lazy_data || selections.length === 0) {
      console.warn("preconditions for handle_cell_paste() not met");
      return;
    }
    const paste_text = ev.clipboardData?.getData("text");
    if (paste_text === undefined) {
      console.warn("paste text is undefined");
      return;
    }
    const top_left: Coords = {
      region: selections.some(({ coords: { region } }) => region === "main")
        ? "main"
        : "inserter",
      field_idx: Math.min(
        ...selections.map(({ coords: { field_idx } }) => field_idx),
      ),
      row_idx: Math.min(
        ...(selections.some(({ coords: { region } }) => region === "main")
          ? selections.filter(({ coords: { region } }) => region === "main")
          : selections
        ).map(({ coords: { row_idx } }) => row_idx),
      ),
    };
    const fields = lazy_data.fields.slice(top_left.field_idx);
    const parsed_tsv: Datum[][] = paste_text.split("\n").map((line) => {
      const raw_values = line.split("\t");
      return fields.map(({ field: { presentation } }, i) =>
        parse_clipboard_value(presentation, raw_values[i] ?? ""),
      );
    });
    if (selections.length === 1) {
      const bottom_left = offset_coords(top_left, parsed_tsv.length - 1, 0, {
        n_fields: lazy_data.fields.length,
        n_rows_main: lazy_data.rows.length,
        // Ensure that result won't be clamped.
        n_rows_inserter: inserter_rows.length + parsed_tsv.length,
      });
      if (
        bottom_left.region === "inserter" &&
        bottom_left.row_idx >= inserter_rows.length
      ) {
        inserter_rows = [
          ...inserter_rows,
          // TypeScript lacks a built-in range function or operator.
          ...range(inserter_rows.length, bottom_left.row_idx + 1).map((i) => ({
            key: i,
            data: lazy_data!.fields.map(({ field: { presentation } }) =>
              get_empty_datum_for(presentation),
            ),
          })),
        ];
      }
      deltas.commit_queued = [
        ...deltas.commit_queued,
        {
          cells: parsed_tsv.flatMap((row, i) =>
            row.map((value, j) => {
              const coords = offset_coords(top_left, i, j, {
                n_fields: lazy_data!.fields.length,
                n_rows_main: lazy_data!.rows.length,
                n_rows_inserter: inserter_rows.length,
              });
              return {
                coords,
                value_initial: (coords.region === "main"
                  ? lazy_data!.rows
                  : inserter_rows)[coords.row_idx].data[coords.field_idx],
                value_updated: value,
              };
            }),
          ),
        },
      ];
      parsed_tsv.forEach((row, i) =>
        row.map((value, j) => {
          const coords = offset_coords(top_left, i, j, {
            n_fields: lazy_data!.fields.length,
            n_rows_main: lazy_data!.rows.length,
            n_rows_inserter: inserter_rows.length,
          });
          if (coords.region === "main") {
            lazy_data!.rows[coords.row_idx].data[coords.field_idx] = value;
          } else if (coords.region === "inserter") {
            inserter_rows[coords.row_idx].data[coords.field_idx] = value;
          } else {
            throw new Error("Unknown region");
          }
        }),
      );
    }
    // TODO: pasting into multiple selections
  }

  // -------- Initial API Fetch -------- //

  (async function () {
    const get_data_response_schema = z.object({
      rows: z.array(
        z.object({
          pkey: z.string(),
          data: z.array(datum_schema),
        }),
      ),
      fields: z.array(field_info_schema),
    });
    const resp = await fetch("get-data");
    const body = get_data_response_schema.parse(await resp.json());
    lazy_data = {
      fields: body.fields,
      rows: body.rows.map(({ data, pkey }) => ({ data, key: pkey })),
    };
    inserter_rows = [
      {
        key: 0,
        data: body.fields.map(({ field: { presentation } }) =>
          get_empty_datum_for(presentation),
        ),
      },
    ];
    if (lazy_data.rows.length > 0 && lazy_data.fields.length > 0) {
      set_selections([{ region: "main", row_idx: 0, field_idx: 0 }]);
    }
  })().catch(console.error);

  setInterval(tick_delta_queue, 500);
</script>

{#snippet table_region({
  region,
  rows,
  on_cell_click,
}: {
  region: "main" | "inserter";
  rows: Row[];
  on_cell_click(ev: MouseEvent, coords: Coords): void;
})}
  {#if lazy_data}
    {#each rows as row, row_idx}
      <div class="lens-table__row" role="row">
        {#each lazy_data.fields as field, field_idx}
          <TableCell
            coords={{ region, row_idx, field_idx }}
            cursor={coords_eq(selections[0].coords, {
              region,
              row_idx,
              field_idx,
            })}
            {field}
            onbecomecursor={(focus) => {
              focus_cursor = focus;
            }}
            ondblclick={() => datum_editor?.focus()}
            onkeydown={(ev) => handle_cell_keydown(ev)}
            onmousedown={on_cell_click}
            onpaste={handle_cell_paste}
            selected={selections.some(
              (sel) =>
                sel.coords.region === region &&
                coords_eq(sel.coords, { region, row_idx, field_idx }),
            )}
            table_region={region}
            value={row.data[field_idx]}
          />
        {/each}
      </div>
    {/each}
  {/if}
{/snippet}

<div class="lens-grid">
  {#if lazy_data}
    <div class="lens-table" role="grid">
      <div class={["lens-table__headers"]}>
        {#each lazy_data.fields as _, field_index}
          <FieldHeader
            bind:field={lazy_data.fields[field_index]}
            index={field_index}
            ondragstart={() => {
              dragged_header = field_index;
            }}
            ondragover={(ev) => {
              // Enable element as drop target
              ev.preventDefault();
            }}
            ondrop={(ev) => {
              ev.preventDefault();
              if (dragged_header !== undefined) {
                update_field_ordinality({
                  field_index: dragged_header,
                  beyond_index: field_index,
                });
              }
            }}
          />
        {/each}
        <div class="lens-table__header-actions">
          <FieldAdder {columns}></FieldAdder>
        </div>
      </div>
      <div class="lens-table__main">
        {@render table_region({
          region: "main",
          rows: lazy_data.rows,
          on_cell_click: (ev: MouseEvent, coords: Coords) => {
            if (ev.metaKey || ev.ctrlKey) {
              set_selections([
                coords,
                ...selections
                  .filter((sel) => !coords_eq(sel.coords, coords))
                  .map((sel) => sel.coords),
              ]);
            } else if (ev.shiftKey) {
              move_cursor(coords, { additive: true });
            } else {
              move_cursor(coords);
            }
          },
        })}
      </div>
      <form method="post" action="insert">
        <div class="lens-inserter">
          <h3 class="lens-inserter__help">
            Insert rows &mdash; press "shift + enter" to jump here or add a row
          </h3>
          <div class="lens-inserter__main">
            <div class="lens-inserter__rows">
              {@render table_region({
                region: "inserter",
                rows: inserter_rows,
                on_cell_click: (ev: MouseEvent, coords: Coords) => {
                  if (ev.metaKey || ev.ctrlKey) {
                    set_selections([
                      coords,
                      ...selections
                        .filter((sel) => !coords_eq(sel.coords, coords))
                        .map((sel) => sel.coords),
                    ]);
                  } else if (ev.shiftKey) {
                    move_cursor(coords, { additive: true });
                  } else {
                    move_cursor(coords);
                  }
                },
              })}
            </div>
            <button
              aria-label="Insert rows"
              class="lens-inserter__submit"
              onkeydown={(ev) => {
                // Prevent keypress (e.g. pressing Enter on the button to submit
                // it) from triggering a table interaction.
                ev.stopPropagation();
              }}
              title="Insert rows"
              type="submit"
            >
              <i class="ti ti-upload"></i>
            </button>
          </div>
        </div>
        {#each inserter_rows as row}
          {#each lazy_data.fields as field, field_index}
            <input
              type="hidden"
              name={field.field.name}
              value={JSON.stringify(row.data[field_index])}
            />
          {/each}
        {/each}
      </form>
    </div>
    <div class="table-viewer__datum-editor">
      {#if selections.length !== 0 && selections.every(({ coords: { field_idx } }) => field_idx === selections[0]?.coords.field_idx)}
        <DatumEditor
          bind:this={datum_editor}
          bind:value={editor_value}
          field_info={lazy_data.fields[selections[0].coords.field_idx]}
          on_blur={() => try_queue_delta()}
          on_cancel_edit={cancel_edit}
          on_change={() => {
            try_sync_edit_to_cells();
          }}
          on_restore_focus={handle_restore_focus}
        />
      {/if}
    </div>
  {/if}
</div>
