<svelte:options customElement={{ props: {} }} />

<script lang="ts">
  import {defaultKeymap, history, historyKeymap} from '@codemirror/commands';
  import {Compartment, EditorState, type Extension} from '@codemirror/state';
  import {
    Decoration,
    EditorView,
    keymap,
    placeholder as cmPlaceholder,
    ViewPlugin,
    type DecorationSet,
    type ViewUpdate,
    WidgetType
  } from '@codemirror/view';
  import {onMount} from 'svelte';
  import {Button, FloatingTrigger} from '../base';
  import type {Parameter} from '#types';
  import {
    findBackspaceTokenRange,
    findTokenRangeAtCursor,
    flattenParameterCandidates,
    getTokenRanges,
  } from '../utils/paramToken';

  const {
    mode = 'textarea',
    value = '',
    parameters = [],
    rows = 3,
    placeholder = '',
    disabled = false,
    class: className = '',
    style = '',
    ...rest
  }: {
    mode?: 'input' | 'textarea';
    value?: string;
    parameters?: Parameter[];
    rows?: number;
    placeholder?: string;
    disabled?: boolean;
    class?: string;
    style?: string;
    [key: string]: any;
  } = $props();

  type TokenVariant = 'valid' | 'warning' | 'invalid';
  type TokenMeta = {
    displayName: string;
    variant: TokenVariant;
  };

  const editableCompartment = new Compartment();
  const eventCompartment = new Compartment();
  const keymapCompartment = new Compartment();
  const placeholderCompartment = new Compartment();
  const lineModeCompartment = new Compartment();
  const tokenCompartment = new Compartment();
  const themeCompartment = new Compartment();

  let editorContainerEl = $state<HTMLDivElement | null>(null);
  let editorHostEl = $state<HTMLDivElement | null>(null);
  let editorView = $state<EditorView | null>(null);
  let triggerObject: any;
  let applyingExternalValue = $state(false);
  let currentValue = $state('');

  const isSingleLine = $derived(mode === 'input');
  const minRows = $derived(isSingleLine ? 1 : Math.max(1, rows || 1));
  const editorMinHeight = $derived(isSingleLine ? '32px' : `${minRows * 22 + 10}px`);
  const hostStyle = $derived(`${style};--param-token-editor-min-height:${editorMinHeight};`);

  const paramCandidates = $derived(flattenParameterCandidates(parameters));
  const pickerCandidates = $derived(paramCandidates.filter((item) => !item.disconnected));
  const hasParams = $derived(pickerCandidates.length > 0);
  const paramNames = $derived(paramCandidates.map((item) => item.name));
  const tokenMetaMap = $derived.by(() => {
    const map = new Map<string, TokenMeta>();
    for (const candidate of paramCandidates) {
      map.set(candidate.name, {
        displayName: candidate.displayName || candidate.name,
        variant: candidate.disconnected ? 'invalid' : candidate.resolved ? 'valid' : 'warning'
      });
    }
    return map;
  });

  function getDisconnectedLabel(tokenKey: string, displayName?: string) {
    const label = (displayName || '').trim();
    if (label) {
      return `${label}（已断开连接）`;
    }
    const normalizedKey = tokenKey.trim();
    if (!normalizedKey) {
      return '参数（已断开连接）';
    }
    const segments = normalizedKey.split('.');
    const parameterName = segments[segments.length - 1] || normalizedKey;
    return `${parameterName}（已断开连接）`;
  }

  class ParamTokenWidget extends WidgetType {
    constructor(
      private readonly from: number,
      private readonly to: number,
      private readonly displayName: string,
      private readonly variant: TokenVariant
    ) {
      super();
    }

    eq(other: ParamTokenWidget) {
      return (
        this.from === other.from &&
        this.to === other.to &&
        this.displayName === other.displayName &&
        this.variant === other.variant
      );
    }

    toDOM(view: EditorView) {
      const chip = document.createElement('span');
      chip.className = `param-token-chip param-token-${this.variant}`;
      chip.setAttribute('contenteditable', 'false');

      const text = document.createElement('span');
      text.className = 'param-token-chip-text';
      text.textContent = this.displayName;
      chip.appendChild(text);

      chip.addEventListener('mousedown', (event) => {
        if (event.button !== 0) {
          return;
        }
        event.preventDefault();
        const rect = chip.getBoundingClientRect();
        const placeAfter = event.clientX - rect.left >= rect.width / 2;
        view.dispatch({
          selection: {
            anchor: placeAfter ? this.to : this.from
          },
          scrollIntoView: true
        });
        view.focus();
      });

      return chip;
    }

    ignoreEvent(event: Event) {
      return event.type === 'mousedown' || event.type === 'click';
    }
  }

  function emitInput(nextValue: string) {
    rest.oninput?.({
      target: {
        value: nextValue
      }
    });
  }

  function emitChange(nextValue: string) {
    rest.onchange?.({
      target: {
        value: nextValue
      }
    });
  }

  function createTokenDecorations(metaMap: Map<string, TokenMeta>): Extension {
    const buildDecorations = (source: string) => {
      const decorations = [];
      for (const range of getTokenRanges(source)) {
        const tokenMeta = metaMap.get(range.key);
        const displayName =
          !tokenMeta || tokenMeta.variant === 'invalid'
            ? getDisconnectedLabel(range.key, tokenMeta?.displayName)
            : tokenMeta?.displayName || range.key;
        decorations.push(
          Decoration.replace({
            widget: new ParamTokenWidget(
              range.start,
              range.end,
              displayName,
              tokenMeta?.variant || 'invalid'
            )
          }).range(range.start, range.end)
        );
      }
      return Decoration.set(decorations, true);
    };

    const tokenPlugin = ViewPlugin.fromClass(
      class {
        decorations: DecorationSet;

        constructor(view: EditorView) {
          this.decorations = buildDecorations(view.state.doc.toString());
        }

        update(update: ViewUpdate) {
          if (update.docChanged || update.viewportChanged) {
            this.decorations = buildDecorations(update.state.doc.toString());
          }
        }
      },
      {
        decorations: (value) => value.decorations
      }
    );

    return [
      tokenPlugin,
      EditorView.atomicRanges.of((view) => view.plugin(tokenPlugin)?.decorations || Decoration.none)
    ];
  }

  function createEditorTheme(singleLine: boolean) {
    return EditorView.theme({
      '&': {
        width: '100%',
        minHeight: 'var(--param-token-editor-min-height)',
        color: 'var(--tf-text-primary)',
        background: 'var(--tf-bg-input)',
        border: '1px solid var(--tf-border-color-strong)',
        borderRadius: '5px',
        fontFamily: 'inherit',
        fontSize: 'inherit'
      },
      '&.cm-focused': {
        outline: 'none',
        borderColor: 'var(--tf-primary-color)',
        boxShadow: 'var(--tf-focus-shadow)'
      },
      '&.cm-editor.cm-disabled': {
        color: 'var(--tf-text-disabled)',
        backgroundColor: 'var(--tf-bg-input-disabled)'
      },
      '.cm-scroller': {
        minHeight: 'var(--param-token-editor-min-height)',
        overflowX: singleLine ? 'auto' : 'hidden',
        overflowY: singleLine ? 'hidden' : 'auto'
      },
      '.cm-content': {
        minHeight: 'calc(var(--param-token-editor-min-height) - 2px)',
        padding: '5px 36px 5px 8px',
        caretColor: 'var(--tf-text-primary)',
        fontFamily: 'inherit',
        fontSize: 'inherit',
        fontWeight: 'inherit',
        lineHeight: '22px',
        whiteSpace: singleLine ? 'pre' : 'pre-wrap',
        overflowWrap: singleLine ? 'normal' : 'break-word'
      },
      '.cm-line': {
        padding: '0'
      },
      '.cm-placeholder': {
        fontSize: '12px',
        color: 'var(--tf-text-muted)'
      },
      '.cm-cursor, .cm-dropCursor': {
        borderLeftColor: 'var(--tf-text-primary)'
      },
      '.cm-selectionBackground': {
        backgroundColor: 'var(--tf-primary-ghost-bg)'
      }
    });
  }

  function createLineModeExtension(singleLine: boolean): Extension {
    return singleLine ? [] : EditorView.lineWrapping;
  }

  function insertTextAtSelection(text: string) {
    if (!editorView) {
      return;
    }
    const selection = editorView.state.selection.main;
    const from = selection.from;
    const to = selection.to;
    editorView.dispatch({
      changes: {
        from,
        to,
        insert: text
      },
      selection: {
        anchor: from + text.length
      },
      scrollIntoView: true
    });
    editorView.focus();
  }

  function insertParam(paramName: string) {
    if (!editorView || disabled) {
      return;
    }
    insertTextAtSelection(`{{${paramName}}}`);
    emitChange(editorView.state.doc.toString());
    triggerObject?.hide?.();
  }

  function moveCursorAcrossToken(direction: 'left' | 'right') {
    return (view: EditorView) => {
      const selection = view.state.selection.main;
      if (!selection.empty) {
        return false;
      }

      const cursor = selection.head;
      const source = view.state.doc.toString();
      const range =
        direction === 'left'
          ? findTokenRangeAtCursor(source, cursor, {
              includeStart: false,
              includeEnd: true
            })
          : findTokenRangeAtCursor(source, cursor, {
              includeStart: true,
              includeEnd: false
            });

      if (!range) {
        return false;
      }

      const anchor = direction === 'left' ? range.start : range.end;
      if (anchor === cursor) {
        return false;
      }

      view.dispatch({
        selection: {anchor},
        scrollIntoView: true
      });
      return true;
    };
  }

  function deleteTokenBackward(view: EditorView) {
    const selection = view.state.selection.main;
    if (!selection.empty) {
      return false;
    }

    const range = findBackspaceTokenRange(view.state.doc.toString(), selection.head);
    if (!range) {
      return false;
    }

    view.dispatch({
      changes: {
        from: range.start,
        to: range.end,
        insert: ''
      },
      selection: {
        anchor: range.start
      },
      scrollIntoView: true
    });
    return true;
  }

  function deleteTokenForward(view: EditorView) {
    const selection = view.state.selection.main;
    if (!selection.empty) {
      return false;
    }

    const range = findTokenRangeAtCursor(view.state.doc.toString(), selection.head, {
      includeStart: true,
      includeEnd: false
    });
    if (!range) {
      return false;
    }

    view.dispatch({
      changes: {
        from: range.start,
        to: range.end,
        insert: ''
      },
      selection: {
        anchor: range.start
      },
      scrollIntoView: true
    });
    return true;
  }

  function sanitizeSingleLineText(text: string) {
    return text.replace(/\r\n|\r|\n/g, ' ');
  }

  function createEditorKeymap(singleLine: boolean) {
    const entries = [
      {
        key: 'ArrowLeft',
        run: moveCursorAcrossToken('left')
      },
      {
        key: 'ArrowRight',
        run: moveCursorAcrossToken('right')
      },
      {
        key: 'Backspace',
        run: deleteTokenBackward
      },
      {
        key: 'Delete',
        run: deleteTokenForward
      },
      ...defaultKeymap,
      ...historyKeymap
    ];

    if (singleLine) {
      entries.unshift(
        {
          key: 'Shift-Enter',
          run: () => true
        },
        {
          key: 'Enter',
          run: () => true
        }
      );
    }

    return keymap.of(entries);
  }

  function createDomEventHandlers(singleLine: boolean): Extension {
    return EditorView.domEventHandlers({
      focus: (event) => {
        rest.onfocus?.(event);
        return false;
      },
      blur: (event, view) => {
        emitChange(view.state.doc.toString());
        rest.onblur?.(event);
        return false;
      },
      click: (event) => {
        rest.onclick?.(event);
        return false;
      },
      mousedown: (event) => {
        rest.onmousedown?.(event);
        return false;
      },
      mouseup: (event) => {
        rest.onmouseup?.(event);
        return false;
      },
      keydown: (event) => {
        rest.onkeydown?.(event);
        return false;
      },
      keyup: (event) => {
        rest.onkeyup?.(event);
        return false;
      },
      compositionstart: (event) => {
        rest.oncompositionstart?.(event);
        return false;
      },
      compositionend: (event) => {
        rest.oncompositionend?.(event);
        return false;
      },
      paste: (event, editor) => {
        rest.onpaste?.(event);
        if (!singleLine) {
          return false;
        }
        const plainText = event.clipboardData?.getData('text/plain');
        if (!plainText || !/[\r\n]/.test(plainText)) {
          return false;
        }
        event.preventDefault();
        const selection = editor.state.selection.main;
        const insertText = sanitizeSingleLineText(plainText);
        editor.dispatch({
          changes: {
            from: selection.from,
            to: selection.to,
            insert: insertText
          },
          selection: {
            anchor: selection.from + insertText.length
          },
          scrollIntoView: true
        });
        return true;
      }
    });
  }

  onMount(() => {
    if (!editorContainerEl) {
      return;
    }

    editorView = new EditorView({
      parent: editorContainerEl,
      state: EditorState.create({
        doc: currentValue,
        extensions: [
          history(),
          EditorView.contentAttributes.of({
            spellcheck: 'false'
          }),
          EditorView.updateListener.of((update) => {
            if (!update.docChanged) {
              return;
            }
            const nextValue = update.state.doc.toString();
            currentValue = nextValue;
            if (!applyingExternalValue) {
              emitInput(nextValue);
            }
          }),
          eventCompartment.of(createDomEventHandlers(isSingleLine)),
          keymapCompartment.of(createEditorKeymap(isSingleLine)),
          editableCompartment.of([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]),
          placeholderCompartment.of(placeholder ? cmPlaceholder(placeholder) : []),
          lineModeCompartment.of(createLineModeExtension(isSingleLine)),
          tokenCompartment.of(createTokenDecorations(tokenMetaMap)),
          themeCompartment.of(createEditorTheme(isSingleLine))
        ]
      })
    });

    if (editorHostEl) {
      (editorHostEl as any).__paramTokenEditorView = editorView;
    }
    editorView.dom.classList.toggle('cm-disabled', disabled);

    return () => {
      if (editorHostEl && (editorHostEl as any).__paramTokenEditorView === editorView) {
        delete (editorHostEl as any).__paramTokenEditorView;
      }
      editorView?.destroy();
      editorView = null;
    };
  });

  $effect(() => {
    if (!editorView) {
      return;
    }
    editorView.dispatch({
      effects: editableCompartment.reconfigure([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)])
    });
    editorView.dom.classList.toggle('cm-disabled', disabled);
  });

  $effect(() => {
    if (!editorView) {
      return;
    }
    editorView.dispatch({
      effects: placeholderCompartment.reconfigure(placeholder ? cmPlaceholder(placeholder) : [])
    });
  });

  $effect(() => {
    if (!editorView) {
      return;
    }
    editorView.dispatch({
      effects: eventCompartment.reconfigure(createDomEventHandlers(isSingleLine))
    });
  });

  $effect(() => {
    if (!editorView) {
      return;
    }
    editorView.dispatch({
      effects: keymapCompartment.reconfigure(createEditorKeymap(isSingleLine))
    });
  });

  $effect(() => {
    if (!editorView) {
      return;
    }
    editorView.dispatch({
      effects: lineModeCompartment.reconfigure(createLineModeExtension(isSingleLine))
    });
  });

  $effect(() => {
    if (!editorView) {
      return;
    }
    editorView.dispatch({
      effects: tokenCompartment.reconfigure(createTokenDecorations(tokenMetaMap))
    });
  });

  $effect(() => {
    if (!editorView) {
      return;
    }
    editorView.dispatch({
      effects: themeCompartment.reconfigure(createEditorTheme(isSingleLine))
    });
  });

  $effect(() => {
    const nextValue = (value || '') as string;
    currentValue = nextValue;
    if (!editorView) {
      return;
    }
    const currentDoc = editorView.state.doc.toString();
    if (nextValue === currentDoc) {
      return;
    }
    applyingExternalValue = true;
    editorView.dispatch({
      changes: {
        from: 0,
        to: currentDoc.length,
        insert: nextValue
      }
    });
    applyingExternalValue = false;
  });
</script>

<div class="param-token-editor {className}">
  <div class="param-token-editor-shell" bind:this={editorHostEl} style={hostStyle}>
    <div class="param-token-editor-codemirror" bind:this={editorContainerEl}></div>

    <div class="param-token-action">
      <FloatingTrigger placement="bottom-end" bind:this={triggerObject}>
        <Button
          class="param-token-button"
          disabled={!hasParams || disabled}
          title={hasParams ? '选择变量' : '当前没有可引用的上游变量'}
        >
          <span>{"{x}"}</span>
        </Button>
        {#snippet floating()}
          <div class="param-token-panel nowheel">
            {#if hasParams}
              {#each pickerCandidates as candidate}
                <button
                  class="param-token-item {candidate.resolved ? '' : 'unresolved'}"
                  title={candidate.resolved ? candidate.displayName || candidate.name : `${candidate.displayName || candidate.name}（未配置引用值）`}
                  onclick={() => {
                    insertParam(candidate.name);
                  }}
                >
                  <span class="param-token-item-main">
                    <span class="param-token-item-label">{candidate.displayName || candidate.name}</span>
                  </span>
                  {#if !candidate.resolved}
                    <span
                      class="param-token-item-warn"
                      title="该参数尚未映射引用值，运行时可能为空"
                      aria-label="未映射参数"
                    >
                      <svg viewBox="0 0 24 24" fill="currentColor">
                        <path d="M12 2 1 21h22L12 2zm0 5.2 6.4 11.8H5.6L12 7.2zm-1 3.3v4.6h2v-4.6h-2zm0 5.8v1.9h2v-1.9h-2z"></path>
                      </svg>
                    </span>
                  {/if}
                </button>
              {/each}
            {:else}
              <div class="param-token-empty">当前没有可引用的上游变量</div>
            {/if}
          </div>
        {/snippet}
      </FloatingTrigger>
    </div>
  </div>
</div>

<style lang="less">
  .param-token-editor {
    width: 100%;
    position: relative;
  }

  .param-token-editor-shell {
    width: 100%;
    position: relative;
    font-family: inherit;
    font-size: inherit;
    font-weight: inherit;
    line-height: inherit;
    letter-spacing: inherit;
  }

  .param-token-editor-codemirror {
    width: 100%;
  }

  .param-token-action {
    position: absolute;
    top: 4px;
    right: 4px;
    z-index: 2;
  }

  :global(.param-token-button) {
    height: 20px;
    min-width: 20px;
    padding: 0 4px;
    font-size: 10px;
    color: var(--tf-text-secondary);
    background: var(--tf-bg-surface);
    border: 1px solid var(--tf-border-color);
    border-radius: 4px;
  }

  :global(.param-token-button:hover) {
    color: var(--tf-primary-color);
    background: var(--tf-primary-soft-bg);
    border-color: var(--tf-primary-soft-border);
  }

  .param-token-panel {
    min-width: 200px;
    max-width: 320px;
    max-height: 220px;
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 6px;
    overflow: auto;
    background: var(--tf-bg-surface);
    border: 1px solid var(--tf-border-color-strong);
    border-radius: 5px;
    box-shadow: var(--tf-shadow-medium);
  }

  .param-token-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 6px 8px;
    font-size: 12px;
    line-height: 1.3;
    color: var(--tf-text-primary);
    text-align: left;
    cursor: pointer;
    background: var(--tf-bg-surface);
    border: none;
    border-radius: 4px;
  }

  .param-token-item-main {
    display: flex;
    min-width: 0;
    align-items: center;
  }

  .param-token-item-label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .param-token-item:hover {
    color: var(--tf-primary-color);
    background: var(--tf-bg-surface-alt);
  }

  .param-token-item.unresolved {
    color: var(--tf-warning-soft-text);
    background: var(--tf-warning-soft-bg);
  }

  .param-token-item-warn {
    width: 14px;
    height: 14px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--tf-warning-icon-color);
    flex-shrink: 0;

    svg {
      width: 14px;
      height: 14px;
      display: block;
    }
  }

  .param-token-empty {
    padding: 8px;
    font-size: 12px;
    color: var(--tf-text-muted);
    text-align: center;
  }

  :global(.param-token-chip) {
    display: inline-flex;
    align-items: center;
    max-width: 100%;
    vertical-align: middle;
    cursor: text;
    user-select: none;
  }

  :global(.param-token-chip-text) {
    display: inline-flex;
    align-items: center;
    min-width: 0;
    min-height: 20px;
    padding: 0 8px;
    overflow: hidden;
    font-size: 12px;
    line-height: 20px;
    text-overflow: ellipsis;
    white-space: nowrap;
    border-radius: 6px;
  }

  :global(.param-token-valid .param-token-chip-text) {
    color: var(--tf-primary-color);
    background: var(--tf-primary-soft-bg);
    box-shadow: inset 0 0 0 1px var(--tf-primary-soft-border);
  }

  :global(.param-token-warning .param-token-chip-text) {
    color: var(--tf-warning-soft-text);
    background: var(--tf-warning-soft-bg);
    box-shadow: inset 0 0 0 1px var(--tf-warning-soft-border);
  }

  :global(.param-token-invalid .param-token-chip-text) {
    color: var(--tf-danger-soft-text);
    background: var(--tf-danger-soft-bg);
    box-shadow: inset 0 0 0 1px var(--tf-danger-soft-border);
  }
</style>
