/**
 * Non-side-effect class export for `<code-ui>`.
 *
 * Importing this file gives you the class(es) without auto-registering the tag.
 * Useful for test isolation, subclassing with tag-name override, or selective
 * composition.
 *
 * The auto-register path stays at `@adia-ai/web-components/components/code`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * <code-ui language="js">const x = 42;</code-ui>
 * <code-ui language="css" inline>color: red</code-ui>
 * <code-ui language="json" text='{ "hello": "world" }'></code-ui>
 * <code-ui editable language="json" name="schema" required></code-ui>
 *
 * Inline or block code display with optional language label and copy
 * button. The body comes from (a) declarative children, or (b) the
 * `text` attribute — the `text` path is reactive, so setting
 * `code.setAttribute('text', newText)` updates the rendered code in place.
 *
 * When `[language]` names a supported language (see SUPPORTED_LANGUAGES),
 * CodeMirror 6 is lazy-loaded and mounted in place of `<pre><code>` for
 * syntax-highlighted rendering. Phase 1 shipped read-only; Phase 2 added
 * `[editable]`; Phase 3 added lint + form-association (see
 * `docs/specs/code-editor.md §12`).
 *
 * Form participation — editable instances are Form-Associated Custom
 * Elements (FACE). Set `name`, `required`, `disabled`, `readonly` to
 * opt into standard form behavior; the editor's live document
 * becomes the form value. Non-editable instances declare
 * `formAssociated = true` at the class level (required for FACE) but
 * contribute nothing to form submission when `[editable]` is absent.
 */

import { UIElement } from '../../core/element.js';

const SUPPORTED_LANGUAGES = new Set([
  'json', 'html', 'javascript', 'js', 'css', 'markdown', 'md', 'yaml', 'yml',
]);

// Normalize common language aliases to their canonical pack name.
const LANGUAGE_ALIAS = {
  js: 'javascript',
  md: 'markdown',
  yml: 'yaml',
};

function canonicalLanguage(name) {
  if (!name) return '';
  const lower = String(name).toLowerCase();
  return LANGUAGE_ALIAS[lower] ?? lower;
}

export class UICode extends UIElement {
  static formAssociated = true;

  static properties = {
    language:     { type: String,  default: '', reflect: true },
    inline:       { type: Boolean, default: false, reflect: true },
    text:         { type: String,  default: '', reflect: true },
    // NOTE: `textContent` is intentionally NOT declared as a reactive prop.
    // `installProps()` would override the native DOM setter with a signal
    // setter, breaking `this.textContent = ''` in #stampBlock (the wipe
    // becomes a no-op, leaving the authored HTML-escaped source visible
    // above the stamped chrome and feeding an empty doc to CodeMirror).
    // Initial content is read via the native getter in #stampBlock; live
    // updates flow through the reactive `text` property. v0.5.x §327.
    lineNumbers:  { type: Boolean, default: false, reflect: true, attribute: 'line-numbers' },
    editable:     { type: Boolean, default: false, reflect: true },
    bare:         { type: Boolean, default: false, reflect: true },
    placeholder:  { type: String,  default: '', reflect: true },

    // Form-association (active only when `editable` is set; non-editable
    // instances are FACE-declared for class consistency but contribute
    // nothing to form submission).
    name:         { type: String,  default: '', reflect: true },
    required:     { type: Boolean, default: false, reflect: true },
    disabled:     { type: Boolean, default: false, reflect: true },
    readonly:     { type: Boolean, default: false, reflect: true },
  };

  static template = () => null;

  #copyBtn = null;
  #copyTimer = null;
  #cmView = null;               // EditorView instance when mounted
  #mountGen = 0;                // bumps on every connect/disconnect — guards against stale mounts
  #pendingMount = false;        // true while bundle/lang pack is loading
  #focusSnapshot = null;        // doc text captured on focus; used to diff for `change` event
  #initialDoc = '';             // captured at mount; used by formResetCallback
  #editableCompartment = null;  // CM Compartment — reconfigures readonly on disabled/readonly change

  #onCopyClick = () => this.#copy();

  connected() {
    this.#mountGen += 1;

    if (!this.inline && !this.querySelector(':scope > pre')) {
      this.#stampBlock();
    }
    this.#copyBtn = this.querySelector(':scope > header [slot="copy"]');
    if (this.#copyBtn) {
      this.#copyBtn.addEventListener('click', this.#onCopyClick);
    }

    // Mount CodeMirror when the language is supported OR the element is
    // editable (editable plain-text is still useful). Inline instances
    // stay on the static <code> path. Mount failures leave the static
    // fallback in place — it's already visible. Diff line-state mode
    // (auto via `language="diff"` or explicit `data-line-states`) also
    // stays on the static path — bg tinting lives on the wrapper spans.
    const lang = canonicalLanguage(this.language);
    const useLineState = this.hasAttribute('data-line-states') || lang === 'diff';
    const shouldMount = !this.inline && !useLineState && (SUPPORTED_LANGUAGES.has(lang) || this.editable);
    if (shouldMount) {
      this.#mountEditor();
    }
  }

  disconnected() {
    this.#mountGen += 1;     // invalidate any in-flight mounts
    if (this.#copyTimer != null) {
      clearTimeout(this.#copyTimer);
      this.#copyTimer = null;
    }
    if (this.#copyBtn) {
      this.#copyBtn.removeEventListener('click', this.#onCopyClick);
      this.#copyBtn = null;
    }
    if (this.#cmView) {
      this.#cmView.destroy();
      this.#cmView = null;
    }
  }

  #stampBlock() {
    // Content-source priority:
    //   1. `text` attribute  — reactive, programmatic.
    //   2. `<template>` child — escape-free authoring path. The HTML parser
    //      treats `<template>` content as inert (DocumentFragment, not
    //      rendered); we read `template.innerHTML` to recover the literal
    //      source. Author can write `<code-ui language="html"><template>
    //      <nav-ui>…</nav-ui></template></code-ui>` without `&lt;`/`&gt;`
    //      escaping. Indent is normalized via #dedent() so the inner block
    //      doesn't carry the wrapper's indentation. (v0.5.x §329)
    //   3. Authored textContent — original path, requires HTML entities
    //      for `<` and `>` (same constraint as `<pre><code>`).
    // The block is rebuilt from semantic elements so whitespace in source
    // HTML doesn't leak into the rendered <code>.
    const tmpl = this.querySelector(':scope > template');
    const raw = this.text
      ? this.text.trim()
      : tmpl
        ? UICode.#dedent(tmpl.innerHTML)
        : (this.textContent || '').trim();
    this.textContent = '';

    // Header — omitted in [bare] mode. Consumers that need zero chrome
    // (the A2UI Editor's Schema + DOM views, an embedded editable surface
    // in a pane, etc.) set [bare] to opt out.
    if (!this.bare) {
      const header = document.createElement('header');

      const label = document.createElement('span');
      label.setAttribute('slot', 'label');
      label.textContent = this.language || 'code';
      header.appendChild(label);

      const copyBtn = document.createElement('div');
      copyBtn.setAttribute('role', 'button');
      copyBtn.setAttribute('tabindex', '0');
      copyBtn.setAttribute('slot', 'copy');
      copyBtn.textContent = 'Copy';
      header.appendChild(copyBtn);

      this.appendChild(header);
    }

    // Pre > Code — direct semantic elements, no slot attr
    const pre = document.createElement('pre');
    const code = document.createElement('code');

    // Diff line-state mode — wrap each line so it can carry a state-driven
    // bg tint and (when [line-numbers]) a leading line-number gutter.
    // Triggered by `language="diff"` (auto-parses +/- prefix) or by an
    // explicit `data-line-states` CSV that maps positionally onto lines.
    const lang = canonicalLanguage(this.language);
    const explicit = this.getAttribute('data-line-states');
    const useLineState = explicit != null || lang === 'diff';
    if (useLineState) {
      code.setAttribute('data-line-state-mode', '');
      if (this.lineNumbers) code.setAttribute('data-line-numbers', '');
      const states = explicit?.split(',').map((s) => s.trim()) ?? null;
      const lines = raw.split('\n');
      lines.forEach((line, i) => {
        let state = 'unchanged';
        if (states && states[i]) {
          state = states[i];
        } else if (lang === 'diff') {
          const head = line.charAt(0);
          if (head === '+') state = 'added';
          else if (head === '-') state = 'removed';
        }
        const row = document.createElement('span');
        row.setAttribute('data-line-state', state);
        if (this.lineNumbers) {
          const num = document.createElement('span');
          num.setAttribute('data-line-num', '');
          num.textContent = String(i + 1);
          row.appendChild(num);
        }
        const body = document.createElement('span');
        body.setAttribute('data-line-body', '');
        body.textContent = line;
        row.appendChild(body);
        code.appendChild(row);
      });
    } else {
      code.textContent = raw;
    }
    pre.appendChild(code);

    this.appendChild(pre);
  }

  /** Normalize indentation pulled from a `<template>` child. Strips
   *  the common leading whitespace from every non-empty line so the
   *  inner block reads as if it were authored at column 0. Equivalent
   *  in spirit to Python's `textwrap.dedent` or a tagged-template
   *  `dedent` helper. (v0.5.x §329) */
  static #dedent(s) {
    const trimmed = s.replace(/^\n+|\n+$/g, '');
    if (!trimmed) return '';
    const lines = trimmed.split('\n');
    const indents = lines
      .filter((l) => l.trim().length > 0)
      .map((l) => l.match(/^[ \t]*/)[0].length);
    const min = indents.length ? Math.min(...indents) : 0;
    return min > 0 ? lines.map((l) => l.slice(min)).join('\n') : trimmed;
  }

  async #mountEditor() {
    if (this.#pendingMount || this.#cmView) return;
    const gen = this.#mountGen;
    const lang = canonicalLanguage(this.language);
    this.#pendingMount = true;

    try {
      const bundle = await import('./code-editor.js');

      // Load the language pack with a timeout. If it fails, continue with
      // core-only (editor still renders; no syntax highlight). Emit an
      // event so consumers can observe.
      let extension = null;
      try {
        const mod = await bundle.importWithTimeout(bundle.languages[lang], `lang-${lang}`);
        extension = mod?.extension ?? null;
      } catch (err) {
        this.dispatchEvent(new CustomEvent('language-load-error', {
          bubbles: true, detail: { phase: 'language', language: lang, error: err },
        }));
      }

      // Stale — component was disconnected (or reconnected elsewhere) while
      // we were loading. Discard.
      if (gen !== this.#mountGen || !this.isConnected) return;

      this.#attachEditor(bundle, extension, lang);
    } catch (err) {
      this.dispatchEvent(new CustomEvent('language-load-error', {
        bubbles: true, detail: { phase: 'core', error: err },
      }));
      // Fallback <pre><code> stays visible — nothing to revert.
      console.warn('[code-ui] CodeMirror failed to load; staying on static fallback.', err);
    } finally {
      this.#pendingMount = false;
    }
  }

  #attachEditor(bundle, languageExtension, lang) {
    const {
      EditorState, EditorView,
      Compartment,
      lineNumbers,
      placeholder,
      syntaxHighlighting,
      history, historyKeymap, defaultKeymap, indentWithTab,
      keymap,
      adiaBaseTheme, adiaHighlightStyle,
      lintGutter, jsonLinter,
    } = bundle;

    // Replace <pre><code> with a mount target. Keep <header> where it is.
    const pre = this.querySelector(':scope > pre');
    const prior = pre ? pre.querySelector('code')?.textContent ?? '' : '';
    const initial = this.text || prior || '';
    this.#initialDoc = initial;

    const mount = document.createElement('div');
    mount.setAttribute('data-cm-mount', '');
    if (pre) pre.replaceWith(mount);
    else this.appendChild(mount);

    const extensions = [
      adiaBaseTheme,
      syntaxHighlighting(adiaHighlightStyle),
    ];

    if (this.editable) {
      extensions.push(
        history(),
        keymap.of([
          { key: 'Mod-s', run: (v) => { this.#emitSave(v); return true; } },
          ...defaultKeymap,
          ...historyKeymap,
          indentWithTab,
        ]),
        EditorView.updateListener.of((update) => {
          if (!update.docChanged) return;
          const value = update.state.doc.toString();
          this.#syncFormValue(value);
          this.dispatchEvent(new CustomEvent('input', {
            bubbles: true,
            detail: { value },
          }));
        }),
        EditorView.domEventHandlers({
          focus: (_, view) => {
            this.#focusSnapshot = view.state.doc.toString();
            return false;
          },
          blur: (_, view) => {
            const current = view.state.doc.toString();
            if (this.#focusSnapshot !== null && current !== this.#focusSnapshot) {
              this.dispatchEvent(new CustomEvent('change', {
                bubbles: true,
                detail: { value: current },
              }));
            }
            this.#focusSnapshot = null;
            return false;
          },
        }),
      );
    }

    // Editable/readonly compartment — respects editable + disabled + readonly.
    // Dynamic because `disabled` / `readonly` can change at runtime (e.g.
    // `formDisabledCallback` from the owning form). Starts with the
    // correct initial config; `#updateEditableState()` reconfigures on
    // attribute change.
    this.#editableCompartment = new Compartment();
    extensions.push(
      this.#editableCompartment.of(this.#editableExtensions(EditorView, EditorState)),
    );

    if (languageExtension) extensions.push(languageExtension);
    if (this.lineNumbers) extensions.push(lineNumbers());
    if (this.placeholder) extensions.push(placeholder(this.placeholder));

    // Lint: editable-only, language-specific. JSON for now; extend per
    // language as need arises. The gutter shows the aggregate marker
    // per line; the diagnostics themselves produce the tooltip + error
    // underline on the offending range.
    if (this.editable && lang === 'json') {
      extensions.push(jsonLinter, lintGutter());
    }

    this.#cmView = new EditorView({
      parent: mount,
      state: EditorState.create({ doc: initial, extensions }),
    });

    // Seed form value + constraints now that the editor is live.
    if (this.editable) this.#syncFormValue(initial);
  }

  /** Returns the CM extensions that enforce editable vs readonly state
   *  as a function of (editable && !disabled && !readonly). Called at
   *  mount time and on any runtime change via the compartment. */
  #editableExtensions(EditorView, EditorState) {
    const canEdit = this.editable && !this.disabled && !this.readonly;
    if (canEdit) return [];
    return [
      EditorView.editable.of(false),
      EditorState.readOnly.of(true),
    ];
  }

  /** Re-evaluate the editable/readonly state against current attributes.
   *  Called from `render()` on property change. No-op if the editor
   *  hasn't mounted yet. */
  async #updateEditableState() {
    if (!this.#cmView || !this.#editableCompartment) return;
    const bundle = await import('./code-editor.js');
    this.#cmView.dispatch({
      effects: this.#editableCompartment.reconfigure(
        this.#editableExtensions(bundle.EditorView, bundle.EditorState),
      ),
    });
  }

  // ── Form-association (FACE) ───────────────────────────────────────

  get form() { return this.internals.form; }
  get labels() { return this.internals.labels; }
  get validity() { return this.internals.validity; }
  get validationMessage() { return this.internals.validationMessage; }
  get willValidate() { return this.internals.willValidate; }

  checkValidity() { return this.internals.checkValidity(); }
  reportValidity() { return this.internals.reportValidity(); }

  /** Push the current editor value to the form + re-run constraints.
   *  No-op when `editable` is false. Called on every CM doc change and
   *  from the initial mount + `render()` when `text` is driven externally. */
  #syncFormValue(val) {
    if (!this.editable) return;
    val = val ?? this.value ?? '';
    this.internals.setFormValue(val);
    this.#runConstraints(val);
  }

  #runConstraints(val) {
    val = val ?? '';
    if (this.required && !val.trim()) {
      this.internals.setValidity(
        { valueMissing: true },
        this.getAttribute('data-msg-required') || 'This field is required.',
        this,
      );
      this.setAttribute('aria-invalid', 'true');
      return false;
    }
    this.internals.setValidity({});
    this.removeAttribute('aria-invalid');
    return true;
  }

  formResetCallback() {
    if (this.#cmView) {
      this.#cmView.dispatch({
        changes: { from: 0, to: this.#cmView.state.doc.length, insert: this.#initialDoc },
      });
    } else {
      this.text = this.#initialDoc;
    }
  }

  formDisabledCallback(disabled) {
    this.disabled = disabled;
    // `disabled` is reflected; the render() hook picks up the change
    // and calls #updateEditableState().
  }

  formStateRestoreCallback(state, _reason) {
    if (typeof state !== 'string') return;
    if (this.#cmView) {
      this.#cmView.dispatch({
        changes: { from: 0, to: this.#cmView.state.doc.length, insert: state },
      });
    } else {
      this.text = state;
    }
  }

  #emitSave(view) {
    this.dispatchEvent(new CustomEvent('save', {
      bubbles: true, cancelable: true,
      detail: { value: view.state.doc.toString() },
    }));
  }

  /** Live buffer. Reads from CodeMirror when mounted, else the static <code>. */
  get value() {
    if (this.#cmView) return this.#cmView.state.doc.toString();
    const code = this.querySelector(':scope > pre > code');
    return code?.textContent ?? this.text ?? '';
  }

  /** Convenience setter — identical to `text` but matches the form-control idiom. */
  set value(v) {
    this.text = String(v ?? '');
  }

  #copy() {
    const text = this.#cmView
      ? this.#cmView.state.doc.toString()
      : (this.querySelector(':scope > pre > code')?.textContent ?? '');
    if (!text) return;
    navigator.clipboard.writeText(text).then(() => {
      const btn = this.querySelector(':scope > header [slot="copy"]');
      if (!btn) return;
      const prev = btn.textContent;
      btn.textContent = 'Copied!';
      if (this.#copyTimer != null) clearTimeout(this.#copyTimer);
      this.#copyTimer = setTimeout(() => {
        this.#copyTimer = null;
        btn.textContent = prev;
      }, 1500);
    });
  }

  render() {
    const label = this.querySelector(':scope > header [slot="label"]');
    if (label && this.language) label.textContent = this.language;

    // React to disabled/readonly toggles on a live editor.
    this.#updateEditableState();

    // Keep the rendered code in sync with the reactive `text` property so
    // callers can drive updates via setAttribute('text', ...).
    if (!this.inline && this.text !== '') {
      if (this.#cmView) {
        // Don't clobber in-flight edits while the editor has keyboard focus.
        // Callers that want to force-replace can blur the editor first.
        if (this.editable && this.#cmView.hasFocus) return;
        const current = this.#cmView.state.doc.toString();
        if (current !== this.text) {
          this.#cmView.dispatch({
            changes: { from: 0, to: current.length, insert: this.text },
          });
          // CM's updateListener catches the dispatch and re-syncs the
          // form value; nothing extra to do here.
        }
      } else {
        const code = this.querySelector(':scope > pre > code');
        if (code && code.textContent !== this.text) {
          code.textContent = this.text;
        }
      }
    }
  }
}
