<script lang="ts">
  import { debounce } from "lodash";
  import { createEventDispatcher } from "svelte";
  import type { UpListing } from "upend";
  import type { IValue, VALUE_TYPE } from "upend/types";
  import { baseSearchOnce, createLabelled } from "../../util/search";
  import UpObject from "../display/UpObject.svelte";
  import IconButton from "./IconButton.svelte";
  import Input from "./Input.svelte";
  const dispatch = createEventDispatcher();
  import { matchSorter } from "match-sorter";
  import api from "../../lib/api";

  const MAX_OPTIONS = 25;

  export let type: "attribute" | "value";
  export let attributeOptions: string[] | undefined = undefined;
  export let valueTypes: VALUE_TYPE[] | undefined = undefined;
  export let placeholder = "";
  export let disabled = false;

  export let attribute: string | undefined = undefined;
  export let value: IValue | undefined = undefined;

  if (type == "attribute") {
    value = {
      c: attribute,
      t: "String",
    };
  }
  let inputValue = String(value?.c || "");
  $: if (value === undefined) inputValue = undefined;

  function onInput(ev: CustomEvent<string>) {
    if (type == "attribute") {
      attribute = ev.detail;
    } else {
      value = {
        t: "String",
        c: ev.detail,
      };
    }
  }

  interface SelectorOption {
    attribute?: {
      labels: string[];
      name: string;
    };
    value?: IValue;
    labelToCreate?: string;
  }

  let options: SelectorOption[] = [];
  let searchResult: UpListing;
  const updateOptions = debounce(async (query: string, doSearch: boolean) => {
    switch (type) {
      case "attribute": {
        const allAttributes = await api.fetchAllAttributes();
        const attributes = attributeOptions
          ? allAttributes.filter((attr) => attributeOptions.includes(attr.name))
          : allAttributes;
        options = attributes
          .filter(
            (attr) =>
              attr.name.toLowerCase().includes(query.toLowerCase()) ||
              attr.labels.some((label) =>
                label.toLowerCase().includes(query.toLowerCase())
              )
          )
          .map((attribute) => {
            return {
              attribute,
            };
          });

        const attributeToCreate = inputValue
          .toUpperCase()
          .replaceAll(/[^A-Z0-9]/g, "_");
        if (
          !attributeOptions &&
          inputValue &&
          !allAttributes.map((attr) => attr.name).includes(attributeToCreate)
        ) {
          options.push({
            attribute: {
              labels: [],
              name: attributeToCreate,
            },
            labelToCreate: inputValue,
          });
        }
        options = options;
        break;
      }
      case "value": {
        options = [];

        if (query.length == 0) {
          return;
        }

        if (valueTypes === undefined || valueTypes.includes("Number")) {
          const number = parseFloat(query);
          if (!Number.isNaN(number)) {
            options.push({
              value: {
                t: "Number",
                c: number,
              },
            });
          }
        }

        if (valueTypes === undefined || valueTypes.includes("String")) {
          options.push({
            value: {
              t: "String",
              c: query,
            },
          });
        }

        if (valueTypes === undefined || valueTypes.includes("Address")) {
          if (doSearch) {
            searchResult = await baseSearchOnce(query);
          }

          let exactHits = Object.entries(addressToLabels)
            .filter(([_, labels]) =>
              labels
                .map((l) => l.toLowerCase())
                .includes(inputValue.toLowerCase())
            )
            .map(([addr, _]) => addr);

          if (exactHits.length) {
            exactHits.forEach((addr) =>
              options.push({
                value: {
                  t: "Address",
                  c: addr,
                },
              })
            );
          } else {
            options.push({
              labelToCreate: inputValue,
            });
          }

          const validOptions = searchResult.entries
            .filter((e) => e.attribute === "LBL")
            .filter((e) => !exactHits.includes(e.entity));

          const sortedOptions = matchSorter(validOptions, inputValue, {
            keys: ["value.c"],
          });

          options.push(
            ...sortedOptions.map((e) => {
              return {
                value: {
                  t: "Address",
                  c: e.entity,
                },
              } as SelectorOption;
            })
          );
        }

        options = options;
        break;
      }
    }
  }, 200);

  $: {
    if (inputFocused) {
      updateOptions(inputValue, true);
      addressToLabels = {};
    }
  }

  let addressToLabels: { [key: string]: string[] } = {};
  function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
    addressToLabels[address] = ev.detail;
    updateOptions(inputValue, false);
  }

  async function set(option: SelectorOption) {
    switch (type) {
      case "attribute":
        if (option.labelToCreate) {
          // First, "create" attribute, getting its address.
          const [_, address] = await api.putEntry({
            entity: { t: "Attribute", c: option.attribute.name },
          });
          // Second, label it.
          await api.putEntityAttribute(address, "LBL", {
            t: "String",
            c: option.labelToCreate,
          });
        }
        attribute = option.attribute.name;
        inputValue = option.attribute.name;
        break;
      case "value":
        if (!option.labelToCreate) {
          value = option.value;
          inputValue = String(option.value.c);
        } else {
          const addr = await createLabelled(option.labelToCreate);
          value = {
            t: "Address",
            c: addr,
          };
          inputValue = addr;
        }
        break;
    }
    dispatch("input", value);
    options = [];
    optionFocusIndex = -1;
    hover = false;
  }

  let listEl: HTMLUListElement;
  let optionFocusIndex = -1;
  function handleArrowKeys(ev: KeyboardEvent) {
    if (!options.length) {
      return;
    }

    const optionEls = Array.from(listEl.children) as HTMLLIElement[];
    const currentIndex = optionEls.findIndex(
      (el) => document.activeElement === el
    );

    let targetIndex = currentIndex;
    switch (ev.key) {
      case "ArrowDown":
        targetIndex += 1;

        // pressed down on last
        if (targetIndex >= optionEls.length) {
          targetIndex = 0;
        }
        break;
      case "ArrowUp":
        targetIndex -= 1;

        // pressed up on input
        if (targetIndex == -2) {
          targetIndex = optionEls.length - 1;
        }

        // pressed up on first
        if (targetIndex == -1) {
          focus();
          return;
        }
        break;
      default:
        return; // early return, stop processing
    }

    if (optionEls[targetIndex]) {
      optionEls[targetIndex].focus();
    }
  }

  let input: Input;
  export function focus() {
    input.focus();
  }

  let inputFocused = false;
  let hover = false; // otherwise clicking makes options disappear faster than it can emit a set
  $: visible =
    (inputFocused || hover || optionFocusIndex > -1) && Boolean(options.length);
  $: dispatch("focus", inputFocused || hover || optionFocusIndex > -1);
</script>

<div class="selector">
  {#if value?.t == "Address" && inputValue}
    <div class="input">
      <div class="label">
        <UpObject link address={String(value.c)} />
      </div>
      <IconButton name="x" on:click={() => (inputValue = "")} />
    </div>
  {:else}
    <Input
      bind:this={input}
      bind:value={inputValue}
      on:input={onInput}
      on:focusChange={(ev) => (inputFocused = ev.detail)}
      on:keydown={handleArrowKeys}
      {disabled}
      {placeholder}
    />
  {/if}
  <ul
    class="options"
    class:visible
    on:mouseenter={() => (hover = true)}
    on:mouseleave={() => (hover = false)}
    bind:this={listEl}
  >
    {#each options.slice(0, MAX_OPTIONS) as option, idx}
      <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
      <li
        tabindex="0"
        on:click={() => set(option)}
        on:mousemove={() => focus()}
        on:focus={() => (optionFocusIndex = idx)}
        on:blur={() => (optionFocusIndex = -1)}
        on:keydown={(ev) => {
          if (ev.key === "Enter") {
            set(option);
          } else {
            handleArrowKeys(ev);
          }
        }}
      >
        {#if option.attribute}
          {#if option.labelToCreate}
            <div class="content">{option.labelToCreate}</div>
            <div class="type">Create attribute ({option.attribute.name})</div>
          {:else if option.attribute.labels.length}
            <div class="content">
              {#each option.attribute.labels as label}
                <div class="label">{label}</div>
              {/each}
            </div>
            <div class="type">{option.attribute.name}</div>
          {:else}
            <div class="content">
              {option.attribute.name}
            </div>
          {/if}
        {:else if option.value}
          {#if option.value.t == "Address"}
            <UpObject
              address={String(option.value.c)}
              on:resolved={(ev) =>
                onAddressResolved(String(option.value.c), ev)}
            />
          {:else}
            <div class="type">{option.value.t}</div>
            <div class="content">{option.value.c}</div>
          {/if}
        {:else if option.labelToCreate}
          <div class="type">Create object</div>
          <div class="content">{option.labelToCreate}</div>
        {/if}
      </li>
    {/each}
  </ul>
</div>

<style lang="scss">
  .selector {
    position: relative;
  }

  .input {
    display: flex;
    min-width: 0;
    .label {
      flex: 1;
      min-width: 0;
    }
  }

  .options {
    position: absolute;
    list-style: none;
    margin: 0;
    padding: 0;
    border: 1px solid var(--foreground-lighter);
    width: 100%;
    border-radius: 4px;
    margin-top: 2px;
    background: var(--background);
    font-size: smaller;

    visibility: hidden;
    opacity: 0;

    transition: opacity 0.2s;

    z-index: 99;

    &.visible {
      visibility: visible;
      opacity: 1;
    }

    li {
      cursor: pointer;
      padding: 0.5em;

      transition: background-color 0.1s;
      &:hover {
        background-color: var(--background-lighter);
      }

      &:focus {
        background-color: var(--background-lighter);
        outline: none;
      }

      .type,
      .content {
        display: inline-block;
      }

      .type {
        opacity: 0.8;
        font-size: smaller;
      }

      .label {
        display: inline-block;
      }
    }
  }
</style>
