<script lang="ts">
  import { api } from '$lib/api';
  import { toast } from '$lib/toast';
  import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
  import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
  import SortHeader from '$lib/table/SortHeader.svelte';
  import { TableController } from '$lib/table/table.svelte';
  import type {
    EditorResolvedMixFormula,
    EditorResolvedMixIngredient,
    EditorMixRow,
    EditorMixUpdateInput,
    RawMaterial
  } from '$lib/types';
  import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, TriangleAlert, X } from 'lucide-svelte';
  import { fade } from 'svelte/transition';

  let { data } = $props();

  type EditableRow = EditorMixRow & {
    draft_mix_name: string;
    draft_visible: boolean;
  };

  type DraftIngredient = {
    raw_material_id: number | null;
    quantity_kg: number;
    // Percentage is an entry aid; kilograms remain the canonical saved value.
    percentage: number;
    notes: string;
  };

  let rows = $state<EditableRow[]>([]);
  let query = $state('');
  let clientFilter = $state('all');
  let visibilityFilter = $state<'all' | 'visible' | 'hidden'>('visible');
  let savingKey = $state<string | null>(null);
  let expandedMixId = $state<number | null>(null);
  let activeFormula = $state<EditorResolvedMixFormula | null>(null);
  let ingredientDrafts = $state<DraftIngredient[]>([]);
  // Snapshot of the loaded formula, so we can tell whether the open panel has
  // unsaved edits before letting the user leave it.
  let ingredientBaseline = $state<DraftIngredient[]>([]);
  // The reference total used to convert between % and kg. Editing a kg cell
  // redefines it (kg is the source of truth); editing the Total mix field
  // rescales every row's kg from its %.
  let totalReference = $state(0);

  // The row the user is trying to open/close while the current panel has unsaved
  // ingredient edits (null = no pending switch). Drives the confirm dialog.
  let pendingRow = $state<EditableRow | null>(null);

  // The mix whose change history is open in the modal (null = closed).
  let historyMix = $state<EditableRow | null>(null);

  // Inline "create new mix" form state.
  let creatingMix = $state(false);
  let newMixClient = $state('');
  let newMixName = $state('');

  function toEditableRow(row: EditorMixRow): EditableRow {
    return {
      ...row,
      draft_mix_name: row.name,
      draft_visible: row.visible
    };
  }

  $effect(() => {
    if (rows.length === 0 && data.rows.length > 0) {
      rows = data.rows.map(toEditableRow);
    }
  });

  // Round to avoid binary-float noise in the inputs (e.g. 59.60000000001).
  function round4(value: number) {
    return Math.round(value * 1e4) / 1e4;
  }

  function ingredientToDraft(ingredient: EditorResolvedMixIngredient): DraftIngredient {
    return {
      raw_material_id: ingredient.raw_material_id,
      quantity_kg: ingredient.quantity_kg,
      percentage: ingredient.mix_percentage,
      notes: ingredient.notes ?? ''
    };
  }

  function emptyIngredient(): DraftIngredient {
    return {
      raw_material_id: (data.rawMaterials as RawMaterial[])[0]?.id ?? null,
      quantity_kg: 0,
      percentage: 0,
      notes: ''
    };
  }

  function loadIngredientDrafts(formula: EditorResolvedMixFormula) {
    activeFormula = formula;
    totalReference = formula.total_kg || 0;
    ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
    // Reset the baseline so the freshly loaded (or just-saved) formula reads clean.
    ingredientBaseline = ingredientDrafts.map((row) => ({ ...row }));
  }

  // kg overrides %: recompute the reference total from the kg column, then
  // re-derive every row's percentage so they always sum to 100.
  function applyKgEdit() {
    const total = ingredientDrafts.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0);
    totalReference = round4(total);
    ingredientDrafts = ingredientDrafts.map((row) => ({
      ...row,
      percentage: total > 0 ? round4((Number(row.quantity_kg || 0) / total) * 100) : 0
    }));
  }

  // Editing a row's % sets it to that share of the mix and spreads the rest
  // across the other rows in their existing proportions, so the column always
  // re-totals 100 and kg stays the canonical value. The total mix kg is held
  // constant; only the split moves. Without this rebalance a single % edit
  // would leave the total off 100 and the save guard would block the change.
  function applyPercentEdit(index: number) {
    const total = Number(totalReference || 0);
    const rows = ingredientDrafts;
    const count = rows.length;

    // Clamp to a sane 0–100 share.
    let target = Number(rows[index].percentage || 0);
    if (!Number.isFinite(target) || target < 0) target = 0;
    if (target > 100) target = 100;

    // A single ingredient is always the whole mix.
    if (count === 1) {
      ingredientDrafts = rows.map((row) => ({
        ...row,
        percentage: 100,
        quantity_kg: round4(total)
      }));
      return;
    }

    const remaining = 100 - target;
    const otherOldSum = rows.reduce(
      (sum, row, rowIndex) => (rowIndex === index ? sum : sum + Number(row.percentage || 0)),
      0
    );

    ingredientDrafts = rows.map((row, rowIndex) => {
      if (rowIndex === index) {
        return { ...row, percentage: round4(target), quantity_kg: round4((target / 100) * total) };
      }
      // Keep the other rows' relative split; if they were all at 0, share evenly.
      const share =
        otherOldSum > 0 ? (Number(row.percentage || 0) / otherOldSum) * remaining : remaining / (count - 1);
      return { ...row, percentage: round4(share), quantity_kg: round4((share / 100) * total) };
    });
  }

  // Editing the Total mix (kg) rescales every row's kg from its current %.
  function applyTotalEdit() {
    const total = Number(totalReference || 0);
    ingredientDrafts = ingredientDrafts.map((row) => ({
      ...row,
      quantity_kg: total > 0 ? round4((Number(row.percentage || 0) / 100) * total) : 0
    }));
  }

  function rowDirty(row: EditableRow) {
    return row.draft_mix_name !== row.name || row.draft_visible !== row.visible;
  }

  function applyMixUpdate(updated: EditorMixRow) {
    rows = rows.map((row) => (row.id === updated.id ? toEditableRow(updated) : row));
  }

  async function saveRow(row: EditableRow) {
    if (!rowDirty(row)) return;
    savingKey = `row:${row.id}`;

    try {
      const payload: EditorMixUpdateInput = {};
      if (row.draft_mix_name.trim() !== row.name) payload.name = row.draft_mix_name.trim();
      if (row.draft_visible !== row.visible) payload.visible = row.draft_visible;

      applyMixUpdate(await api.updateEditorMix(row.id, payload));
      toast.success('Mix saved');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Unable to save mix');
    } finally {
      savingKey = null;
    }
  }

  function resetRow(row: EditableRow) {
    rows = rows.map((candidate) => (candidate.id === row.id ? toEditableRow(candidate) : candidate));
  }

  function openCreateMix() {
    creatingMix = true;
    // Prefill the client from the active filter so creating several mixes for
    // one client is quick.
    newMixClient = clientFilter !== 'all' ? clientFilter : '';
    newMixName = '';
  }

  function cancelCreateMix() {
    creatingMix = false;
    newMixClient = '';
    newMixName = '';
  }

  async function createMix() {
    if (!newMixClient.trim()) {
      toast.error('Client name is required.');
      return;
    }
    if (!newMixName.trim()) {
      toast.error('Mix name is required.');
      return;
    }

    savingKey = 'mix-create';

    try {
      const created = await api.createEditorMix({
        client_name: newMixClient.trim(),
        name: newMixName.trim()
      });

      rows = [toEditableRow(created), ...rows];
      cancelCreateMix();

      // A new mix is Inactive (no products yet) and may sit under any client, so
      // clear filters and sorting to guarantee it surfaces at the top, then open
      // its ingredient panel so the recipe can be built straight away.
      query = '';
      clientFilter = 'all';
      visibilityFilter = 'all';
      table.sortKey = null;
      table.reset();

      toast.success('Mix created');

      const newRow = rows.find((row) => row.id === created.id);
      if (newRow) await toggleIngredients(newRow);
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Unable to create mix');
    } finally {
      savingKey = null;
    }
  }

  // Gate panel switches: if the open formula has unsaved edits, ask before
  // leaving it (opening another row, or closing this one, both discard them).
  function requestToggleIngredients(row: EditableRow) {
    if (expandedMixId !== null && ingredientsDirty) {
      pendingRow = row;
      return;
    }
    toggleIngredients(row);
  }

  function confirmDiscardChanges() {
    const target = pendingRow;
    pendingRow = null;
    if (target) toggleIngredients(target);
  }

  async function toggleIngredients(row: EditableRow) {
    if (expandedMixId === row.id) {
      expandedMixId = null;
      activeFormula = null;
      ingredientDrafts = [];
      ingredientBaseline = [];
      return;
    }

    expandedMixId = row.id;
    savingKey = `mix-load:${row.id}`;

    try {
      loadIngredientDrafts(await api.editorMixResolvedFormula(row.id));
    } catch (error) {
      expandedMixId = null;
      toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
    } finally {
      savingKey = null;
    }
  }

  function addIngredient() {
    ingredientDrafts = [...ingredientDrafts, emptyIngredient()];
  }

  function removeIngredient(index: number) {
    ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index);
    if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()];
    applyKgEdit();
  }

  function ingredientWarnings() {
    const chosen = ingredientDrafts
      .map((row) => row.raw_material_id)
      .filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null);

    if (!activeFormula) return ['Open a mix before saving ingredients.'];
    if (!chosen.length) return ['Add at least one raw material.'];
    if (new Set(chosen).size !== chosen.length) return ['Each raw material can only appear once in a mix.'];

    for (const [index, row] of ingredientDrafts.entries()) {
      if (!row.raw_material_id) return [`Ingredient row ${index + 1} needs a raw material.`];
      if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`];
    }

    // Percentages must add up to 100 before a change can be saved.
    if (Math.abs(percentTotal - 100) > 0.1) {
      return [`Percentages must total 100% (currently ${percentTotal.toFixed(2)}%).`];
    }

    return [];
  }

  async function saveIngredients() {
    const warnings = ingredientWarnings();
    if (warnings.length) {
      toast.error(warnings[0]);
      return;
    }
    if (!activeFormula) return;

    savingKey = `mix-save:${activeFormula.id}`;

    try {
      const payloadRows = ingredientDrafts.map((row) => ({
        raw_material_id: row.raw_material_id as number,
        quantity_kg: Number(row.quantity_kg),
        notes: row.notes.trim() || null
      }));

      loadIngredientDrafts(await api.replaceEditorMixFormula(activeFormula.id, payloadRows));
      toast.success('Ingredients saved');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
    } finally {
      savingKey = null;
    }
  }

  function rawMaterialName(rawMaterialId: number | null) {
    return (data.rawMaterials as RawMaterial[]).find((material) => material.id === rawMaterialId)?.name ?? 'Raw material';
  }

  function clearFilters() {
    query = '';
    clientFilter = 'all';
    visibilityFilter = 'visible';
    table.reset();
  }

  function setVisibilityFilter(value: 'all' | 'visible' | 'hidden') {
    visibilityFilter = value;
  }

  const clientOptions = $derived(
    Array.from(
      new Set(
        rows
          .filter((row) => {
            if (visibilityFilter === 'visible') return row.visible;
            if (visibilityFilter === 'hidden') return !row.visible;
            return true;
          })
          .map((row) => row.client_name)
      )
    ).sort((a, b) => a.localeCompare(b))
  );

  const visibleRows = $derived(
    rows.filter((row) => {
      const term = query.trim().toLowerCase();
      const clientMatches = clientFilter === 'all' || row.client_name === clientFilter;
      const visibilityMatches =
        visibilityFilter === 'all' ||
        (visibilityFilter === 'visible' && row.visible) ||
        (visibilityFilter === 'hidden' && !row.visible);
      if (!clientMatches) return false;
      if (!visibilityMatches) return false;
      if (!term) return true;

      return [row.client_name, row.name].join(' ').toLowerCase().includes(term);
    })
  );

  const table = new TableController<EditableRow>(() => visibleRows, {
    client_name: (row) => row.client_name,
    name: (row) => row.name,
    visible: (row) => row.visible
  });

  const dirtyCount = $derived(rows.filter(rowDirty).length);
  const filtersActive = $derived(Boolean(query.trim() || clientFilter !== 'all' || visibilityFilter !== 'visible'));
  const ingredientTotalKg = $derived(
    ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.quantity_kg || 0), 0)
  );
  const percentTotal = $derived(
    ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.percentage || 0), 0)
  );
  const percentBalanced = $derived(Math.abs(percentTotal - 100) <= 0.1);

  // True when the open ingredient panel differs from the formula we loaded.
  // kg is the canonical value, so comparing kg (plus raw material and notes)
  // captures both % and kg edits.
  const ingredientsDirty = $derived.by(() => {
    if (ingredientBaseline.length !== ingredientDrafts.length) return true;
    return ingredientDrafts.some((row, index) => {
      const base = ingredientBaseline[index];
      return (
        !base ||
        row.raw_material_id !== base.raw_material_id ||
        round4(Number(row.quantity_kg || 0)) !== round4(Number(base.quantity_kg || 0)) ||
        (row.notes ?? '') !== (base.notes ?? '')
      );
    });
  });

  // Jump back to the first page whenever the filtered set changes.
  $effect(() => {
    query;
    clientFilter;
    visibilityFilter;
    table.pageSize;
    table.reset();
  });

  $effect(() => {
    if (clientFilter !== 'all' && !clientOptions.includes(clientFilter)) {
      clientFilter = 'all';
    }
  });
</script>

<AppSecondaryRailLayout>
  {#snippet rail()}
    <div class="filter-rail" aria-label="Mix Editor filters">
      <p class="rail-label">Mix Editor</p>

      <div class="rail-identity">
        <div class="rail-avatar" aria-hidden="true">
          <ListFilter size={16} strokeWidth={1.8} />
        </div>
        <div class="rail-identity-text">
          <p class="identity-name">Filter mixes</p>
          <p class="identity-role">{visibleRows.length} matching mixes</p>
        </div>
      </div>

      <div class="filter-rail-body">
        <label class="filter-search">
          <span>Search</span>
          <div class="search-input">
            <Search size={17} strokeWidth={2.2} />
            <input bind:value={query} type="search" placeholder="Client or mix name" />
          </div>
        </label>

        <div class="status-filter" role="group" aria-label="Status filter">
          <span class="field-label">Status</span>
          <div class="segmented-control">
            <button type="button" class:active={visibilityFilter === 'visible'} aria-pressed={visibilityFilter === 'visible'} onclick={() => setVisibilityFilter('visible')}>Active</button>
            <button type="button" class:active={visibilityFilter === 'hidden'} aria-pressed={visibilityFilter === 'hidden'} onclick={() => setVisibilityFilter('hidden')}>Inactive</button>
            <button type="button" class:active={visibilityFilter === 'all'} aria-pressed={visibilityFilter === 'all'} onclick={() => setVisibilityFilter('all')}>All</button>
          </div>
        </div>

        <label>
          <span>Client</span>
          <select bind:value={clientFilter}>
            <option value="all">All clients</option>
            {#each clientOptions as client}
              <option value={client}>{client}</option>
            {/each}
          </select>
        </label>

        {#if filtersActive}
          <button type="button" class="clear-button rail-clear" onclick={clearFilters}>
            <X size={16} strokeWidth={2.4} /> Clear filters
          </button>
        {/if}
      </div>
    </div>
  {/snippet}

  <section class="editor">
    <div class="status-band">
      <div class="editor-status">
        <span>
          <strong>Mix Editor</strong>
          <small>{visibleRows.length} mixes</small>
        </span>
      </div>

      <div class="status-actions">
        <dl class="facts">
          <div class="fact">
            <dt>Mixes</dt>
            <dd>{visibleRows.length}</dd>
          </div>
          <div class="fact">
            <dt>Unsaved</dt>
            <dd>{dirtyCount}</dd>
          </div>
        </dl>

        <button class="apply-button new-mix-button" type="button" onclick={openCreateMix} disabled={creatingMix}>
          <Plus size={16} strokeWidth={2.4} />
          New mix
        </button>
      </div>
    </div>

    {#if creatingMix}
      <form class="create-panel" transition:fade={{ duration: 120 }} onsubmit={(event) => { event.preventDefault(); createMix(); }}>
        <div class="create-head">
          <strong>New mix</strong>
          <span>Create a mix, then build its formula below.</span>
        </div>

        <div class="create-fields">
          <label>
            <span>Client <span class="req">*</span></span>
            <input
              bind:value={newMixClient}
              list="editor-client-options"
              placeholder="Search clients or type a new one"
              autocomplete="off"
            />
            <small class="field-hint">Pick an existing client from the list, or type a new client name.</small>
          </label>
          <label>
            <span>Mix name <span class="req">*</span></span>
            <!-- svelte-ignore a11y_autofocus -->
            <input bind:value={newMixName} placeholder="Mix name" autofocus />
          </label>
        </div>

        <datalist id="editor-client-options">
          {#each clientOptions as client}
            <option value={client}></option>
          {/each}
        </datalist>

        <div class="create-actions">
          <button class="clear-button" type="button" onclick={cancelCreateMix}>Cancel</button>
          <button class="apply-button" type="submit" disabled={savingKey === 'mix-create'}>
            {savingKey === 'mix-create' ? 'Creating...' : 'Create mix'}
          </button>
        </div>
      </form>
    {/if}

    <div class="pagination-bar" aria-label="Mix table pagination">
    <span>{table.pageStart}-{table.pageEnd} of {table.total}</span>
    <label class="page-size">
      <span>Rows</span>
      <select bind:value={table.pageSize}>
        {#each table.pageSizes as size}
          <option value={size}>{size}</option>
        {/each}
      </select>
    </label>
    <div class="page-controls">
      <button type="button" class="clear-button icon-button" disabled={!table.canPrev} onclick={() => table.prev()} aria-label="Previous page">
        <ChevronLeft size={16} strokeWidth={2.4} />
      </button>
      <span>Page {table.currentPage} of {table.totalPages}</span>
      <button type="button" class="clear-button icon-button" disabled={!table.canNext} onclick={() => table.next()} aria-label="Next page">
        <ChevronRight size={16} strokeWidth={2.4} />
      </button>
    </div>
    </div>

    <div class="log">
    <div class="log-head">
      <SortHeader label="Client" column="client_name" controller={table} />
      <SortHeader label="Mix" column="name" controller={table} />
      <SortHeader label="Status" column="visible" controller={table} />
      <span>Actions</span>
    </div>

    {#each table.rows as row (row.id)}
      <div class="row" class:edited={rowDirty(row)}>
        <div class="client-cell">
          <span class="cell-label">Client</span>
          <span class="readonly-value">{row.client_name}</span>
        </div>

        <div class="mix-cell">
          <span class="cell-label">Mix</span>
          <input bind:value={row.draft_mix_name} aria-label="Mix name" />
        </div>

        <div class="status-cell">
          <span class="cell-label">Status</span>
          <label class="status-toggle" class:on={row.draft_visible}>
            <input type="checkbox" bind:checked={row.draft_visible} />
            <span>{row.draft_visible ? 'Active' : 'Inactive'}</span>
          </label>
        </div>

        <div class="row-actions">
          <button class="clear-button" type="button" onclick={() => requestToggleIngredients(row)}>
            <FlaskConical size={16} strokeWidth={2.2} />
            {expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
          </button>
          <button class="clear-button" type="button" onclick={() => (historyMix = row)} aria-label={`History for ${row.name}`}>
            <History size={16} strokeWidth={2.2} />
            History
          </button>
          <button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
            <Save size={16} strokeWidth={2.4} />
            {savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'}
          </button>
          {#if rowDirty(row)}
            <button class="link-button" type="button" onclick={() => resetRow(row)}>Reset</button>
          {/if}
        </div>
      </div>

      {#if expandedMixId === row.id}
        <div class="ingredient-panel" transition:fade={{ duration: 120 }}>
          <div class="ingredient-head">
            <div>
              <span>Ingredients{#if activeFormula} · {activeFormula.source === 'product' ? 'product formula' : 'mix master'}{/if}</span>
              <strong>{activeFormula?.client_name ?? row.client_name} / {activeFormula?.name ?? row.name}</strong>
            </div>
            <div class="ingredient-summary">
              <label class="total-field">
                <span>Total mix (kg)</span>
                <input
                  bind:value={totalReference}
                  onchange={applyTotalEdit}
                  type="number"
                  min="0"
                  step="0.0001"
                  aria-label="Total mix kilograms"
                />
              </label>
              <span class="percent-chip" class:off={!percentBalanced} aria-live="polite">
                {percentTotal.toFixed(2)}%
              </span>
            </div>
          </div>

          <div class="ingredient-grid">
            <span class="grid-label">Raw material</span>
            <span class="grid-label">%</span>
            <span class="grid-label">kg (auto)</span>
            <span class="grid-label">Notes</span>
            <span class="grid-label">Remove</span>

            {#each ingredientDrafts as ingredient, index}
              <select bind:value={ingredient.raw_material_id} aria-label={`Raw material ${index + 1}`}>
                {#each data.rawMaterials as material}
                  <option value={material.id}>{material.name}</option>
                {/each}
              </select>
              <input
                bind:value={ingredient.percentage}
                onchange={() => applyPercentEdit(index)}
                type="number"
                min="0"
                step="0.0001"
                aria-label={`Percentage for ${rawMaterialName(ingredient.raw_material_id)}`}
              />
              <!-- kg is derived from % against the Total mix anchor and stays the
                   canonical saved value; it is read-only here so % is the single
                   point of entry (the two cells used to conflict). -->
              <span class="kg-readout" aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}>
                {Number(ingredient.quantity_kg || 0).toFixed(2)}
              </span>
              <input bind:value={ingredient.notes} aria-label={`Notes for ${rawMaterialName(ingredient.raw_material_id)}`} />
              <button class="clear-button remove-button" type="button" onclick={() => removeIngredient(index)}>Remove</button>
            {/each}
          </div>

          <div class="ingredient-footer">
            <span class="footer-total">Total {ingredientTotalKg.toFixed(2)} kg</span>
            <button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
            <button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}` || !percentBalanced} onclick={saveIngredients}>
              {savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
            </button>
          </div>
        </div>
      {/if}
    {:else}
      <div class="empty">
        <p>No mixes match your search</p>
        <button type="button" class="clear-button" onclick={clearFilters}>Clear filters</button>
      </div>
    {/each}
    </div>
  </section>

  {#if historyMix}
    <ChangeHistoryModal
      entityType="mix"
      entityId={historyMix.id}
      title={historyMix.name}
      subtitle={historyMix.client_name}
      onClose={() => (historyMix = null)}
    />
  {/if}

  {#if pendingRow}
    <div class="modal-backdrop" role="presentation" onclick={() => (pendingRow = null)}>
      <div
        class="modal-card"
        role="dialog"
        aria-modal="true"
        aria-labelledby="unsaved-title"
        tabindex="-1"
        onclick={(event) => event.stopPropagation()}
        onkeydown={(event) => { if (event.key === 'Escape') pendingRow = null; }}
      >
        <div class="modal-icon"><TriangleAlert size={22} strokeWidth={2.2} /></div>
        <h2 id="unsaved-title" class="modal-title">Unsaved changes</h2>
        <p class="modal-text">
          You have unsaved ingredient changes. Leaving this mix will discard them.
        </p>
        <div class="modal-actions">
          <button type="button" class="modal-cancel" onclick={() => (pendingRow = null)}>Keep editing</button>
          <button type="button" class="modal-confirm" onclick={confirmDiscardChanges}>Discard changes</button>
        </div>
      </div>
    </div>
  {/if}
</AppSecondaryRailLayout>

<style>
  .editor {
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
    min-height: 100%;
    padding: 1rem 1.15rem 2rem;
    background: var(--color-bg-app);
  }

  :global(.secondary-rail-layout) {
    margin-bottom: 1.25rem;
  }

  :global(.secondary-rail-layout-panel),
  :global(.secondary-rail-layout-content) {
    background: var(--color-bg-app);
  }

  .filter-rail {
    position: sticky;
    top: 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    height: 100%;
    min-height: calc(100vh - 8.5rem);
    background: var(--color-bg-surface);
    border-right: 1px solid var(--line);
    overflow-y: auto;
  }

  .rail-label {
    margin: 0;
    padding: 1rem 1rem 0.15rem;
    color: var(--color-text-muted);
    font-size: 0.64rem;
    font-weight: 700;
    letter-spacing: 0.14em;
    text-transform: uppercase;
  }

  .rail-identity {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0 1rem 1.15rem;
    border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
  }

  .rail-avatar {
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: 2.15rem;
    height: 2.15rem;
    border: 1px solid color-mix(in srgb, var(--color-brand) 22%, var(--color-border));
    border-radius: 50%;
    background: var(--color-brand-tint);
    color: var(--color-brand);
  }

  .rail-identity-text {
    min-width: 0;
  }

  .identity-name,
  .identity-role {
    margin: 0;
  }

  .identity-name {
    color: var(--color-text-primary);
    font-size: 0.8rem;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .identity-role {
    color: var(--color-text-muted);
    font-size: 0.72rem;
  }

  .filter-rail-body {
    display: grid;
    gap: 0.85rem;
    padding: 0.8rem 0.8rem 1rem;
  }

  .rail-clear {
    width: 100%;
  }

  .status-band {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1.5rem 2rem;
    flex-wrap: wrap;
    padding: 0.9rem 1.1rem;
    background: var(--color-brand-tint);
    border: 1px solid color-mix(in srgb, var(--color-brand) 32%, var(--color-border));
    border-radius: 0.9rem;
  }

  .editor-status {
    display: flex;
    align-items: center;
    gap: 0.6rem;
  }

  .editor-status span {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
  }

  .editor-status strong {
    font-size: 1.15rem;
    font-weight: 700;
    color: var(--color-text-primary);
  }

  .editor-status small {
    color: var(--color-text-secondary);
    font-size: 0.88rem;
  }

  .status-actions {
    display: flex;
    align-items: center;
    gap: 1.35rem;
    flex-wrap: wrap;
  }

  .new-mix-button {
    flex-shrink: 0;
  }

  .facts {
    display: flex;
    gap: 1.35rem;
    margin: 0;
  }

  .create-panel {
    display: flex;
    flex-direction: column;
    gap: 0.85rem;
    padding: 1rem 1.1rem;
    background: var(--color-bg-surface);
    border: 1px solid color-mix(in srgb, var(--color-brand) 32%, var(--color-border));
    border-radius: 0.9rem;
  }

  .create-head {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
  }

  .create-head strong {
    color: var(--color-text-primary);
    font-size: 1.02rem;
    font-weight: 700;
  }

  .create-head span {
    color: var(--color-text-secondary);
    font-size: 0.86rem;
  }

  .create-fields {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
    gap: 0.75rem;
    align-items: start;
  }

  .req {
    color: var(--color-error);
    font-weight: 700;
  }

  .field-hint {
    margin-top: 0.1rem;
    color: var(--color-text-muted);
    font-size: 0.76rem;
    font-weight: 500;
  }

  .create-actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 0.6rem;
  }

  @media (max-width: 760px) {
    .create-fields {
      grid-template-columns: 1fr;
    }
  }

  .fact {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    text-align: right;
  }

  .fact dt {
    color: var(--color-text-secondary);
    font-size: 0.78rem;
    font-weight: 500;
  }

  .fact dd {
    margin: 0;
    color: var(--color-text-primary);
    font-size: 1.25rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
  }

  .row-actions,
  .ingredient-footer,
  .ingredient-summary {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    flex-wrap: wrap;
  }

  .clear-button,
  .apply-button,
  .link-button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.45rem;
    min-height: 34px;
    border-radius: 0.45rem;
    padding: 0.45rem 0.65rem;
    font-size: 0.86rem;
    font-weight: 650;
    cursor: pointer;
  }

  .clear-button {
    color: var(--color-text-primary);
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border);
  }

  .clear-button:hover {
    border-color: var(--color-text-muted);
  }

  .clear-button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .apply-button {
    color: var(--color-on-brand);
    background: var(--color-brand);
    border: 1px solid var(--color-brand);
  }

  .apply-button:disabled {
    opacity: 0.55;
    cursor: not-allowed;
  }

  .link-button {
    color: var(--color-text-secondary);
    background: transparent;
    border: 0;
    padding-inline: 0.2rem;
  }

  .field-label {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    color: var(--color-text-muted);
    font-size: 0.76rem;
    font-weight: 700;
  }

  .pagination-bar {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 0.8rem;
    flex-wrap: wrap;
    color: var(--color-text-secondary);
    font-size: 0.84rem;
    font-weight: 600;
  }

  .page-size {
    flex-direction: row;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.84rem;
  }

  .page-size select {
    width: 4.6rem;
    min-height: 32px;
    padding-block: 0.25rem;
  }

  .page-controls {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
  }

  .icon-button {
    width: 32px;
    min-height: 32px;
    padding: 0;
  }

  .filter-search {
    min-width: 0;
  }

  .status-filter {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    min-width: 0;
  }

  .segmented-control {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    min-height: 34px;
    padding: 0.18rem;
    border: 1px solid var(--color-border);
    border-radius: 0.42rem;
    background: var(--color-bg-app);
  }

  .segmented-control button {
    min-width: 0;
    border: 0;
    border-radius: 0.32rem;
    background: transparent;
    color: var(--color-text-secondary);
    font-size: 0.82rem;
    font-weight: 700;
    cursor: pointer;
  }

  .segmented-control button:hover {
    color: var(--color-text-primary);
    background: var(--color-bg-surface);
  }

  .segmented-control button.active {
    color: var(--color-brand);
    background: var(--color-bg-surface);
    box-shadow: 0 0 0 1px var(--color-border);
  }

  label {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    color: var(--color-text-primary);
    font-size: 0.9rem;
    font-weight: 500;
  }

  label span,
  .cell-label,
  .grid-label {
    color: var(--color-text-muted);
    font-size: 0.76rem;
    font-weight: 650;
  }

  .search-input {
    position: relative;
    display: flex;
    align-items: center;
  }

  .search-input :global(svg) {
    position: absolute;
    left: 0.58rem;
    color: var(--color-text-muted);
    pointer-events: none;
  }

  .search-input input {
    padding-left: 1.95rem;
  }

  input,
  select {
    width: 100%;
    min-height: 36px;
    border: 1px solid var(--color-border);
    border-radius: 0.42rem;
    background: var(--color-bg-surface);
    color: var(--color-text-primary);
    padding: 0.38rem 0.5rem;
    font-size: 0.88rem;
  }

  select {
    cursor: pointer;
  }

  input::placeholder {
    color: var(--color-text-muted);
  }

  input:hover,
  select:hover {
    border-color: var(--color-text-muted);
  }

  input:focus-visible,
  select:focus-visible,
  button:focus-visible {
    outline: 3px solid var(--color-brand);
    outline-offset: 2px;
    border-color: var(--color-brand);
  }

  .log {
    display: flex;
    flex-direction: column;
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    border-radius: 0.9rem;
    overflow: hidden;
  }

  .log-head,
  .row {
    display: grid;
    grid-template-columns:
      minmax(170px, 1fr)
      minmax(220px, 1.6fr)
      minmax(110px, 0.5fr)
      minmax(198px, auto);
    gap: 0.55rem;
    align-items: center;
  }

  .log-head {
    position: sticky;
    top: 0;
    z-index: 1;
    padding: 0.55rem 0.85rem;
    background: var(--color-bg-app);
    border-bottom: 1px solid var(--color-border);
    color: var(--color-text-secondary);
    font-size: 0.76rem;
    font-weight: 650;
    text-transform: uppercase;
    letter-spacing: 0;
  }

  .row {
    padding: 0.58rem 0.85rem;
    border-bottom: 1px solid var(--color-divider);
    transition: background-color 140ms ease;
  }

  .row:hover {
    background: var(--color-surface-hover);
  }

  .row.edited {
    background: var(--color-brand-tint);
  }

  .readonly-value {
    display: flex;
    align-items: center;
    min-height: 34px;
    color: var(--color-text-primary);
    font-size: 0.88rem;
    font-weight: 650;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .cell-label {
    display: none;
  }

  .client-cell,
  .mix-cell,
  .status-cell {
    display: flex;
    flex-direction: column;
    gap: 0.22rem;
    min-width: 0;
  }

  .status-toggle {
    display: inline-flex;
    align-items: center;
    flex-direction: row;
    justify-content: flex-start;
    gap: 0.3rem;
    min-height: 34px;
    padding: 0.25rem 0;
    border: 1px solid var(--color-border);
    border-radius: 0.42rem;
    background: transparent;
    border-color: transparent;
    color: var(--color-text-secondary);
    font-size: 0.76rem;
    font-weight: 600;
    cursor: pointer;
    user-select: none;
  }

  .status-toggle input {
    width: 0.85rem;
    min-height: 0.85rem;
    margin: 0;
    accent-color: var(--color-brand);
  }

  .status-toggle span {
    color: inherit;
    font-size: inherit;
    font-weight: inherit;
  }

  .status-toggle.on {
    color: var(--color-success);
  }

  .row-actions {
    justify-content: flex-end;
    gap: 0.4rem;
    flex-wrap: nowrap;
  }

  .row-actions button :global(svg) {
    flex-shrink: 0;
  }

  .ingredient-panel {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    padding: 0.85rem;
    background: var(--panel-soft);
    border-bottom: 1px solid var(--color-divider);
  }

  .ingredient-head {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
  }

  .ingredient-head div:first-child {
    display: flex;
    flex-direction: column;
    gap: 0.18rem;
  }

  .ingredient-head span,
  .ingredient-summary span {
    color: var(--color-text-secondary);
    font-size: 0.92rem;
    font-weight: 600;
  }

  .ingredient-head strong {
    color: var(--color-text-primary);
    font-size: 1.05rem;
  }

  .ingredient-summary span {
    padding: 0.35rem 0.65rem;
    border: 1px solid var(--color-border);
    border-radius: 999px;
    background: var(--color-bg-surface);
  }

  .ingredient-grid {
    display: grid;
    grid-template-columns: minmax(220px, 1fr) minmax(88px, 0.22fr) minmax(110px, 0.25fr) minmax(160px, 0.6fr) auto;
    gap: 0.45rem;
    align-items: center;
  }

  .total-field {
    flex-direction: row;
    align-items: center;
    gap: 0.45rem;
  }

  .total-field input {
    width: 7.5rem;
  }

  .percent-chip {
    display: inline-flex;
    align-items: center;
    padding: 0.35rem 0.7rem;
    border: 1px solid color-mix(in srgb, var(--color-success) 40%, var(--color-border));
    border-radius: 999px;
    background: var(--color-bg-surface);
    color: var(--color-success);
    font-size: 0.92rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
  }

  .percent-chip.off {
    border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-border));
    color: var(--color-error);
  }

  /* Read-only kg cell: looks like a quiet field, not an input, so it reads as a
     calculated value rather than something editable. */
  .kg-readout {
    display: inline-flex;
    align-items: center;
    min-height: 36px;
    padding: 0.38rem 0.5rem;
    color: var(--color-text-secondary);
    font-size: 0.88rem;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
  }

  .footer-total {
    margin-right: auto;
    color: var(--color-text-secondary);
    font-size: 0.9rem;
    font-weight: 650;
    font-variant-numeric: tabular-nums;
  }

  .remove-button {
    color: var(--color-error);
    border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border));
  }

  .ingredient-footer {
    justify-content: flex-end;
  }

  .empty {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    padding: 1.5rem;
    color: var(--color-text-secondary);
  }

  .empty p {
    margin: 0;
    font-weight: 600;
  }

  @media (max-width: 1180px) {
    .log-head {
      display: none;
    }

    .row {
      grid-template-columns: 1fr;
    }

    .cell-label {
      display: block;
    }

    .row-actions {
      justify-content: flex-start;
    }

    .ingredient-grid {
      grid-template-columns: 1fr;
    }

    .grid-label {
      display: none;
    }
  }

  @media (max-width: 980px) {
    .filter-rail {
      position: static;
      min-height: auto;
      height: auto;
      border-right: none;
      overflow: visible;
    }

    .filter-rail-body {
      grid-template-columns: repeat(2, minmax(0, 1fr));
      align-items: end;
    }

    .filter-search {
      grid-column: 1 / -1;
    }
  }

  @media (max-width: 760px) {
    .filter-rail-body {
      grid-template-columns: 1fr;
    }

    .facts {
      width: 100%;
      justify-content: space-between;
      gap: 1rem;
    }

    .fact {
      text-align: left;
    }
  }

  /* Unsaved-changes confirm dialog, mirroring the throughput delete dialog. */
  .modal-backdrop {
    position: fixed;
    inset: 0;
    z-index: 80;
    display: grid;
    place-items: center;
    padding: 1.5rem;
    background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
    backdrop-filter: blur(6px);
  }

  .modal-card {
    width: min(28rem, 100%);
    display: grid;
    gap: 0.7rem;
    padding: 1.6rem;
    border: 1px solid var(--color-border);
    border-radius: 1rem;
    background: var(--color-bg-surface);
    box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
  }

  .modal-card:focus {
    outline: none;
  }

  .modal-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.8rem;
    height: 2.8rem;
    border-radius: 0.8rem;
    background: var(--color-warning-tint);
    color: var(--color-warning-text);
  }

  .modal-title,
  .modal-text {
    margin: 0;
  }

  .modal-title {
    font-size: 1.25rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: var(--color-text-primary);
  }

  .modal-text {
    font-size: 0.98rem;
    line-height: 1.5;
    color: var(--color-text-secondary);
  }

  .modal-actions {
    display: flex;
    justify-content: flex-end;
    gap: 0.6rem;
    margin-top: 0.55rem;
  }

  .modal-cancel,
  .modal-confirm {
    min-height: 44px;
    padding: 0.6rem 1.15rem;
    border-radius: 0.7rem;
    font-size: 0.98rem;
    font-weight: 650;
    cursor: pointer;
    transition: background-color 150ms ease, border-color 150ms ease;
  }

  .modal-cancel {
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    color: var(--color-text-secondary);
  }

  .modal-cancel:hover {
    color: var(--color-text-primary);
    border-color: var(--color-text-muted);
  }

  .modal-confirm {
    background: var(--color-error);
    border: 1px solid var(--color-error);
    color: #fff;
  }

  .modal-confirm:hover {
    background: color-mix(in srgb, var(--color-error) 85%, black);
  }

  .modal-cancel:focus-visible,
  .modal-confirm:focus-visible {
    outline: 3px solid var(--color-brand);
    outline-offset: 2px;
  }
</style>
