<!--
@component
Hub of the `<TableViewer>` component, which keeps track of the data in each
table region as well as selection state, and coordinates data flow between the
commit queue, datum editor, and field headers.
-->

<script lang="ts">
  import { AsyncTaskQueue, withRetry } from "attq";

  import { type Datum, parse_datum_from_text } from "../datum.svelte";
  import DatumEditor from "../datum-editor.svelte";
  import { BLUR_DEBOUNCE_MS } from "../datum-editor-common.svelte";
  import { type Row, type FieldInfo } from "../field.svelte";
  import { get_empty_datum_for } from "../presentation.svelte";
  import {
    type Coords,
    coords_eq,
    get_box,
    offset_coords,
  } from "./coords.svelte";
  import FieldAdder from "./field-adder.svelte";
  import FieldHeader from "./field-header.svelte";
  import TableCell from "./table-cell.svelte";
  import { invert_diff, type Undoable, UndoStack } from "./undo-stack.svelte";

  type Props = {
    columns: {
      name: string;
      regtype: string;
    }[];
    fields: FieldInfo[];
    rows_main: Row[];
    subfilter_active: boolean;
    total_count: number;
  };

  let {
    columns = [],
    fields = $bindable([]),
    rows_main = $bindable([]),
    subfilter_active,
    total_count,
  }: Props = $props();

  type Delta = Undoable<Coords, Datum>;

  let selections = $state<Delta[]>([]);
  let editor_value = $state<Datum | undefined>(undefined);
  let datum_editor = $state<DatumEditor | undefined>();
  let focus_cursor = $state<(() => unknown) | undefined>();
  let rows_inserter = $state<Row[]>([
    {
      key: 0,
      data: fields.map(({ field: { presentation } }) =>
        get_empty_datum_for(presentation),
      ),
    },
  ]);
  let dragged_header = $state<number | undefined>();

  // This is state, but it doesn't need to be reactive (that is, `$state`).
  const upload_queue = new AsyncTaskQueue<Delta[]>(
    withRetry(async ([batch]) => {
      const cells = batch
        .filter(({ loc: { region } }) => region === "main")
        .map(({ loc: { field_idx, row_idx }, value_updated }) => ({
          pkey: JSON.parse(rows_main[row_idx].key as string),
          column: fields[field_idx].field.name,
          value: value_updated,
        }));
      if (cells.length > 0) {
        const resp = await fetch("update-values", {
          method: "post",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ cells }),
        });
        if (resp.status > 299) {
          // Throw so that retry logic is triggered. Even if the error code is a
          // 4xx, this could be a temporary error, so still retry.
          try {
            throw new Error(`API error (${resp.status}): ${await resp.text()}`);
          } catch {
            throw new Error(`API error (${resp.status})`);
          }
        }
      }
    }),
  );
  // The undo stack facilitates most changes to the table data as displayed in
  // the UI and as replayed to the server side.
  const undo_stack = new UndoStack<Delta>({
    apply_diff: (diff) => {
      set_cell_values(diff);
      upload_queue.push(diff);
    },
  });

  // -------- 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;
  }) {
    let target_ordinality: number | undefined;
    const ordinality_near = fields[beyond_index].field.ordinality;
    if (beyond_index > field_index) {
      // Field is moving towards the end.
      const ordinality_far = 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 = 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 = 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();
  }

  function update_field_table_width_px(
    field_index: number,
    new_width_px: number,
  ) {
    if (fields[field_index]) {
      fields[field_index].field.table_width_px = new_width_px;
      fetch("update-field-table-width-px", {
        method: "post",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: `field_id=${encodeURIComponent(fields[field_index].field.id)}&table_width_px=${new_width_px}`,
      }).catch(console.error);
    }
  }

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

  function set_cell_values(diff: Delta[]) {
    for (const {
      loc: { region, row_idx, field_idx },
      value_updated,
    } of diff) {
      (region === "main" ? rows_main : rows_inserter)[row_idx].data[field_idx] =
        value_updated;
    }
  }

  function set_selections(arr: Coords[]) {
    selections = arr.map((coords) => {
      let cell_data: Datum | undefined;
      if (coords.region === "main") {
        cell_data = rows_main[coords.row_idx].data[coords.field_idx];
      } else if (coords.region === "inserter") {
        cell_data = rows_inserter[coords.row_idx].data[coords.field_idx];
      } else {
        throw new Error(`invalid region: ${coords.region}`);
      }
      return {
        loc: coords,
        value_initial: cell_data!,
        value_updated: cell_data!,
      };
    });
    if (arr.length === 1) {
      const [coords] = arr;
      let cell_data: Datum | undefined;
      if (coords.region === "main") {
        cell_data = rows_main[coords.row_idx].data[coords.field_idx];
      } else if (coords.region === "inserter") {
        cell_data = rows_inserter[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 (selections.length === 0) {
      console.warn("move_cursor() preconditions not met");
      return;
    }

    const first_selection = selections[selections.length - 1];
    if (
      additive &&
      first_selection !== undefined &&
      !coords_eq(new_cursor, first_selection.loc)
    ) {
      // 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.loc, new_cursor, {
          n_fields: fields.length,
          n_rows_main: rows_main.length,
          n_rows_inserter: rows_inserter.length,
        }).filter(
          (sel) =>
            !coords_eq(sel, new_cursor) && !coords_eq(sel, first_selection.loc),
        ),
        first_selection.loc,
      ]);
    } else {
      set_selections([new_cursor]);
    }
  }

  function try_sync_edit_to_cells() {
    // Copy value locally so that it can be used intuitively in closures.
    const editor_value_scoped = editor_value;
    if (editor_value_scoped !== undefined) {
      // Editor state represents a valid cell value.
      selections = selections.map((sel) => ({
        ...sel,
        value_updated: editor_value_scoped,
      }));
    }
    set_cell_values(selections);
  }

  function try_queue_delta() {
    // Copy value locally so that it can be used intuitively in closures.
    const editor_value_scoped = editor_value;
    if (editor_value_scoped === undefined) {
      return;
    }
    if (editor_value_scoped === undefined) {
      console.debug("not a valid cell value");
      cancel_edit();
    } else {
      if (selections.length > 0) {
        undo_stack.push(
          selections.map(({ loc, value_initial }) => ({
            loc,
            value_initial,
            value_updated: editor_value_scoped,
          })),
        );
        selections = selections.map((sel) => ({
          ...sel,
          original_value: editor_value_scoped,
        }));
      }
    }
  }

  function cancel_edit() {
    set_cell_values(invert_diff(selections));
    // Reset editor input value
    set_selections(selections.map(({ loc }) => loc));
  }

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

  function handle_cell_keydown(ev: KeyboardEvent) {
    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.loc, row_offset, field_offset, {
        n_fields: fields.length,
        n_rows_main: rows_main.length,
        n_rows_inserter: rows_inserter.length,
      });
      if (ev.shiftKey) {
        move_cursor(new_cursor, { additive: true });
      } else {
        move_cursor(new_cursor);
      }
    } else if (ev.metaKey && ev.key == "z") {
      if (ev.shiftKey) {
        undo_stack.redo();
      } else {
        undo_stack.undo();
      }
    } else if (
      !ev.altKey &&
      !ev.ctrlKey &&
      !ev.metaKey &&
      /^([a-zA-Z0-9`~!@#$%^&*()_=+[\]{}\\|;:'",<.>/?-]|Backspace)$/.test(ev.key)
    ) {
      const sel = selections[0];
      if (sel) {
        editor_value = get_empty_datum_for(
          fields[sel.loc.field_idx].field.presentation,
        );
        datum_editor?.focus();
        try_sync_edit_to_cells();
      }
    } else if (ev.key === "Enter") {
      if (ev.shiftKey) {
        if (selections[0]?.loc.region === "main") {
          set_selections([
            {
              region: "inserter",
              row_idx: 0,
              field_idx: selections[0]?.loc.field_idx ?? 0,
            },
          ]);
        } else {
          rows_inserter = [
            ...rows_inserter,
            {
              key: rows_inserter.length,
              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 (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(({ loc: { region } }) => region === "main")
        ? "main"
        : "inserter",
      field_idx: Math.min(
        ...selections.map(({ loc: { field_idx } }) => field_idx),
      ),
      row_idx: Math.min(
        ...(selections.some(({ loc: { region } }) => region === "main")
          ? selections.filter(({ loc: { region } }) => region === "main")
          : selections
        ).map(({ loc: { row_idx } }) => row_idx),
      ),
    };
    const fields_to_right = fields.slice(top_left.field_idx);
    const parsed_tsv: Datum[][] = paste_text.split("\n").map((line) => {
      const raw_values = line.split("\t");
      return fields_to_right.map(({ field: { presentation } }, i) =>
        parse_datum_from_text(presentation, raw_values[i] ?? ""),
      );
    });
    if (selections.length === 1) {
      const bottom_left = offset_coords(top_left, parsed_tsv.length - 1, 0, {
        n_fields: fields.length,
        n_rows_main: rows_main.length,
        // Ensure that result won't be clamped.
        n_rows_inserter: rows_inserter.length + parsed_tsv.length,
      });
      if (
        bottom_left.region === "inserter" &&
        bottom_left.row_idx >= rows_inserter.length
      ) {
        rows_inserter = [
          ...rows_inserter,
          // TypeScript lacks a built-in range function or operator.
          ...range(rows_inserter.length, bottom_left.row_idx + 1).map((i) => ({
            key: i,
            data: fields.map(({ field: { presentation } }) =>
              get_empty_datum_for(presentation),
            ),
          })),
        ];
      }
      undo_stack.push(
        parsed_tsv.flatMap((row, i) =>
          row.map((value, j) => {
            const coords = offset_coords(top_left, i, j, {
              n_fields: fields.length,
              n_rows_main: rows_main.length,
              n_rows_inserter: rows_inserter.length,
            });
            return {
              loc: coords,
              value_initial: (coords.region === "main"
                ? rows_main
                : rows_inserter)[coords.row_idx].data[coords.field_idx],
              value_updated: value,
            };
          }),
        ),
      );
    }
    // TODO: pasting into multiple selections
  }

  // Set initial selection on component creation.
  if (fields.length > 0) {
    set_selections([
      {
        region: rows_main.length > 0 ? "main" : "inserter",
        row_idx: 0,
        field_idx: 0,
      },
    ]);
  }
</script>

{#snippet table_region({
  region,
  rows,
  on_cell_click,
}: {
  region: "main" | "inserter";
  rows: Row[];
  on_cell_click(ev: MouseEvent, coords: Coords): void;
})}
  {#each rows as row, row_idx}
    <div class="table-viewer__row" role="row">
      {#each fields as field, field_idx}
        <TableCell
          coords={{ region, row_idx, field_idx }}
          cursor={selections.length !== 0 &&
            coords_eq(selections[0].loc, {
              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.loc.region === region &&
              coords_eq(sel.loc, { region, row_idx, field_idx }),
          )}
          table_region={region}
          value={row.data[field_idx]}
        />
      {/each}
    </div>
  {/each}
{/snippet}

<div class="table-viewer__table" role="grid">
  <div class="table-viewer__headers">
    {#each fields as _, field_index}
      <FieldHeader
        bind:field={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,
            });
          }
        }}
        onresize={(new_width_px) => {
          update_field_table_width_px(field_index, new_width_px);
        }}
      />
    {/each}
    <div class="table-viewer__header-actions">
      <FieldAdder {columns}></FieldAdder>
    </div>
  </div>
  <div class="table-viewer__main">
    {#if subfilter_active}
      <div class="padded padded-sm">
        <a href="?">&hellip;</a>
      </div>
    {/if}
    <div>
      {@render table_region({
        region: "main",
        rows: rows_main,
        on_cell_click: (ev: MouseEvent, coords: Coords) => {
          // Must wait out `BLUR_DEBOUNCE_MS` before switching selection to
          // avoid committing updates to the wrong cells. Refer to docs for
          // `BLUR_DEBOUNCE_MS`.
          setTimeout(() => {
            if (ev.metaKey || ev.ctrlKey) {
              set_selections([
                coords,
                ...selections
                  .filter(({ loc }) => !coords_eq(loc, coords))
                  .map(({ loc }) => loc),
              ]);
            } else if (ev.shiftKey) {
              move_cursor(coords, { additive: true });
            } else {
              move_cursor(coords);
            }
          }, BLUR_DEBOUNCE_MS + 1);
        },
      })}
    </div>
    <div class="table-viewer__count padded padded-sm">
      {total_count} records total
      {#if total_count > rows_main.length}
        ({total_count - rows_main.length} hidden; use filters to narrow your search)
      {/if}
    </div>
  </div>
  <form method="post" action="insert" class="table-viewer__inserter">
    <h3 class="table-viewer__inserter-help">
      <b>Insert rows</b> (press "shift + enter" to jump here or add a row)
    </h3>
    <div class="table-viewer__inserter-main">
      <div class="table-viewer__inserter-rows">
        {@render table_region({
          region: "inserter",
          rows: rows_inserter,
          on_cell_click: (ev: MouseEvent, coords: Coords) => {
            // Must wait out `BLUR_DEBOUNCE_MS` before switching selection
            // to avoid committing updates to the wrong cells. Refer to docs
            // for `BLUR_DEBOUNCE_MS`.
            setTimeout(() => {
              if (ev.metaKey || ev.ctrlKey) {
                set_selections([
                  coords,
                  ...selections
                    .filter(({ loc }) => !coords_eq(loc, coords))
                    .map(({ loc }) => loc),
                ]);
              } else if (ev.shiftKey) {
                move_cursor(coords, { additive: true });
              } else {
                move_cursor(coords);
              }
            }, BLUR_DEBOUNCE_MS + 1);
          },
        })}
      </div>
      <button
        aria-label="Insert rows"
        class="table-viewer__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>
    {#each rows_inserter as row}
      {#each 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="datum-editor">
  {#if selections.length !== 0 && selections.every(({ loc: { field_idx } }) => field_idx === selections[0]?.loc.field_idx)}
    <DatumEditor
      bind:this={datum_editor}
      bind:value={editor_value}
      current_presentation={fields[selections[0].loc.field_idx].field
        .presentation}
      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>
