import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import {
  ArrowLeft,
  Check,
  CheckCircle2,
  Circle,
  Clapperboard,
  Eye,
  EyeOff,
  Film,
  FolderTree,
  Hash,
  Image as ImageIcon,
  Info,
  ListOrdered,
  MinusCircle,
  Pencil,
  Plus,
  RotateCcw,
  Search,
  SearchX,
  Settings2,
  Shuffle,
  Sparkles,
  TriangleAlert,
  Trash2,
  Tv,
  XCircle
} from 'lucide-react';
import {
  Badge,
  Button,
  ChannelLogo,
  Checkbox,
  IconButton,
  Input,
  Select,
  SlideOver,
  Spinner,
  Stat,
  Switch,
  Tag,
  Tooltip
} from '../components';
import {
  createAutoTunedChannels,
  getAutoTuneChannelMembers,
  getChannelTemplates,
  getDefaultChannelTemplate,
  getFFmpegProfiles,
  getFillerPresets,
  getSearchResults,
  getWatermarks,
  messageFromAutoTuneError,
  messageFromChannelTemplateError,
  messageFromSearchError,
  uploadArtwork,
  previewAutoTune,
  type AutoTuneAxis,
  type AutoTunedChannelRequest,
  type AutoTuneMember,
  type AutoTuneProposal,
  type AutoTuneResult,
  type AutoTuneSourceWeightRequest,
  type ChannelTemplate,
  type FFmpegProfile,
  type FillerPreset,
  type Watermark
} from '../api';
import { registerNavigationGuard } from '../navigationGuard';
import {
  applyOverridesToRequest,
  FIXED_START_TIME_BEHAVIORS,
  IDLE_BEHAVIORS,
  MUSIC_VIDEO_CREDITS_MODES,
  SONG_VIDEO_MODES,
  STREAM_SELECTOR_MODES,
  STREAMING_MODE_LABELS,
  STREAMING_MODES,
  SUBTITLE_MODES,
  TRANSCODE_MODES,
  effectiveValue,
  inheritOption,
  templatePlaybackOrder,
  useAdvancedOverrides,
  type Overrides,
  type PlaybackOrder,
  type PlayoutMode,
  type StreamingMode
} from '../builder/advancedOptions';

type WizardStep = 'configure' | 'preview' | 'create';

interface AxisMeta {
  id: AutoTuneAxis;
  title: string;
  tagline: string;
  icon: typeof Tv;
  order: string;
  shuffled: boolean;
}

// Display catalogue only — the server owns the query and channel name. Order here mirrors the
// server grouping (TvShow → TvGenre → MovieGenre).
const AXES: AxisMeta[] = [
  { id: 'TvShow', title: 'TV Shows', tagline: 'One 24/7 channel per show', icon: Tv, order: 'Episode order', shuffled: false },
  { id: 'TvGenre', title: 'TV Genres', tagline: 'A channel per episode genre', icon: Clapperboard, order: 'Shuffled', shuffled: true },
  { id: 'MovieGenre', title: 'Movie Genres', tagline: 'A movie channel per genre', icon: Film, order: 'Shuffled', shuffled: true }
];

const eyebrowStyle: CSSProperties = {
  font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)',
  letterSpacing: '0.06em',
  textTransform: 'uppercase',
  color: 'var(--text-disabled)'
};

const monoStyle: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' };

// Selection identity — axis+value uniquely identifies a proposal. Keying on the display
// name would couple two same-named proposals (e.g. a TV show titled "Comedy" and the
// "Comedy" genre), flipping/creating both. An axis id never contains a space, so a single
// space separates it unambiguously from the metadata value.
const proposalKey = (proposal: { axis: string; value: string }) => `${proposal.axis} ${proposal.value}`;

// Per-source weight bounds. Mirrors the server's MultiCollectionItemWeight validator (1..1000) —
// the same bound the multi-collection editor enforces (#404) and the one #425's auto-tune handler
// clamps to. Clamp client-side on blur AND again at save so an out-of-range value never reaches the
// server as a raw 400 (spa-conventions §4a).
const WEIGHT_MIN = 1;
const WEIGHT_MAX = 1000;

// Coerce a free-text weight to a valid integer share: empty / non-numeric / out-of-range all snap
// into [1,1000], with 1 (the floor, = fair share) for a blank or invalid field.
function clampWeight(raw: string): number {
  const parsed = Math.trunc(Number(raw));
  if (!Number.isFinite(parsed)) {
    return WEIGHT_MIN;
  }
  return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, parsed));
}

// One per-source correction in the draft, keyed by the member's source id (a show id on the TV axes,
// a movie id on MovieGenre — the ids GET /channels/auto-tune/members returns). `weight` is held as a
// string so the number field edits smoothly (transient empty/partial values), clamped on blur and at
// save. `addedTitle` is set only for an add-untagged source — one that is NOT in the axis's base set,
// so the members list can't supply its title/artwork for the row.
interface SourceDraft {
  weight: string;
  excluded: boolean;
  addedTitle?: string;
  addedArtwork?: string;
}

const defaultSourceDraft = (): SourceDraft => ({ weight: String(WEIGHT_MIN), excluded: false });

// A source only counts as customized when it actually diverges from fair-share: a non-default weight,
// an exclusion, or an added out-of-axis id. This is exactly the server's `customized` predicate in
// CreateAutoTunedChannelsHandler.BuildWeightedPlan — an all-default list is a no-op there, so we keep
// it off the wire entirely rather than send noise.
const sourceCustomized = (draft: SourceDraft): boolean =>
  draft.excluded || draft.addedTitle != null || clampWeight(draft.weight) !== WEIGHT_MIN;

// A per-channel edit accumulated in the Preview step's DetailPanel, keyed by proposalKey.
// Everything is optional/inherited: an unset field falls back to the proposal default or the
// (per-channel-or-batch) template. playbackOrder + playoutMode mirror the Channel Builder's
// dedicated-state model (see builder/advancedOptions ADVANCED_KEYS comment) — the Shuffle and
// Always-playing toggles — and are merged into `advanced` only when the user diverges from the
// template default. `sources` carries the #425 per-source weights / corrections, keyed by source id.
interface ChannelOverride {
  name?: string;
  number?: string;
  templateId?: string; // '' / undefined = inherit the batch template
  logoFile?: File;
  logoPreview?: string;
  playbackOrder?: PlaybackOrder;
  playoutMode?: PlayoutMode;
  advanced: Overrides;
  sources?: Record<number, SourceDraft>;
}

const emptyOverride = (): ChannelOverride => ({ advanced: {} });

// Draft → request mapping for the #425 `sources` array. Only genuinely customized sources are sent
// (an all-default array is a backend no-op, so omitting it keeps the request clean and keeps the
// channel on the cheaper single-SmartCollection fair-share shape). Returns undefined when nothing
// was customized. Weights are clamped here too — the blur clamp is feedback, this one is
// authoritative (a value can reach save unblurred).
function sourcesRequest(override: ChannelOverride | undefined): AutoTuneSourceWeightRequest[] | undefined {
  const entries = Object.entries(override?.sources ?? {}).filter(([, draft]) => sourceCustomized(draft));
  if (entries.length === 0) {
    return undefined;
  }
  return entries.map(([id, draft]) => ({
    sourceId: Number(id),
    weight: clampWeight(draft.weight),
    excluded: draft.excluded
  }));
}

// True when the user actually diverged this proposal from its defaults (drives the "Edited"
// row badge + the unsaved-changes guard). A name/number equal to the proposal default does
// not count as edited.
function overrideEdited(override: ChannelOverride | undefined, proposal: AutoTuneProposal): boolean {
  if (!override) {
    return false;
  }
  return Boolean(
    // A cleared field (trimmed to '') is NOT an edit — it inherits the proposal default, the same
    // value buildChannels submits (`override.name?.trim() || proposal.name`). Only a non-empty value
    // that differs counts, so the "Edited" badge + dirty guard match what actually gets created.
    (override.name != null && override.name.trim() !== '' && override.name.trim() !== proposal.name) ||
      (override.number != null && override.number.trim() !== '' && override.number.trim() !== proposal.number) ||
      override.templateId ||
      override.logoFile ||
      override.playbackOrder != null ||
      override.playoutMode != null ||
      Object.keys(override.advanced).length > 0 ||
      // A weight/exclusion/added source only counts once it diverges from fair-share, mirroring the
      // cleared-name rule above: a source row touched and set back to weight 1 is not an edit.
      sourcesRequest(override) != null
  );
}

type TemplatesState =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | {
      status: 'success';
      templates: ChannelTemplate[];
      ffmpegProfiles: FFmpegProfile[];
      fillerPresets: FillerPreset[];
      watermarks: Watermark[];
    };

type PreviewState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; proposals: AutoTuneProposal[] };

type CreateState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; result: AutoTuneResult };

const RESULT_META: Record<string, { icon: typeof CheckCircle2; tone: 'ok' | 'neutral' | 'error'; color: string }> = {
  Created: { icon: CheckCircle2, tone: 'ok', color: 'var(--status-ok)' },
  Skipped: { icon: MinusCircle, tone: 'neutral', color: 'var(--text-secondary)' },
  Failed: { icon: XCircle, tone: 'error', color: 'var(--status-error)' }
};

export function AutoTuneScreen() {
  const [step, setStep] = useState<WizardStep>('configure');
  const [axisIds, setAxisIds] = useState<Set<AutoTuneAxis>>(() => new Set<AutoTuneAxis>(['TvShow', 'TvGenre', 'MovieGenre']));
  const [minItems, setMinItems] = useState('5');
  const [startingNumber, setStartingNumber] = useState('500');
  const [group, setGroup] = useState('Auto-Tuned');
  const [templateId, setTemplateId] = useState('');

  const [templatesState, setTemplatesState] = useState<TemplatesState>({ status: 'loading' });
  const [previewState, setPreviewState] = useState<PreviewState>({ status: 'idle' });
  const [createState, setCreateState] = useState<CreateState>({ status: 'idle' });
  const [selected, setSelected] = useState<Set<string>>(() => new Set());
  const [overrides, setOverrides] = useState<Record<string, ChannelOverride>>({});
  const [detailKey, setDetailKey] = useState<string | null>(null);

  // Cache each per-channel logo upload by File so a create retry doesn't orphan a fresh
  // artwork every attempt (mirrors ChannelBuilder's uploadedLogoRef).
  const uploadedLogosRef = useRef<Map<File, { path: string; contentType: string }>>(new Map());

  const activeRef = useRef(true);
  const templatesSeqRef = useRef(0);
  const previewSeqRef = useRef(0);
  const createSeqRef = useRef(0);

  useEffect(() => {
    activeRef.current = true;
    return () => {
      activeRef.current = false;
    };
  }, []);

  const loadTemplates = useCallback(() => {
    const id = ++templatesSeqRef.current;
    // Pickers (ffmpeg profiles / filler presets / watermarks) back the DetailPanel's Advanced
    // pane; load them alongside templates so a Configure panel opens without a second spinner.
    Promise.all([
      getChannelTemplates(),
      getDefaultChannelTemplate(),
      getFFmpegProfiles(),
      getFillerPresets(),
      getWatermarks()
    ])
      .then(([templates, preferred, ffmpegProfiles, fillerPresets, watermarks]) => {
        if (!activeRef.current || id !== templatesSeqRef.current) {
          return;
        }
        setTemplatesState({ status: 'success', templates, ffmpegProfiles, fillerPresets, watermarks });
        const fallback = preferred ?? templates[0] ?? null;
        if (fallback) {
          setTemplateId(String(fallback.id));
        }
      })
      .catch((error: unknown) => {
        if (activeRef.current && id === templatesSeqRef.current) {
          setTemplatesState({ status: 'error', message: messageFromChannelTemplateError(error) });
        }
      });
  }, []);

  useEffect(() => {
    loadTemplates();
  }, [loadTemplates]);

  const toggleAxis = (id: AutoTuneAxis) => {
    setAxisIds((current) => {
      const next = new Set(current);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  };

  const runPreview = () => {
    if (axisIds.size === 0) {
      return;
    }
    const id = ++previewSeqRef.current;
    setStep('preview');
    setPreviewState({ status: 'loading' });
    // A fresh preview is a clean slate: drop any per-channel edits from a prior batch. They are
    // keyed by axis+value and would silently re-attach to the re-enumerated proposals — including a
    // pinned channel number that no longer fits the server's recomputed numbering (collision risk).
    setOverrides({});
    setDetailKey(null);
    uploadedLogosRef.current = new Map();
    previewAutoTune({
      axes: AXES.filter((axis) => axisIds.has(axis.id)).map((axis) => axis.id),
      minItems: Math.max(1, Number.parseInt(minItems, 10) || 1),
      startingNumber: Number.parseInt(startingNumber, 10) || 1
    })
      .then((proposals) => {
        if (!activeRef.current || id !== previewSeqRef.current) {
          return;
        }
        setPreviewState({ status: 'success', proposals });
        // Default selection: every proposal that doesn't already exist.
        setSelected(new Set(proposals.filter((proposal) => !proposal.alreadyExists).map(proposalKey)));
      })
      .catch((error: unknown) => {
        if (activeRef.current && id === previewSeqRef.current) {
          setPreviewState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to preview channels') });
        }
      });
  };

  const proposals = previewState.status === 'success' ? previewState.proposals : [];
  const selectedProposals = proposals.filter(
    (proposal) => !proposal.alreadyExists && selected.has(proposalKey(proposal))
  );

  const patchOverride = useCallback(
    (key: string, delta: Partial<ChannelOverride> | ((current: ChannelOverride) => Partial<ChannelOverride>)) => {
      setOverrides((prev) => {
        const current = prev[key] ?? emptyOverride();
        const resolved = typeof delta === 'function' ? delta(current) : delta;
        return { ...prev, [key]: { ...current, ...resolved } };
      });
    },
    []
  );

  const patchAdvanced = useCallback((key: string, updater: (prev: Overrides) => Overrides) => {
    setOverrides((prev) => {
      const current = prev[key] ?? emptyOverride();
      return { ...prev, [key]: { ...current, advanced: updater(current.advanced) } };
    });
  }, []);

  // Build the per-channel create payload, uploading any per-channel logo first and folding the
  // Shuffle/Always-playing toggles back into `advanced` (only when diverged from the template).
  const buildChannels = async (chosen: AutoTuneProposal[]): Promise<AutoTunedChannelRequest[]> =>
    Promise.all(
      chosen.map(async (proposal) => {
        const override = overrides[proposalKey(proposal)];
        const channel: AutoTunedChannelRequest = {
          axis: proposal.axis as AutoTuneAxis,
          value: proposal.value,
          name: override?.name?.trim() || proposal.name,
          number: override?.number?.trim() || proposal.number
        };
        if (override?.templateId) {
          channel.templateId = Number(override.templateId);
        }
        if (override?.logoFile) {
          let logo = uploadedLogosRef.current.get(override.logoFile);
          if (!logo) {
            const uploaded = await uploadArtwork(override.logoFile, 'logo');
            logo = { path: uploaded.path, contentType: uploaded.contentType };
            uploadedLogosRef.current.set(override.logoFile, logo);
          }
          channel.logo = logo;
        }
        // applyOverridesToRequest copies set overrides and folds any CLEAR sentinels into
        // advanced.clear, so the sentinel never leaks onto the wire as a field value (#135).
        const advanced = applyOverridesToRequest<
          Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode }
        >({}, override?.advanced ?? {});
        if (override?.playbackOrder != null) {
          advanced.playbackOrder = override.playbackOrder;
        }
        if (override?.playoutMode != null) {
          advanced.playoutMode = override.playoutMode;
        }
        if (Object.keys(advanced).length > 0) {
          channel.advanced = advanced as AutoTunedChannelRequest['advanced'];
        }
        // Per-source weights / exclusions / added sources (#425). Omitted entirely when nothing was
        // customized so the server keeps the fair-share single-SmartCollection shape.
        const sources = sourcesRequest(override);
        if (sources) {
          channel.sources = sources;
        }
        return channel;
      })
    );

  const runCreate = () => {
    if (selectedProposals.length === 0 || templateId === '') {
      return;
    }
    const id = ++createSeqRef.current;
    const chosen = selectedProposals;
    setStep('create');
    setCreateState({ status: 'loading' });
    buildChannels(chosen)
      .then((channels) => createAutoTunedChannels({ templateId: Number(templateId), group, channels }))
      .then((result) => {
        if (activeRef.current && id === createSeqRef.current) {
          setCreateState({ status: 'success', result });
        }
      })
      .catch((error: unknown) => {
        if (activeRef.current && id === createSeqRef.current) {
          setCreateState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to create channels') });
        }
      });
  };

  const restart = () => {
    setPreviewState({ status: 'idle' });
    setCreateState({ status: 'idle' });
    setSelected(new Set());
    setOverrides({});
    setDetailKey(null);
    setStep('configure');
  };

  // Unsaved-changes guard (spa-conventions §8): per-channel edits live only in this screen's
  // draft state until bulk-create, so leaving the screen (or a full-page unload) with edits that
  // haven't been created yet must confirm first. Closing the DetailPanel keeps edits in state, so
  // only navigation/unload is guarded, not panel close.
  const hasUncreatedEdits =
    createState.status !== 'success' && proposals.some((proposal) => overrideEdited(overrides[proposalKey(proposal)], proposal));
  const dirtyRef = useRef(false);
  useEffect(() => {
    dirtyRef.current = hasUncreatedEdits;
  }, [hasUncreatedEdits]);
  useEffect(
    () =>
      registerNavigationGuard(
        () => !dirtyRef.current || window.confirm('Discard your per-channel edits? They have not been created yet.')
      ),
    []
  );
  useEffect(() => {
    if (!hasUncreatedEdits) {
      return;
    }
    const onBeforeUnload = (event: BeforeUnloadEvent) => {
      event.preventDefault();
      event.returnValue = '';
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => window.removeEventListener('beforeunload', onBeforeUnload);
  }, [hasUncreatedEdits]);

  const templateOptions =
    templatesState.status === 'success'
      ? templatesState.templates.map((template) => ({ value: String(template.id), label: template.name }))
      : [];

  // Resolve the proposal + effective template backing the open DetailPanel. The per-channel
  // template override wins over the batch template; fall back to the batch/first template.
  const detailProposal = detailKey ? (proposals.find((proposal) => proposalKey(proposal) === detailKey) ?? null) : null;
  const detailOverride = detailKey ? (overrides[detailKey] ?? emptyOverride()) : emptyOverride();
  const detailTemplate =
    templatesState.status === 'success'
      ? (templatesState.templates.find((template) => String(template.id) === (detailOverride.templateId || templateId)) ??
        templatesState.templates.find((template) => String(template.id) === templateId) ??
        templatesState.templates[0] ??
        null)
      : null;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: 14,
          padding: '12px 20px',
          borderBottom: '1px solid var(--border-hairline)',
          flex: '0 0 auto'
        }}
      >
        <span
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            width: 34,
            height: 34,
            borderRadius: 'var(--radius-sm)',
            background: 'var(--ctv-accent-soft)',
            color: 'var(--ctv-accent)',
            flex: '0 0 auto'
          }}
        >
          <Sparkles aria-hidden="true" size={19} />
        </span>
        <div style={{ flex: '0 0 auto', minWidth: 0 }}>
          <h2 style={{ margin: 0, font: 'var(--weight-semibold) var(--text-md)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
            Auto-Tune
          </h2>
          <div style={{ marginTop: 3, font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>
            Generate channels from your library
          </div>
        </div>
        <div style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
          <StepRail step={step} />
        </div>
        {step === 'configure' && (
          <Tooltip
            placement="bottom"
            label={axisIds.size ? 'Enumerate the library and preview proposed channels' : 'Pick at least one metadata axis'}
          >
            <Button
              variant="primary"
              disabled={axisIds.size === 0}
              startIcon={<Eye aria-hidden="true" size={15} />}
              onClick={runPreview}
            >
              Preview channels
            </Button>
          </Tooltip>
        )}
        {step === 'preview' && (
          <>
            <Button variant="ghost" startIcon={<ArrowLeft aria-hidden="true" size={15} />} onClick={() => setStep('configure')}>
              Back
            </Button>
            <Tooltip
              placement="bottom"
              label={
                selectedProposals.length === 0
                  ? 'Select at least one channel'
                  : templateId === ''
                    ? 'Choose a channel template on the Configure step first'
                    : 'Create the selected channels'
              }
            >
              <Button
                variant="primary"
                disabled={selectedProposals.length === 0 || templateId === ''}
                startIcon={<Check aria-hidden="true" size={15} />}
                onClick={runCreate}
              >
                {selectedProposals.length
                  ? `Create ${selectedProposals.length} channel${selectedProposals.length === 1 ? '' : 's'}`
                  : 'Create channels'}
              </Button>
            </Tooltip>
          </>
        )}
        {step === 'create' && (
          <Button variant="primary" startIcon={<RotateCcw aria-hidden="true" size={15} />} onClick={restart}>
            Start over
          </Button>
        )}
      </div>

      <main style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
        {step === 'configure' && (
          <ConfigureStep
            axisIds={axisIds}
            toggleAxis={toggleAxis}
            minItems={minItems}
            setMinItems={setMinItems}
            startingNumber={startingNumber}
            setStartingNumber={setStartingNumber}
            group={group}
            setGroup={setGroup}
            templateId={templateId}
            setTemplateId={setTemplateId}
            templatesState={templatesState}
            templateOptions={templateOptions}
            retryTemplates={loadTemplates}
          />
        )}
        {step === 'preview' && (
          <PreviewStep
            state={previewState}
            selected={selected}
            setSelected={setSelected}
            overrides={overrides}
            onConfigure={setDetailKey}
            onRetry={runPreview}
          />
        )}
        {step === 'create' && <CreateStep state={createState} group={group} onRetry={runCreate} />}
      </main>

      {detailProposal && detailTemplate && templatesState.status === 'success' && (
        <DetailPanel
          key={detailKey ?? undefined}
          proposal={detailProposal}
          override={detailOverride}
          templates={templatesState.templates}
          template={detailTemplate}
          batchTemplateId={templateId}
          ffmpegProfiles={templatesState.ffmpegProfiles}
          fillerPresets={templatesState.fillerPresets}
          watermarks={templatesState.watermarks}
          onPatch={(delta) => detailKey && patchOverride(detailKey, delta)}
          onPatchAdvanced={(updater) => detailKey && patchAdvanced(detailKey, updater)}
          onClose={() => setDetailKey(null)}
        />
      )}
    </div>
  );
}

const STEPS: { id: WizardStep; label: string }[] = [
  { id: 'configure', label: 'Configure' },
  { id: 'preview', label: 'Preview' },
  { id: 'create', label: 'Create' }
];

function StepRail({ step }: { step: WizardStep }) {
  const index = STEPS.findIndex((entry) => entry.id === step);
  return (
    <ol style={{ display: 'flex', alignItems: 'center', gap: 4, margin: 0, padding: 0, listStyle: 'none' }}>
      {STEPS.map((entry, i) => {
        const done = i < index;
        const active = i === index;
        const color = active ? 'var(--ctv-accent)' : done ? 'var(--text-secondary)' : 'var(--text-disabled)';
        return (
          <li key={entry.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
              <span
                aria-hidden="true"
                style={{
                  display: 'inline-flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  width: 20,
                  height: 20,
                  borderRadius: '50%',
                  font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)',
                  background: active ? 'var(--ctv-accent)' : done ? 'var(--ctv-accent-soft)' : 'var(--ctv-surface-2)',
                  color: active ? 'var(--text-on-accent)' : done ? 'var(--ctv-accent)' : 'var(--text-disabled)'
                }}
              >
                {done ? <Check aria-hidden="true" size={12} /> : i + 1}
              </span>
              <span
                aria-current={active ? 'step' : undefined}
                style={{
                  font: `${active ? 'var(--weight-semibold)' : 'var(--weight-medium)'} var(--text-xs)/1 var(--font-sans)`,
                  color
                }}
              >
                {entry.label}
              </span>
            </span>
            {i < STEPS.length - 1 && (
              <span aria-hidden="true" style={{ width: 26, height: 1, background: 'var(--border-control)', margin: '0 4px' }} />
            )}
          </li>
        );
      })}
    </ol>
  );
}

function Field({ label, hint, children }: { label: string; hint: string; children: ReactNode }) {
  return (
    <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
      <span style={{ font: 'var(--weight-medium) var(--text-xs)/1 var(--font-sans)', color: 'var(--text-primary)' }}>{label}</span>
      {children}
      <span style={{ font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</span>
    </label>
  );
}

function ConfigureStep({
  axisIds,
  toggleAxis,
  minItems,
  setMinItems,
  startingNumber,
  setStartingNumber,
  group,
  setGroup,
  templateId,
  setTemplateId,
  templatesState,
  templateOptions,
  retryTemplates
}: {
  axisIds: Set<AutoTuneAxis>;
  toggleAxis: (id: AutoTuneAxis) => void;
  minItems: string;
  setMinItems: (value: string) => void;
  startingNumber: string;
  setStartingNumber: (value: string) => void;
  group: string;
  setGroup: (value: string) => void;
  templateId: string;
  setTemplateId: (value: string) => void;
  templatesState: TemplatesState;
  templateOptions: { value: string; label: string }[];
  retryTemplates: () => void;
}) {
  return (
    <div style={{ maxWidth: 860, margin: '0 auto', padding: '26px 24px 40px', display: 'flex', flexDirection: 'column', gap: 24 }}>
      <p style={{ margin: 0, font: 'var(--text-sm)/1.55 var(--font-sans)', color: 'var(--text-secondary)', maxWidth: 620 }}>
        Turn your library into a full lineup in one pass. Pick which metadata axes to generate from, preview the proposed
        channels, then create the ones you want. Existing channels are never touched.
      </p>

      <section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        <div style={eyebrowStyle}>Generate from</div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 12 }}>
          {AXES.map((axis) => {
            const on = axisIds.has(axis.id);
            const AxisIcon = axis.icon;
            const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
            return (
              <button
                key={axis.id}
                type="button"
                aria-pressed={on}
                onClick={() => toggleAxis(axis.id)}
                className="ctv-press"
                style={{
                  textAlign: 'left',
                  cursor: 'pointer',
                  padding: '16px 16px 15px',
                  borderRadius: 'var(--radius-md)',
                  border: `1px solid ${on ? 'var(--ctv-accent)' : 'var(--border-control)'}`,
                  background: on ? 'var(--ctv-accent-soft)' : 'var(--surface-card)',
                  boxShadow: on ? 'var(--shadow-sm)' : 'none',
                  position: 'relative',
                  display: 'flex',
                  flexDirection: 'column',
                  gap: 9
                }}
              >
                <span style={{ position: 'absolute', top: 12, right: 12, color: on ? 'var(--ctv-accent)' : 'var(--text-disabled)' }}>
                  {on ? <CheckCircle2 aria-hidden="true" size={17} /> : <Circle aria-hidden="true" size={17} />}
                </span>
                <span
                  style={{
                    display: 'inline-flex',
                    alignItems: 'center',
                    justifyContent: 'center',
                    width: 30,
                    height: 30,
                    borderRadius: 'var(--radius-sm)',
                    background: on ? 'var(--ctv-accent)' : 'var(--ctv-surface-2)',
                    color: on ? 'var(--text-on-accent)' : 'var(--text-secondary)'
                  }}
                >
                  <AxisIcon aria-hidden="true" size={17} />
                </span>
                <div>
                  <div style={{ font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)', color: 'var(--text-primary)' }}>
                    {axis.title}
                  </div>
                  <div style={{ marginTop: 3, font: 'var(--text-xs)/1.35 var(--font-sans)', color: 'var(--text-secondary)' }}>
                    {axis.tagline}
                  </div>
                </div>
                <div
                  style={{
                    marginTop: 'auto',
                    display: 'inline-flex',
                    alignItems: 'center',
                    gap: 5,
                    font: 'var(--text-2xs)/1 var(--font-sans)',
                    color: 'var(--text-disabled)'
                  }}
                >
                  <OrderIcon aria-hidden="true" size={11} />
                  {axis.order}
                </div>
              </button>
            );
          })}
        </div>
      </section>

      <section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        <div style={eyebrowStyle}>Defaults</div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 14, alignItems: 'start' }}>
          <Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
            <Input
              size="sm"
              type="number"
              value={minItems}
              onChange={(event) => setMinItems(event.target.value)}
              leadingIcon={<Hash aria-hidden="true" size={14} />}
            />
          </Field>
          <Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
            <Input
              size="sm"
              type="number"
              value={startingNumber}
              onChange={(event) => setStartingNumber(event.target.value)}
              leadingIcon={<Tv aria-hidden="true" size={14} />}
            />
          </Field>
          <Field label="Channel group" hint="Every generated channel lands in this group.">
            <Input
              size="sm"
              value={group}
              onChange={(event) => setGroup(event.target.value)}
              leadingIcon={<FolderTree aria-hidden="true" size={14} />}
            />
          </Field>
          <Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
            {templatesState.status === 'loading' ? (
              <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '4px 2px' }} role="status">
                <Spinner size={14} />
                <span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading templates…</span>
              </div>
            ) : templatesState.status === 'error' ? (
              <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="alert">
                <TriangleAlert aria-hidden="true" size={14} style={{ color: 'var(--status-error)' }} />
                <span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--status-error)' }}>{templatesState.message}</span>
                <Button size="sm" variant="secondary" onClick={retryTemplates}>
                  Retry
                </Button>
              </div>
            ) : templateOptions.length === 0 ? (
              <div
                role="status"
                style={{ font: 'var(--text-xs)/1.4 var(--font-sans)', color: 'var(--text-secondary)', padding: '4px 2px' }}
              >
                No channel templates yet — create one under Templates first.
              </div>
            ) : (
              <Select
                size="sm"
                value={templateId}
                onChange={(event) => setTemplateId(event.target.value)}
                options={templateOptions}
              />
            )}
          </Field>
        </div>
      </section>
    </div>
  );
}

function PreviewStep({
  state,
  selected,
  setSelected,
  overrides,
  onConfigure,
  onRetry
}: {
  state: PreviewState;
  selected: Set<string>;
  setSelected: (updater: (current: Set<string>) => Set<string>) => void;
  overrides: Record<string, ChannelOverride>;
  onConfigure: (key: string) => void;
  onRetry: () => void;
}) {
  if (state.status === 'loading' || state.status === 'idle') {
    return (
      <div style={centeredMessageStyle} role="status">
        <Spinner size={22} />
        <div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Enumerating your library…</div>
      </div>
    );
  }

  if (state.status === 'error') {
    return (
      <div style={centeredMessageStyle} role="alert">
        <TriangleAlert aria-hidden="true" size={24} style={{ color: 'var(--status-error)' }} />
        <div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>{state.message}</div>
        <Button size="sm" variant="secondary" onClick={onRetry}>
          Retry
        </Button>
      </div>
    );
  }

  const { proposals } = state;

  if (proposals.length === 0) {
    return (
      <div style={centeredMessageStyle}>
        <SearchX aria-hidden="true" size={26} style={{ color: 'var(--text-disabled)' }} />
        <div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
          No channels matched — try lowering the minimum items.
        </div>
      </div>
    );
  }

  const selectable = proposals.filter((proposal) => !proposal.alreadyExists);
  const existingCount = proposals.length - selectable.length;
  const selectedCount = selectable.filter((proposal) => selected.has(proposalKey(proposal))).length;

  const setAll = (rows: AutoTuneProposal[], on: boolean) => {
    setSelected((current) => {
      const next = new Set(current);
      rows.forEach((row) => {
        if (!row.alreadyExists) {
          if (on) {
            next.add(proposalKey(row));
          } else {
            next.delete(proposalKey(row));
          }
        }
      });
      return next;
    });
  };

  const toggle = (key: string) => {
    setSelected((current) => {
      const next = new Set(current);
      if (next.has(key)) {
        next.delete(key);
      } else {
        next.add(key);
      }
      return next;
    });
  };

  const groups = AXES.map((axis) => ({ axis, rows: proposals.filter((proposal) => proposal.axis === axis.id) })).filter(
    (entry) => entry.rows.length > 0
  );

  return (
    <div style={{ maxWidth: 920, margin: '0 auto', padding: '20px 24px 40px', display: 'flex', flexDirection: 'column', gap: 16 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
        <span style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>
          <span style={{ ...monoStyle, color: 'var(--text-primary)', fontWeight: 600 }}>{selectedCount}</span> of{' '}
          {selectable.length} selected
        </span>
        {existingCount > 0 && (
          <Tag icon={<CheckCircle2 aria-hidden="true" size={11} />} tone="neutral">
            {existingCount} already exist — deselected
          </Tag>
        )}
        <span style={{ flex: 1 }} />
        <Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>
          Select all
        </Button>
        <Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>
          Clear
        </Button>
      </div>

      {groups.map(({ axis, rows }) => {
        const groupSelectable = rows.filter((row) => !row.alreadyExists);
        const allOn = groupSelectable.length > 0 && groupSelectable.every((row) => selected.has(proposalKey(row)));
        const someOn = groupSelectable.some((row) => selected.has(proposalKey(row)));
        const AxisIcon = axis.icon;
        const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
        return (
          <section
            key={axis.id}
            style={{
              border: '1px solid var(--border-hairline)',
              borderRadius: 'var(--radius-md)',
              overflow: 'hidden',
              background: 'var(--surface-card)'
            }}
          >
            <div
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: 10,
                padding: '10px 14px',
                borderBottom: '1px solid var(--border-hairline)',
                background: 'var(--ctv-bg-sunken)'
              }}
            >
              <Checkbox
                checked={allOn}
                indeterminate={someOn && !allOn}
                disabled={groupSelectable.length === 0}
                label={`Select all ${axis.title}`}
                onChange={() => setAll(rows, !allOn)}
              />
              <AxisIcon aria-hidden="true" size={15} />
              <span style={{ font: 'var(--weight-semibold) var(--text-sm)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
                {axis.title}
              </span>
              <Badge tone="neutral">{rows.length}</Badge>
              <span style={{ flex: 1 }} />
              <span style={{ ...eyebrowStyle, display: 'inline-flex', alignItems: 'center', gap: 5 }}>
                <OrderIcon aria-hidden="true" size={11} />
                {axis.order}
              </span>
            </div>
            <div>
              {rows.map((row, i) => {
                const key = proposalKey(row);
                const on = selected.has(key);
                const displayName = overrides[key]?.name?.trim() || row.name;
                const edited = overrideEdited(overrides[key], row);
                return (
                  <div
                    key={key}
                    style={{
                      borderTop: i ? '1px solid var(--border-hairline)' : 'none',
                      opacity: row.alreadyExists ? 0.55 : 1
                    }}
                  >
                    <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '9px 14px' }}>
                      <Checkbox
                        checked={on && !row.alreadyExists}
                        disabled={row.alreadyExists}
                        label={displayName}
                        onChange={() => toggle(key)}
                      />
                      <span style={{ ...monoStyle, minWidth: 42, font: 'var(--text-sm) var(--font-mono)', color: 'var(--text-secondary)' }}>
                        {overrides[key]?.number?.trim() || row.number}
                      </span>
                      <div style={{ flex: 1, minWidth: 0 }}>
                        <div
                          style={{
                            font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)',
                            color: 'var(--text-primary)',
                            whiteSpace: 'nowrap',
                            overflow: 'hidden',
                            textOverflow: 'ellipsis'
                          }}
                        >
                          {displayName}
                        </div>
                        <div style={{ marginTop: 2, font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
                          from “{row.value}”
                        </div>
                      </div>
                      {edited && (
                        <Tag icon={<Pencil aria-hidden="true" size={11} />} tone="accent">
                          Edited
                        </Tag>
                      )}
                      <Badge tone="neutral">
                        <span style={monoStyle}>{row.itemCount}</span>&nbsp;items
                      </Badge>
                      {row.alreadyExists ? (
                        <Tag tone="neutral">Exists</Tag>
                      ) : (
                        <Button
                          size="sm"
                          variant="ghost"
                          startIcon={<Settings2 aria-hidden="true" size={14} />}
                          onClick={() => onConfigure(key)}
                        >
                          Configure
                        </Button>
                      )}
                    </div>
                  </div>
                );
              })}
            </div>
          </section>
        );
      })}
    </div>
  );
}

function CreateStep({ state, group, onRetry }: { state: CreateState; group: string; onRetry: () => void }) {
  if (state.status === 'loading' || state.status === 'idle') {
    return (
      <div style={centeredMessageStyle} role="status">
        <Spinner size={22} />
        <div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Creating channels…</div>
      </div>
    );
  }

  if (state.status === 'error') {
    return (
      <div style={centeredMessageStyle} role="alert">
        <TriangleAlert aria-hidden="true" size={24} style={{ color: 'var(--status-error)' }} />
        <div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>{state.message}</div>
        <Button size="sm" variant="secondary" onClick={onRetry}>
          Retry
        </Button>
      </div>
    );
  }

  const { result } = state;

  return (
    <div style={{ maxWidth: 860, margin: '0 auto', padding: '26px 24px 40px', display: 'flex', flexDirection: 'column', gap: 20 }}>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 12 }}>
        <Stat label="Created" value={result.createdCount} icon={<CheckCircle2 aria-hidden="true" size={16} />} />
        <Stat label="Skipped" value={result.skippedCount} icon={<MinusCircle aria-hidden="true" size={16} />} />
        <Stat label="Failed" value={result.failedCount} icon={<XCircle aria-hidden="true" size={16} />} />
      </div>
      <div
        style={{ display: 'flex', alignItems: 'center', gap: 7, font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}
      >
        <FolderTree aria-hidden="true" size={13} /> Added to group{' '}
        <span style={{ font: 'var(--weight-semibold) var(--text-xs) var(--font-sans)', color: 'var(--text-primary)' }}>{group}</span>
      </div>
      <section
        style={{
          border: '1px solid var(--border-hairline)',
          borderRadius: 'var(--radius-md)',
          overflow: 'hidden',
          background: 'var(--surface-card)'
        }}
      >
        {result.results.map((entry, i) => {
          const meta = RESULT_META[entry.status] ?? RESULT_META.Skipped;
          const StatusIcon = meta.icon;
          return (
            <div
              key={`${entry.name}-${i}`}
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: 12,
                padding: '11px 14px',
                borderTop: i ? '1px solid var(--border-hairline)' : 'none'
              }}
            >
              <span style={{ color: meta.color, display: 'inline-flex' }}>
                <StatusIcon aria-hidden="true" size={17} />
              </span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)', color: 'var(--text-primary)' }}>
                  {entry.name}
                </div>
                {entry.reason && (
                  <div style={{ marginTop: 2, font: 'var(--text-2xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
                    {entry.reason}
                  </div>
                )}
              </div>
              {entry.channelId != null && (
                <span style={{ ...monoStyle, font: 'var(--text-xs) var(--font-mono)', color: 'var(--text-disabled)' }}>
                  #{entry.channelId}
                </span>
              )}
              <Badge tone={meta.tone}>{entry.status}</Badge>
            </div>
          );
        })}
      </section>
    </div>
  );
}

type MembersState =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; totalCount: number; members: AutoTuneMember[] };

// How many distinct members to preview in the Content sources pane. The list is advisory
// (it shows what the smart collection resolves to) so a single page is enough — no infinite scroll.
const MEMBER_PREVIEW_SIZE = 50;

function DetailSection({ label, children, hint }: { label: string; hint?: ReactNode; children: ReactNode }) {
  return (
    <section style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 22 }}>
      <div style={eyebrowStyle}>{label}</div>
      {children}
      {hint != null && <div style={{ font: 'var(--text-2xs)/1.4 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</div>}
    </section>
  );
}

function ToggleRow({
  label,
  hint,
  checked,
  overridesTemplate,
  onChange
}: {
  label: string;
  hint: string;
  checked: boolean;
  overridesTemplate: boolean;
  onChange: (next: boolean) => void;
}) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <Switch checked={checked} onChange={onChange} label={label} />
        {overridesTemplate && <Tag tone="accent">overrides template</Tag>}
      </div>
      <div style={{ marginTop: 1, font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</div>
    </div>
  );
}

function ReadRow({ label, value }: { label: string; value: ReactNode }) {
  return (
    <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
      <span style={{ font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-secondary)' }}>{label}</span>
      <span style={{ font: 'var(--weight-medium) var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-primary)', textAlign: 'right' }}>
        {value}
      </span>
    </div>
  );
}

function DetailPanel({
  proposal,
  override,
  templates,
  template,
  batchTemplateId,
  ffmpegProfiles,
  fillerPresets,
  watermarks,
  onPatch,
  onPatchAdvanced,
  onClose
}: {
  proposal: AutoTuneProposal;
  override: ChannelOverride;
  templates: ChannelTemplate[];
  template: ChannelTemplate;
  batchTemplateId: string;
  ffmpegProfiles: FFmpegProfile[];
  fillerPresets: FillerPreset[];
  watermarks: Watermark[];
  onPatch: (delta: Partial<ChannelOverride> | ((current: ChannelOverride) => Partial<ChannelOverride>)) => void;
  onPatchAdvanced: (updater: (prev: Overrides) => Overrides) => void;
  onClose: () => void;
}) {
  const [advancedOpen, setAdvancedOpen] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  // Per-source draft writers (#440). Each lazily materializes a fair-share draft for the row, so a
  // source only enters `sources` once it is actually touched.
  const patchSource = (id: number, delta: Partial<SourceDraft>) =>
    onPatch((current) => ({
      sources: { ...current.sources, [id]: { ...defaultSourceDraft(), ...current.sources?.[id], ...delta } }
    }));

  const removeSource = (id: number) =>
    onPatch((current) => {
      const next = { ...current.sources };
      delete next[id];
      return { sources: next };
    });

  const readImage = (file: File | null | undefined) => {
    if (!file) {
      return;
    }
    const reader = new FileReader();
    reader.onload = () => onPatch({ logoFile: file, logoPreview: typeof reader.result === 'string' ? reader.result : undefined });
    reader.readAsDataURL(file);
  };

  const displayName = override.name?.trim() || proposal.name;
  const displayNumber = override.number?.trim() || proposal.number;

  const templateShuffle = templatePlaybackOrder(template);
  const shuffleOn = (override.playbackOrder ?? templateShuffle) === 'Shuffle';
  const shuffleOverrides = override.playbackOrder != null && override.playbackOrder !== templateShuffle;

  const templatePlayout = ((template as unknown as Record<string, unknown>).playoutMode as PlayoutMode) ?? 'Continuous';
  const alwaysOn = (override.playoutMode ?? templatePlayout) === 'Continuous';
  const alwaysOverrides = override.playoutMode != null && override.playoutMode !== templatePlayout;

  const effectiveStreaming = effectiveValue('streamingMode', template, override.advanced) as StreamingMode | undefined;
  const streamingLabel = effectiveStreaming ? (STREAMING_MODE_LABELS[effectiveStreaming] ?? effectiveStreaming) : '—';

  const batchTemplateName = templates.find((candidate) => String(candidate.id) === batchTemplateId)?.name ?? 'batch template';

  const overrideCount = Object.keys(override.advanced).length;

  return (
    <SlideOver
      open
      onClose={onClose}
      title={displayName}
      subtitle={
        <span style={monoStyle}>
          {displayNumber} · {shuffleOn ? 'Shuffled' : 'In order'}
        </span>
      }
    >
      <DetailSection label="Channel identity">
        <Field label="Name" hint="Shown in the guide and channel list.">
          <Input
            size="sm"
            value={displayName}
            onChange={(event) => onPatch({ name: event.target.value })}
          />
        </Field>
        <Field label="Channel number" hint="Must be unique; the server skips numbers already taken.">
          <Input
            size="sm"
            value={displayNumber}
            onChange={(event) => onPatch({ number: event.target.value })}
            leadingIcon={<Hash aria-hidden="true" size={14} />}
          />
        </Field>
        <div
          role="button"
          tabIndex={0}
          onClick={() => fileInputRef.current?.click()}
          onKeyDown={(event) => {
            if (event.key === 'Enter' || event.key === ' ') {
              event.preventDefault();
              fileInputRef.current?.click();
            }
          }}
          style={{
            display: 'flex',
            alignItems: 'center',
            gap: 12,
            padding: 10,
            borderRadius: 'var(--radius-md)',
            border: '1px dashed var(--border-control)',
            cursor: 'pointer'
          }}
        >
          <ChannelLogo name={displayName} src={override.logoPreview ?? null} size={44} radius="var(--radius-sm)" />
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ font: 'var(--weight-medium) var(--text-xs)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
              {override.logoPreview ? 'Channel image set' : 'Drop or choose a channel image'}
            </div>
            <div style={{ marginTop: 3, font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>
              Used as the guide logo and on-screen bug. Optional.
            </div>
          </div>
          <ImageIcon aria-hidden="true" size={16} style={{ color: 'var(--text-disabled)' }} />
          <input
            ref={fileInputRef}
            type="file"
            accept="image/*"
            style={{ display: 'none' }}
            onChange={(event) => readImage(event.target.files && event.target.files[0])}
          />
        </div>
      </DetailSection>

      <DetailSection label="Playback">
        <ToggleRow
          label="Shuffle"
          hint="Play this channel's items in a shuffled order instead of chronologically."
          checked={shuffleOn}
          overridesTemplate={shuffleOverrides}
          onChange={(next) => onPatch({ playbackOrder: next ? 'Shuffle' : 'Chronological' })}
        />
        <ToggleRow
          label="Always playing"
          hint="Keep a continuous playout running instead of starting on demand."
          checked={alwaysOn}
          overridesTemplate={alwaysOverrides}
          onChange={(next) => onPatch({ playoutMode: next ? 'Continuous' : 'OnDemand' })}
        />
      </DetailSection>

      <DetailSection label="Channel template" hint={`Leave on Inherit to use the batch template (${batchTemplateName}).`}>
        <Select
          size="sm"
          value={override.templateId ?? ''}
          onChange={(event) => onPatch({ templateId: event.target.value })}
          options={[{ value: '', label: `Inherit (${batchTemplateName})` }, ...templates.map((candidate) => ({ value: String(candidate.id), label: candidate.name }))]}
        />
      </DetailSection>

      <DetailSection label="Advanced">
        <button
          type="button"
          className="ctv-press"
          onClick={() => setAdvancedOpen((open) => !open)}
          style={{
            display: 'flex',
            alignItems: 'center',
            gap: 8,
            width: '100%',
            padding: '9px 11px',
            borderRadius: 'var(--radius-md)',
            border: '1px solid var(--border-control)',
            background: 'var(--surface-card)',
            cursor: 'pointer',
            textAlign: 'left'
          }}
        >
          <Settings2 aria-hidden="true" size={15} style={{ color: 'var(--text-secondary)' }} />
          <span style={{ flex: 1, font: 'var(--weight-medium) var(--text-sm)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
            {advancedOpen ? 'Hide advanced settings' : 'Override advanced settings'}
          </span>
          <span style={{ font: 'var(--text-2xs)/1 var(--font-mono)', color: 'var(--text-disabled)' }}>
            {overrideCount > 0 ? `${overrideCount} overridden · ` : ''}
            {template.name}
          </span>
        </button>
        {advancedOpen && (
          <AdvancedFields
            template={template}
            overrides={override.advanced}
            setOverrides={onPatchAdvanced}
            ffmpegProfiles={ffmpegProfiles}
            fillerPresets={fillerPresets}
            watermarks={watermarks}
          />
        )}
      </DetailSection>

      <DetailSection label="Query & size">
        <ReadRow label="Order" value={shuffleOn ? 'Shuffled' : 'In order'} />
        <ReadRow label="Estimated items" value={<span style={monoStyle}>{proposal.itemCount}</span>} />
        <ReadRow label="Streaming mode" value={streamingLabel} />
      </DetailSection>

      <ContentSourcesSection
        proposal={proposal}
        sources={override.sources}
        patchSource={patchSource}
        removeSource={removeSource}
      />
    </SlideOver>
  );
}

// Add-untagged typeahead: how many library matches to offer, and the shortest query worth searching.
const ADD_SOURCE_RESULTS = 8;
const ADD_SOURCE_MIN_QUERY = 2;

// The picker takes plain typed text, not Lucene: the index's default field does NOT match bare title
// words (`Alpha` finds nothing for "Show Alpha" — docs/e2e-local.md), so a raw forward like the search
// box does would look broken here. Compile a substring title match instead, escaping every Lucene
// special (and whitespace) so the boundary stars are the only live wildcards — the same shape the rule
// builder's `contains` operator emits (builder/rules/compile.ts).
const LUCENE_WILD_SPECIAL = /([\s+\-!(){}[\]^"~*?:\\/])/g;

function titleContainsQuery(text: string): string {
  return `title:*${text.replace(LUCENE_WILD_SPECIAL, '\\$1')}*`;
}

type AddSearchState =
  | { status: 'idle' }
  | { status: 'error'; query: string; message: string }
  | { status: 'success'; query: string; items: AutoTuneMember[] };

// One row in the Content-sources pane: either a live member of the axis's base set, or an
// add-untagged source the user pulled in from the library search.
interface SourceRow {
  id: number;
  title: string;
  artwork?: string | null;
  added: boolean;
}

// The Content-sources pane (#384 read model, #425 write model, wired here in #440). Each row carries a
// 1..1000 weight stepper and an include/exclude toggle; below them a library typeahead adds a source
// that isn't in the axis's base set. Everything writes into the parent screen's per-channel draft
// (spa-conventions §11: draft-until-commit, so there is no panel-level save) — the bulk create flushes
// it as the request's `sources` array.
function ContentSourcesSection({
  proposal,
  sources,
  patchSource,
  removeSource
}: {
  proposal: AutoTuneProposal;
  sources: Record<number, SourceDraft> | undefined;
  patchSource: (id: number, delta: Partial<SourceDraft>) => void;
  removeSource: (id: number) => void;
}) {
  const [membersState, setMembersState] = useState<MembersState>({ status: 'loading' });
  const [addQuery, setAddQuery] = useState('');
  const [addState, setAddState] = useState<AddSearchState>({ status: 'idle' });
  const activeRef = useRef(true);
  const searchSeqRef = useRef(0);

  useEffect(() => {
    activeRef.current = true;
    return () => {
      activeRef.current = false;
    };
  }, []);

  // Load the distinct members this proposal's smart collection resolves to (#384). The parent keys
  // the panel by proposalKey, so this remounts per proposal and `membersState` starts at 'loading'
  // each time — the effect never sets state synchronously (spa-conventions §3), only in the fetch
  // callbacks.
  useEffect(() => {
    getAutoTuneChannelMembers({ axis: proposal.axis as AutoTuneAxis, value: proposal.value, pageNum: 0, pageSize: MEMBER_PREVIEW_SIZE })
      .then((page) => {
        if (activeRef.current) {
          setMembersState({ status: 'success', totalCount: page.totalCount, members: page.page ?? [] });
        }
      })
      .catch((error: unknown) => {
        if (activeRef.current) {
          setMembersState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to load content sources') });
        }
      });
  }, [proposal.axis, proposal.value]);

  // The axis decides what an added source can be: the TV axes group episodes by parent show, the
  // movie axis groups movies (GetAutoTuneChannelMembersHandler), so the id space must match.
  const wantsMovies = proposal.axis === 'MovieGenre';
  const trimmedQuery = addQuery.trim();
  const searchable = trimmedQuery.length >= ADD_SOURCE_MIN_QUERY;

  // Debounced library search for the add-untagged picker. The timer fires on mount too, so the
  // length gate above (not a setState in the effect body) is what keeps a blank field quiet; results
  // are keyed to the query that produced them and read back through `resultsForQuery`, so a stale
  // response can never render under a newer query (spa-conventions §3a/§9).
  useEffect(() => {
    if (!searchable) {
      return;
    }
    const id = ++searchSeqRef.current;
    const timer = window.setTimeout(() => {
      getSearchResults({ query: titleContainsQuery(trimmedQuery), pageSize: ADD_SOURCE_RESULTS })
        .then((results) => {
          if (activeRef.current && id === searchSeqRef.current) {
            const group = wantsMovies ? results.movies : results.shows;
            setAddState({ status: 'success', query: trimmedQuery, items: group?.items ?? [] });
          }
        })
        .catch((error: unknown) => {
          if (activeRef.current && id === searchSeqRef.current) {
            setAddState({ status: 'error', query: trimmedQuery, message: messageFromSearchError(error) });
          }
        });
    }, 250);
    return () => window.clearTimeout(timer);
  }, [searchable, trimmedQuery, wantsMovies]);

  const baseMembers = membersState.status === 'success' ? membersState.members : [];
  const baseIds = new Set(baseMembers.map((member) => member.id));

  const rows: SourceRow[] = [
    ...baseMembers.map((member) => ({ id: member.id, title: member.title, artwork: member.artwork, added: false })),
    ...Object.entries(sources ?? {})
      .filter(([id, draft]) => draft.addedTitle != null && !baseIds.has(Number(id)))
      .map(([id, draft]) => ({ id: Number(id), title: draft.addedTitle ?? `#${id}`, artwork: draft.addedArtwork, added: true }))
  ];

  const customizedCount = Object.values(sources ?? {}).filter(sourceCustomized).length;

  const addSource = (item: AutoTuneMember) => {
    setAddQuery('');
    // A hit that is already a base member is not an "added" source: materialize a DEFAULT draft for
    // it so no duplicate row sprouts. That draft is deliberately not customised (`sourceCustomized`
    // is false, and `sourcesRequest` omits it), so picking an existing member is a payload no-op —
    // the row is already listed above, and the only visible effect is the query clearing.
    patchSource(item.id, baseIds.has(item.id) ? {} : { addedTitle: item.title, addedArtwork: item.artwork ?? undefined });
  };

  const resultsForQuery = addState.status === 'success' && addState.query === trimmedQuery ? addState.items : null;
  const errorForQuery = addState.status === 'error' && addState.query === trimmedQuery ? addState.message : null;

  return (
    <DetailSection
      label="Content sources"
      hint={
        <span style={{ display: 'inline-flex', alignItems: 'flex-start', gap: 6 }}>
          <Info aria-hidden="true" size={12} style={{ flex: '0 0 auto', marginTop: 1 }} />
          <span>
            Weight {WEIGHT_MIN}–{WEIGHT_MAX}: a source at weight <strong>N</strong> airs N× as often as each other source.
            Excluded sources are dropped from the channel entirely. Leave every source at {WEIGHT_MIN} and the channel keeps
            the simpler fair-share collection.
          </span>
        </span>
      }
    >
      {membersState.status === 'loading' && (
        <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="status">
          <Spinner size={14} />
          <span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading content sources…</span>
        </div>
      )}
      {membersState.status === 'error' && (
        <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="alert">
          <TriangleAlert aria-hidden="true" size={14} style={{ color: 'var(--status-error)' }} />
          <span style={{ font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--status-error)' }}>{membersState.message}</span>
        </div>
      )}
      {membersState.status === 'success' && (
        <>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
              {membersState.totalCount} {membersState.totalCount === 1 ? 'source' : 'sources'}
              {membersState.totalCount > membersState.members.length ? ` · showing first ${membersState.members.length}` : ''}
            </span>
            <span style={{ flex: 1 }} />
            {customizedCount > 0 && (
              <Tag icon={<Pencil aria-hidden="true" size={11} />} tone="accent">
                {customizedCount} customised
              </Tag>
            )}
          </div>
          <div
            style={{
              display: 'flex',
              flexDirection: 'column',
              border: '1px solid var(--border-hairline)',
              borderRadius: 'var(--radius-md)',
              overflow: 'hidden',
              maxHeight: 260,
              overflowY: 'auto'
            }}
          >
            {rows.length === 0 ? (
              <div style={{ padding: '12px 14px', font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
                No members resolved.
              </div>
            ) : (
              rows.map((row, i) => {
                const draft = sources?.[row.id] ?? defaultSourceDraft();
                return (
                  <div
                    key={row.id}
                    style={{
                      display: 'flex',
                      alignItems: 'center',
                      gap: 10,
                      padding: '7px 12px',
                      borderTop: i ? '1px solid var(--border-hairline)' : 'none',
                      opacity: draft.excluded ? 0.5 : 1
                    }}
                  >
                    {row.artwork ? (
                      <img
                        src={row.artwork}
                        alt=""
                        loading="lazy"
                        style={{ width: 28, height: 42, objectFit: 'cover', borderRadius: 3, flex: '0 0 auto' }}
                      />
                    ) : (
                      <div style={{ width: 28, height: 42, borderRadius: 3, background: 'var(--ctv-surface-2)', flex: '0 0 auto' }} />
                    )}
                    <span
                      style={{
                        flex: 1,
                        minWidth: 0,
                        font: 'var(--text-xs)/1.3 var(--font-sans)',
                        color: 'var(--text-primary)',
                        whiteSpace: 'nowrap',
                        overflow: 'hidden',
                        textOverflow: 'ellipsis',
                        textDecoration: draft.excluded ? 'line-through' : 'none'
                      }}
                    >
                      {row.title}
                    </span>
                    {row.added && <Tag tone="accent">Added</Tag>}
                    <Input
                      ariaLabel={`Weight for ${row.title}`}
                      disabled={draft.excluded}
                      fullWidth={false}
                      inputMode="numeric"
                      max={WEIGHT_MAX}
                      min={WEIGHT_MIN}
                      onBlur={() => patchSource(row.id, { weight: String(clampWeight(draft.weight)) })}
                      onChange={(event) => patchSource(row.id, { weight: event.target.value })}
                      size="sm"
                      style={{ width: 64 }}
                      type="number"
                      value={draft.weight}
                    />
                    <IconButton
                      size="sm"
                      active={draft.excluded}
                      title={draft.excluded ? `Include ${row.title}` : `Exclude ${row.title}`}
                      onClick={() => patchSource(row.id, { excluded: !draft.excluded })}
                    >
                      {draft.excluded ? <EyeOff aria-hidden="true" size={14} /> : <Eye aria-hidden="true" size={14} />}
                    </IconButton>
                    {row.added && (
                      <IconButton size="sm" title={`Remove ${row.title}`} onClick={() => removeSource(row.id)}>
                        <Trash2 aria-hidden="true" size={14} />
                      </IconButton>
                    )}
                  </div>
                );
              })
            )}
          </div>

          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            <Input
              size="sm"
              value={addQuery}
              onChange={(event) => setAddQuery(event.target.value)}
              ariaLabel={wantsMovies ? 'Add a movie not in this genre' : 'Add a show not in this axis'}
              placeholder={wantsMovies ? 'Add a movie from your library…' : 'Add a show from your library…'}
              leadingIcon={<Search aria-hidden="true" size={14} />}
            />
            {searchable && errorForQuery && (
              <div role="alert" style={{ font: 'var(--text-2xs)/1.3 var(--font-sans)', color: 'var(--status-error)' }}>
                {errorForQuery}
              </div>
            )}
            {searchable && !errorForQuery && resultsForQuery == null && (
              <div role="status" style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                <Spinner size={12} />
                <span style={{ font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Searching…</span>
              </div>
            )}
            {searchable && resultsForQuery != null && resultsForQuery.length === 0 && (
              <div style={{ font: 'var(--text-2xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
                No {wantsMovies ? 'movies' : 'shows'} matched “{trimmedQuery}”.
              </div>
            )}
            {searchable && resultsForQuery != null && resultsForQuery.length > 0 && (
              <div
                style={{
                  display: 'flex',
                  flexDirection: 'column',
                  border: '1px solid var(--border-hairline)',
                  borderRadius: 'var(--radius-md)',
                  overflow: 'hidden'
                }}
              >
                {resultsForQuery.map((item, i) => (
                  <button
                    key={item.id}
                    type="button"
                    className="ctv-press"
                    onClick={() => addSource(item)}
                    style={{
                      display: 'flex',
                      alignItems: 'center',
                      gap: 8,
                      padding: '7px 10px',
                      border: 'none',
                      borderTop: i ? '1px solid var(--border-hairline)' : 'none',
                      background: 'var(--surface-card)',
                      cursor: 'pointer',
                      textAlign: 'left'
                    }}
                  >
                    <Plus aria-hidden="true" size={13} style={{ color: 'var(--ctv-accent)', flex: '0 0 auto' }} />
                    <span
                      style={{
                        flex: 1,
                        minWidth: 0,
                        font: 'var(--text-xs)/1.3 var(--font-sans)',
                        color: 'var(--text-primary)',
                        whiteSpace: 'nowrap',
                        overflow: 'hidden',
                        textOverflow: 'ellipsis'
                      }}
                    >
                      {item.title}
                    </span>
                    {baseIds.has(item.id) && <Tag tone="neutral">Already a source</Tag>}
                  </button>
                ))}
              </div>
            )}
          </div>
        </>
      )}
    </DetailSection>
  );
}

// The per-channel Advanced override grid. Reuses the shared INHERIT/omit adapters
// (builder/advancedOptions) so the "leave on Inherit = omit from request" contract (#135) is
// identical to the Channel Builder. Playback order + playout mode are NOT here — they are the
// Playback pane's Shuffle / Always-playing toggles; group/categories are Channel-Builder-only.
function AdvancedFields({
  template,
  overrides,
  setOverrides,
  ffmpegProfiles,
  fillerPresets,
  watermarks
}: {
  template: ChannelTemplate;
  overrides: Overrides;
  setOverrides: (updater: (prev: Overrides) => Overrides) => void;
  ffmpegProfiles: FFmpegProfile[];
  fillerPresets: FillerPreset[];
  watermarks: Watermark[];
}) {
  const { setOverride, idSelectOptions, selValue, onSelect, textValue, onText, textPlaceholder, boolValue } =
    useAdvancedOverrides(template, overrides, setOverrides);
  const t = template as unknown as Record<string, unknown>;
  const str = (value: unknown): string | null => (value == null ? null : String(value));

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 2 }}>
      <AdvGroup label="Streaming">
        <Select
          size="sm"
          label="FFmpeg profile"
          value={selValue('ffmpegProfileId')}
          options={[
            inheritOption(ffmpegProfiles.find((p) => p.id === t.ffmpegProfileId)?.name ?? `#${t.ffmpegProfileId}`),
            ...ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` }))
          ]}
          onChange={onSelect('ffmpegProfileId', Number)}
        />
        <Select
          size="sm"
          label="Streaming mode"
          value={selValue('streamingMode')}
          options={[
            inheritOption(STREAMING_MODE_LABELS[t.streamingMode as StreamingMode] ?? str(t.streamingMode)),
            ...STREAMING_MODES.map((value) => ({ value, label: STREAMING_MODE_LABELS[value] }))
          ]}
          onChange={onSelect('streamingMode', (raw) => raw)}
        />
        <Select
          size="sm"
          label="Transcode mode"
          value={selValue('transcodeMode')}
          options={[inheritOption(str(t.transcodeMode)), ...TRANSCODE_MODES.map((value) => ({ value, label: value }))]}
          onChange={onSelect('transcodeMode', (raw) => raw)}
        />
      </AdvGroup>

      <AdvGroup label="Filler">
        <Select size="sm" label="Fallback filler" value={selValue('fallbackFillerId')} options={idSelectOptions('fallbackFillerId', fillerPresets)} onChange={onSelect('fallbackFillerId', Number)} />
        <Select size="sm" label="Pre-roll filler" value={selValue('preRollFillerId')} options={idSelectOptions('preRollFillerId', fillerPresets)} onChange={onSelect('preRollFillerId', Number)} />
        <Select size="sm" label="Mid-roll filler" value={selValue('midRollFillerId')} options={idSelectOptions('midRollFillerId', fillerPresets)} onChange={onSelect('midRollFillerId', Number)} />
        <Select size="sm" label="Post-roll filler" value={selValue('postRollFillerId')} options={idSelectOptions('postRollFillerId', fillerPresets)} onChange={onSelect('postRollFillerId', Number)} />
        <Select size="sm" label="Watermark" value={selValue('watermarkId')} options={idSelectOptions('watermarkId', watermarks)} onChange={onSelect('watermarkId', Number)} />
      </AdvGroup>

      <AdvGroup label="Playback">
        <Input size="sm" label="Preferred audio language" value={textValue('preferredAudioLanguageCode')} onChange={onText('preferredAudioLanguageCode')} placeholder={textPlaceholder('preferredAudioLanguageCode', 'e.g. eng')} />
        <Input size="sm" label="Preferred subtitle language" value={textValue('preferredSubtitleLanguageCode')} onChange={onText('preferredSubtitleLanguageCode')} placeholder={textPlaceholder('preferredSubtitleLanguageCode', 'e.g. eng')} />
        <Input size="sm" label="Preferred audio title" value={textValue('preferredAudioTitle')} onChange={onText('preferredAudioTitle')} placeholder={textPlaceholder('preferredAudioTitle')} />
        <Select size="sm" label="Subtitle mode" value={selValue('subtitleMode')} options={[inheritOption(str(t.subtitleMode)), ...SUBTITLE_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('subtitleMode', (raw) => raw)} />
        <Select size="sm" label="Stream selector mode" value={selValue('streamSelectorMode')} options={[inheritOption(str(t.streamSelectorMode)), ...STREAM_SELECTOR_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('streamSelectorMode', (raw) => raw)} />
        <Input size="sm" label="Stream selector" value={textValue('streamSelector')} onChange={onText('streamSelector')} placeholder={textPlaceholder('streamSelector')} />
      </AdvGroup>

      <AdvGroup label="Behavior">
        <Select size="sm" label="Music video credits" value={selValue('musicVideoCreditsMode')} options={[inheritOption(str(t.musicVideoCreditsMode)), ...MUSIC_VIDEO_CREDITS_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('musicVideoCreditsMode', (raw) => raw)} />
        <Select size="sm" label="Song video mode" value={selValue('songVideoMode')} options={[inheritOption(str(t.songVideoMode)), ...SONG_VIDEO_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('songVideoMode', (raw) => raw)} />
        <Select size="sm" label="Idle behavior" value={selValue('idleBehavior')} options={[inheritOption(str(t.idleBehavior)), ...IDLE_BEHAVIORS.map((value) => ({ value, label: value }))]} onChange={onSelect('idleBehavior', (raw) => raw)} />
        <Select size="sm" label="Fixed start time" value={selValue('fixedStartTimeBehavior')} options={[inheritOption(str(t.fixedStartTimeBehavior)), ...FIXED_START_TIME_BEHAVIORS.map((value) => ({ value, label: value }))]} onChange={onSelect('fixedStartTimeBehavior', (raw) => raw)} />
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
          <span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Random start point</span>
          <Switch checked={boolValue('randomStartPoint')} onChange={(next) => setOverride('randomStartPoint', next)} />
        </div>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
          <span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Shuffle schedule items</span>
          <Switch checked={boolValue('shuffleScheduleItems')} onChange={(next) => setOverride('shuffleScheduleItems', next)} />
        </div>
      </AdvGroup>
    </div>
  );
}

function AdvGroup({ label, children }: { label: string; children: ReactNode }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
      <div style={{ ...eyebrowStyle, color: 'var(--text-secondary)' }}>{label}</div>
      {children}
    </div>
  );
}

const centeredMessageStyle: CSSProperties = {
  display: 'flex',
  flexDirection: 'column',
  alignItems: 'center',
  justifyContent: 'center',
  height: '100%',
  gap: 12,
  padding: '40px 24px'
};
