import { createSignal, createEffect, on, onMount, onCleanup, For, Show, type JSX } from 'solid-js';
import { defineWebComponent } from './define';
import { ResizableHandle, normalizeSize } from '../ui/resizable';

type Orientation = 'horizontal' | 'vertical';

/** Bubbling, composed intent: a descendant asks the nearest enclosing
 *  <kc-resizable> to maximize the item containing it (filling, hiding siblings)
 *  or to restore. Any panel content may emit it — the protocol is zero-config. */
export interface KcMaximizeIntentDetail {
  /** true = maximize the item containing me; false = restore. */
  requested: boolean;
}

/** Composed, non-bubbling notification the group dispatches DOWN onto the
 *  affected <kc-resizable-item> (on maximize) or the group host + the formerly
 *  maximized item (on restore) so descendant content can sync its affordance. */
export interface KcMaximizeStateDetail {
  /** Whether THIS subtree's item is the maximized one. */
  maximized: boolean;
}

/** Event type names for the cross-element maximize protocol. */
export const KC_MAXIMIZE_INTENT = 'kc-maximize-intent' as const;
export const KC_MAXIMIZE_STATE = 'kc-maximize-state' as const;

/** Parsed view of a `<kc-resizable-item>` light child. */
interface ItemInfo {
  el: HTMLElement;
  size?: string;
  /** Original configured size, captured once and stable across drags (for dblclick-reset). */
  defaultSize?: string;
  min?: string;
  max?: string;
  locked: boolean;
  hidden: boolean;
}

/** Max number of panels a single group lays out (nest for more). */
const MAX_ITEMS = 3;

/** Convert a px-or-% bound to `data-*` attribute values the handle understands. */
function boundAttrs(value: string | undefined): { pxAttr?: string; pctAttr?: string } {
  if (value === undefined || value === '') return {};
  const t = value.trim();
  if (t.endsWith('px')) return { pxAttr: String(parseFloat(t)) };
  if (t.endsWith('%')) return { pctAttr: String(parseFloat(t)) };
  if (Number.isFinite(Number(t))) return { pctAttr: t };
  return {};
}

interface GroupProps extends Record<string, unknown> {
  /** Layout axis: `horizontal` (row, default) or `vertical` (column). */
  orientation?: Orientation;
  /** Which item index is maximized (null = none). Declarative source of truth. */
  maximizedIndex?: number | null;
}

interface GroupEvents extends Record<string, unknown> {
  /** Fired on drag-end / keyboard resize / visibility change. `detail.sizes` = panel sizes in percent. */
  'kc-change': { sizes: number[] };
  /** Observe layout maximize state. */
  'kc-maximize-change': { maximized: boolean; index: number | null };
}

/**
 * `<kc-resizable>` — a composable, resizable multi-panel layout (up to 3 panels)
 * with auto-inserted draggable dividers. It lays out its `<kc-resizable-item>`
 * light children along an axis (`orientation`), reading each item's `size`,
 * `min`, `max`, `locked` and `hidden` attributes via a `MutationObserver`. A
 * divider is interactive only between two unlocked, visible panels. Emits a
 * `change` event (`detail.sizes`, percent) on resize / visibility change.
 */
defineWebComponent<GroupProps, GroupEvents>('kc-resizable', {
  orientation: 'horizontal',
  maximizedIndex: null,
}, (props, { element, dispatch }) => {
  const [items, setItems] = createSignal<ItemInfo[]>([]);
  const orientation = (): Orientation => (props.orientation === 'vertical' ? 'vertical' : 'horizontal');

  /** Read the current `<kc-resizable-item>` light children + their attributes. */
  function readItems() {
    const all = Array.from(element.children).filter(
      (c): c is HTMLElement => c.tagName.toLowerCase() === 'kc-resizable-item',
    );
    if (all.length > MAX_ITEMS) {
      // eslint-disable-next-line no-console
      console.warn(
        `<kc-resizable>: only the first ${MAX_ITEMS} <kc-resizable-item> children are laid out ` +
        `(got ${all.length}). Nest <kc-resizable> for more.`,
      );
    }
    const capped = all.slice(0, MAX_ITEMS);
    const parsed: ItemInfo[] = capped.map((el) => {
      // Capture the ORIGINAL configured size ONCE per item, before any drag (or
      // `persistSizes`) overwrites the live `size` attribute. Stashed on a non-
      // observed data-* attr so dblclick-reset always snaps to the true default,
      // not to wherever the user last dragged. Empty string = no explicit default.
      if (!el.hasAttribute('data-kc-default-size')) {
        el.setAttribute('data-kc-default-size', el.getAttribute('size') ?? '');
      }
      const defaultSize = el.getAttribute('data-kc-default-size') || undefined;
      return {
        el,
        size: el.getAttribute('size') ?? undefined,
        defaultSize,
        min: el.getAttribute('min') ?? undefined,
        max: el.getAttribute('max') ?? undefined,
        locked: el.hasAttribute('locked') && el.getAttribute('locked') !== 'false',
        // Honour both the `hidden` boolean attribute and the IDL property — Solid
        // (and direct `el.hidden = true`) set the property, which doesn't reflect
        // to the attribute on a custom element.
        hidden: el.hidden || (el.hasAttribute('hidden') && el.getAttribute('hidden') !== 'false'),
      };
    });

    // Assign each visible item to its panel slot by visible order; clear the
    // rest so hidden/extra items don't leak into a slot.
    let visIdx = 0;
    for (const info of parsed) {
      if (info.hidden) {
        info.el.removeAttribute('slot');
      } else {
        info.el.setAttribute('slot', `p${visIdx}`);
        visIdx++;
      }
    }
    for (const el of all.slice(MAX_ITEMS)) el.removeAttribute('slot');

    // BUG FIX (U10b): the MutationObserver fires for ANY subtree change, including
    // a slotted child re-rendering its CONTENT (e.g. a new chat message). If we
    // unconditionally `setItems(parsed)`, the <For> re-keys and each panel's
    // flex-basis is re-derived from the item's `size` ATTRIBUTE — wiping out any
    // size the user dragged (which lives only as inline flex-basis on the panel
    // div, set by ResizableHandle.settleToPercent). The composite <kc-workspace>
    // never hits this because it renders Solid panels directly with no re-read.
    //
    // So: only commit a new items() array when the panel LAYOUT actually changed —
    // i.e. an item was added/removed, or a config attribute (size/min/max/locked/
    // hidden) changed. A pure content mutation is structurally identical and is
    // skipped, leaving the dragged inline basis intact.
    if (!itemsChanged(items(), parsed)) return;
    setItems(parsed);
  }

  /** True when the parsed item list differs structurally (identity, order, or any
   *  layout-affecting config attribute) from the current one. Content-only
   *  mutations leave every field equal → returns false → no re-render. */
  function itemsChanged(prev: ItemInfo[], next: ItemInfo[]): boolean {
    if (prev.length !== next.length) return true;
    for (let i = 0; i < prev.length; i++) {
      const a = prev[i];
      const b = next[i];
      if (a.el !== b.el) return true;
      if (a.size !== b.size || a.min !== b.min || a.max !== b.max) return true;
      if (a.locked !== b.locked || a.hidden !== b.hidden) return true;
    }
    return false;
  }

  const visible = () => items().filter((i) => !i.hidden);

  /** Compute current panel sizes (percent of container) from the rendered DOM. */
  function currentSizes(): number[] {
    return visible().map((info) => {
      const panel = info.el.assignedSlot?.closest('[data-panel]') as HTMLElement | null;
      const target = panel ?? info.el;
      const parent = target.parentElement;
      const dim = orientation() === 'horizontal' ? 'width' : 'height';
      const total = parent ? parent.getBoundingClientRect()[dim] : 0;
      const val = target.getBoundingClientRect()[dim];
      return total > 0 ? Math.round((val / total) * 100) : 0;
    });
  }

  function emitChange() {
    dispatch('kc-change', { sizes: currentSizes() });
  }

  /** Guard: while writing `size` attributes back from a live layout, the
   *  MutationObserver must NOT treat our own writes as an external structural
   *  change (which would re-render and could fight the in-flight drag). */
  let persistingSizes = false;

  /**
   * BUG FIX (U10b) — durability: a drag/keyboard resize writes inline flex-basis
   * onto the panel divs only; the item `size` ATTRIBUTE (the source of truth that
   * `readItems` re-derives basis from) is left at its initial value. Persist the
   * live percent back to each visible item's `size` attribute so the dragged
   * layout survives ANY later re-read (including a genuine add/remove relayout),
   * not just content-only mutations. Guarded so it doesn't trip the observer.
   */
  function persistSizes() {
    const vis = visible();
    const live = currentSizes();
    if (vis.length !== live.length) return;
    persistingSizes = true;
    vis.forEach((info, i) => {
      const pct = live[i];
      if (Number.isFinite(pct) && pct > 0) info.el.setAttribute('size', `${pct}%`);
    });
    // Hold the guard across the MutationObserver microtask (it fires AFTER this
    // synchronous code), then clear it — otherwise the observer sees the flag
    // already false and re-reads our own writes. Same pattern as applyingMaximize.
    queueMicrotask(() => { persistingSizes = false; });
  }

  // --- Task 2: maximize/restore core ---

  interface SavedItemState {
    el: HTMLElement;
    size: string | null;
    hidden: boolean;
    locked: boolean;
  }
  interface MaximizeStash {
    /** The element that was maximized (identity anchor for re-target + events). */
    item: HTMLElement;
    /** Per-element stash keyed by element identity, not position. */
    saved: SavedItemState[];
  }
  const [maximized, setMaximized] = createSignal<MaximizeStash | null>(null);
  /** Re-entrancy guard: while we (un)apply maximize attributes, the observer must
   *  NOT emit a mid-flight `change`. The final relayout emits the real one. */
  let applyingMaximize = false;

  /** Find the capped <kc-resizable-item> ancestor of an event target, if any. */
  function findContainingItem(node: Node | null): HTMLElement | null {
    let el = node instanceof Element ? node : node?.parentElement ?? null;
    // The intent is composed; its target may be inside the artifact's shadow.
    // Resolve to a direct light child <kc-resizable-item> of THIS group.
    const capped = items().map((i) => i.el);
    while (el) {
      if (el instanceof HTMLElement && capped.includes(el)) return el;
      el = el.parentElement ?? (el.getRootNode() as ShadowRoot).host ?? null;
    }
    return null;
  }

  function readAttrState(el: HTMLElement) {
    return {
      size: el.getAttribute('size'),
      hidden: el.hidden || (el.hasAttribute('hidden') && el.getAttribute('hidden') !== 'false'),
      locked: el.hasAttribute('locked') && el.getAttribute('locked') !== 'false',
    };
  }

  function setBoolAttr(el: HTMLElement, name: string, on: boolean) {
    if (on) el.setAttribute(name, '');
    else el.removeAttribute(name);
  }

  function maximizeItem(item: HTMLElement) {
    const list = items();
    const index = list.findIndex((i) => i.el === item);
    if (index < 0) return;
    const current = maximized();
    if (current) {
      if (current.item === item) return;       // same item → no-op
      restore();                               // different item → re-target
    }
    applyingMaximize = true;
    // Capture the EFFECTIVE current % so a post-drag layout restores faithfully.
    const live = currentSizes(); // visible-order percents (Playwright-verified)
    let visIdx = 0;
    const saved: SavedItemState[] = list.map((info) => {
      const prev = readAttrState(info.el);
      if (!prev.hidden) {
        // Write the live % back as the stashed size baseline.
        const pct = live[visIdx++];
        if (Number.isFinite(pct) && pct > 0) info.el.setAttribute('size', `${pct}%`);
      }
      return { el: info.el, size: info.el.getAttribute('size'), hidden: prev.hidden, locked: prev.locked };
    });
    // Hide every other item; free the maximized one to fill.
    list.forEach((info, i) => {
      if (i === index) {
        info.el.removeAttribute('size');
        info.el.removeAttribute('locked');
        setBoolAttr(info.el, 'hidden', false);
      } else {
        setBoolAttr(info.el, 'hidden', true);
      }
    });
    setMaximized({ item, saved });
    element.setAttribute('data-maximized', '');
    item.setAttribute('data-maximized-panel', '');
    readItems();
    emitChange();
    // Keep applyingMaximize = true until AFTER the MutationObserver microtask fires
    // so its queueMicrotask(emitChange) sees the guard and skips (storm guard).
    queueMicrotask(() => { applyingMaximize = false; });
    // Tell the maximized subtree (and only it) it is now maximized.
    item.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: true }, bubbles: false, composed: true }));
    dispatch('kc-maximize-change', { maximized: true, index });
  }

  function restore() {
    const stash = maximized();
    if (!stash) return;
    applyingMaximize = true;
    // Re-apply saved state keyed by element identity; items no longer in the DOM
    // are simply skipped.  This is safe regardless of sibling additions/removals
    // that happened while maximized (no index drift).
    for (const s of stash.saved) {
      if (!s.el.isConnected) continue;
      if (s.size === null) s.el.removeAttribute('size');
      else s.el.setAttribute('size', s.size);
      setBoolAttr(s.el, 'hidden', s.hidden);
      setBoolAttr(s.el, 'locked', s.locked);
      s.el.removeAttribute('data-maximized-panel');
    }
    const prevItem = stash.item;
    setMaximized(null);
    element.removeAttribute('data-maximized');
    readItems();
    emitChange();
    // Keep applyingMaximize = true until AFTER the MutationObserver microtask fires
    // so its queueMicrotask(emitChange) sees the guard and skips (storm guard).
    queueMicrotask(() => { applyingMaximize = false; });
    // Broadcast restore on the host AND directly on the formerly-maximized item.
    element.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: false }, bubbles: false, composed: true }));
    prevItem?.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: false }, bubbles: false, composed: true }));
    dispatch('kc-maximize-change', { maximized: false, index: null });
  }

  onMount(() => {
    readItems();
    const onIntent = (e: Event) => {
      const ce = e as CustomEvent<KcMaximizeIntentDetail>;
      e.stopPropagation();                       // nearest group wins (nesting)
      const item = findContainingItem(ce.target as Node);
      if (!item) return;                         // outside any item → ignore
      if (ce.detail.requested) maximizeItem(item);
      else restore();
    };
    element.addEventListener('kc-maximize-intent', onIntent);

    const onKeydown = (e: KeyboardEvent) => {
      // Only act while maximized — the capture listener on the host already
      // ensures the event originates within the group.
      if (e.key !== 'Escape' || !maximized()) return;
      e.stopPropagation();
      restore();
    };
    element.addEventListener('keydown', onKeydown, true);

    const mo = new MutationObserver(() => {
      // Skip our OWN per-drag size writes (persistSizes). Re-reading here would
      // setItems() → re-render the <For>, replacing the ResizableHandle mid-drag
      // and dropping its pointer capture — the drag stalls after the first move.
      // (The flag is held across this observer microtask; see persistSizes.)
      if (persistingSizes) return;
      readItems();
      const stash = maximized();
      if (stash) {
        // Use element identity, not a positional index, to detect whether the
        // maximized item is still present and visible.
        const maximizedEl = stash.item;
        const isConnected = maximizedEl.isConnected && element.contains(maximizedEl);
        const isVisible = isConnected && !(maximizedEl.hidden || maximizedEl.hasAttribute('hidden'));
        if (!isConnected || !isVisible) {
          // The maximized item was removed or hidden out from under us → restore.
          restore();
          return;
        }
      }
      if (applyingMaximize) return; // our own maximize writes — skip the emit
      queueMicrotask(emitChange);
    });
    mo.observe(element, {
      childList: true,
      // Attribute changes happen on the <kc-resizable-item> *children*, so we
      // must observe the subtree (filtered to the config attributes) — not just
      // the host's own attributes.
      subtree: true,
      attributes: true,
      attributeFilter: ['size', 'locked', 'min', 'max', 'hidden'],
    });
    onCleanup(() => {
      mo.disconnect();
      element.removeEventListener('kc-maximize-intent', onIntent);
      element.removeEventListener('keydown', onKeydown, true);
    });

    // --- Task 3: imperative host methods + declarative maximizedIndex prop ---

    // Imperative host API (assigned onto the element; typed by resizable.d.ts).
    const host = element as unknown as { maximize(i: number): void; restore(): void };
    host.maximize = (i: number) => {
      const it = items()[i]?.el;
      if (it) maximizeItem(it);
    };
    host.restore = () => restore();

    // Declarative maximizedIndex → maximize/restore. Skip the initial null run.
    createEffect(
      on(
        () => props.maximizedIndex,
        (idx) => {
          if (idx == null) restore();
          else {
            const it = items()[idx]?.el;
            if (it) maximizeItem(it);
          }
        },
        { defer: true },
      ),
    );
  });

  const isHoriz = () => orientation() === 'horizontal';

  return (
    <>
      {/* A resizable layout fills its container — default the host to a block
          that stretches, so consumers only need to give a parent (or the element)
          a height. Override with an explicit height on the element if needed. */}
      <style>{':host{display:block;height:100%}'}</style>
    <div
      data-resizable-root
      data-orientation={orientation()}
      style={{
        display: 'flex',
        'flex-direction': isHoriz() ? 'row' : 'column',
        width: '100%',
        height: '100%',
      }}
    >
      <For each={visible()}>
        {(info, i) => {
          const min = boundAttrs(info.min);
          const max = boundAttrs(info.max);
          const def = boundAttrs(info.defaultSize);
          const basis = normalizeSize(info.size);
          const prev = () => visible()[i() - 1];
          const isStatic = () => !!(info.locked || prev()?.locked);
          return (
            <>
              <Show when={i() > 0}>
                <ResizableHandle
                  withHandle
                  orientation={orientation()}
                  static={isStatic()}
                  onPanelResize={() => {
                    // Persist the dragged/dblclick-reset layout back to the item
                    // `size` attributes so it survives later re-reads, then emit.
                    persistSizes();
                    queueMicrotask(emitChange);
                  }}
                />
              </Show>
              <div
                data-panel
                data-locked={info.locked ? '' : undefined}
                data-min-size={min.pxAttr}
                data-min-size-pct={min.pctAttr}
                data-max-size={max.pxAttr}
                data-max-size-pct={max.pctAttr}
                // Reflect the configured default size so a dblclick on the
                // adjacent <ResizableHandle> can snap this panel back to it.
                data-default-size={def.pxAttr}
                data-default-size-pct={def.pctAttr}
                style={{
                  // The panel is a flex item of the root: `flex-basis` sizes its
                  // MAIN axis (width when horizontal, height when vertical) and the
                  // drag/keyboard handlers rewrite that basis — so the layout spine
                  // stays a plain flex row/column (drag math unchanged).
                  //
                  // For the FILL, the panel is itself a `display:grid` with a single
                  // `minmax(0,1fr)` cell on BOTH axes. A grid item (the slotted
                  // `<kc-resizable-item>`) stretches to fill its cell on both axes by
                  // default (`place-items:stretch`), and a `1fr` track inside a
                  // definite-sized grid is a *definite* length — so content fills
                  // height AND width whether or not it sets `height:100%`, in both
                  // orientations. A flex panel only stretches the CROSS axis (which is
                  // why the previous `display:flex` panel collapsed the main axis to
                  // content height). This mirrors how Shoelace/Web Awesome
                  // `<sl-split-panel>` lay panels out with grid for guaranteed fill,
                  // adapted here to a per-panel grid cell (our double slot boundary
                  // makes a slot-as-grid-item unusable — a slot with assigned nodes
                  // has a zero box). min:0 on both axes enables shrink-to-scroll;
                  // overflow clips (the item owns the scroll).
                  ...(basis !== undefined
                    ? { 'flex-basis': basis, 'flex-grow': '0', 'flex-shrink': '0' }
                    : { flex: '1 1 0%' }),
                  display: 'grid',
                  'grid-template-rows': 'minmax(0, 1fr)',
                  'grid-template-columns': 'minmax(0, 1fr)',
                  'min-width': '0',
                  'min-height': '0',
                  overflow: 'hidden',
                }}
              >
                <slot name={`p${i()}`} />
              </div>
            </>
          );
        }}
      </For>
    </div>
    </>
  );
});

interface ItemProps extends Record<string, unknown> {
  /** Initial main-axis size: `"280px"` (fixed) or `"25%"`/`25` (percent). Omitted → flexible. */
  size?: string;
  /** Minimum size during resize (px or %). */
  min?: string;
  /** Maximum size during resize (px or %). */
  max?: string;
  /** Fix this panel's size; adjacent dividers become non-draggable. */
  locked?: boolean;
  /** Hide this panel; its divider is dropped and the rest reflow. */
  hidden?: boolean;
}

/**
 * `<kc-resizable-item>` — a passive config-carrier inside `<kc-resizable>`. It
 * renders its own slotted light content (`<slot/>`); the parent `<kc-resizable>`
 * reads its `size`/`min`/`max`/`locked`/`hidden` attributes to lay it out.
 */
defineWebComponent<ItemProps>('kc-resizable-item', {
  size: undefined,
  min: undefined,
  max: undefined,
  locked: false,
  hidden: false,
}, () => (
  <>
    {/* The item host fills the panel's single grid cell (the panel stretches it on
        both axes) and OWNS the scroll: `display:block; width/height:100%;
        overflow:auto`. min:0 enables shrink-to-scroll.

        The FILL of the *slotted* content happens one level in: a `display:grid`
        wrapper with a single `minmax(0,1fr)` cell whose ONLY child is the `<slot>`.
        A grid item stretches to fill its cell on BOTH axes by default
        (`place-items:stretch`) into a *definite* `1fr` track, so slotted content
        fills width AND height whether or not it sets `height:100%`. The grid MUST
        be its own element wrapping the slot — NOT the host — because the element
        facade renders an empty portal-mount `<div>` (for overlays) as a sibling of
        the facade output; if the host itself were the grid, that portal div would
        become a second grid item and steal a track (collapsing content to the auto
        row). Scoping the grid to a wrapper whose sole child is the slot keeps
        exactly one grid item and is robust to the facade's portal sibling.
        Mirrors how Shoelace/Web Awesome `<sl-split-panel>` use grid for fill. */}
    <style>{':host{display:block;width:100%;height:100%;min-width:0;min-height:0;overflow:auto}'}</style>
    <div style={{ display: 'grid', 'grid-template-rows': 'minmax(0, 1fr)', 'grid-template-columns': 'minmax(0, 1fr)', width: '100%', height: '100%', 'min-width': '0', 'min-height': '0' }}>
      <slot />
    </div>
  </>
) as unknown as JSX.Element);
