import { useEffect, useState, type ReactNode } from 'react';
import {
  ArrowRight,
  CalendarDays,
  Cast,
  Check,
  CircleAlert,
  CircleCheck,
  Clapperboard,
  Copy,
  Cpu,
  ExternalLink,
  HardDrive,
  ListVideo,
  Proportions,
  Radar,
  Radio,
  ScrollText,
  Server,
  SlidersHorizontal,
  Trash2,
  TriangleAlert
} from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ConfirmDialog, IconButton, Input, Select, Switch, Tag, Tooltip } from '../components';
import {
  createResolution,
  deleteResolution,
  messageFromSettingsError,
  updateFfmpegSettings,
  updateHdhrSettings,
  updateIptvSettings,
  updateLoggingSettings,
  updatePlayoutSettings,
  updateScannerSettings,
  updateUiSettings,
  updateXmltvSettings,
  useSettingsScreenQuery,
  type FfmpegSettings,
  type HdhrSettings,
  type IptvSettings,
  type LoggingSettings,
  type LogEventLevel,
  type MediaSource,
  type OutputFormatKind,
  type PlayoutSettings,
  type Resolution,
  type ScannerSettings,
  type SettingsScreenData,
  type UiSettings,
  type XmltvBlockBehavior,
  type XmltvSettings,
  type XmltvTimeZone
} from '../api';

const SETTINGS_BASE_PATH = '/app/settings';

type SectionId = 'general' | 'streaming' | 'playout' | 'xmltv' | 'iptv' | 'scanner' | 'logging' | 'system';

interface SectionDef {
  hint: string;
  icon: ReactNode;
  id: SectionId;
  label: string;
}

const SECTIONS: SectionDef[] = [
  { hint: 'Theme & language', icon: <SlidersHorizontal aria-hidden="true" size={16} />, id: 'general', label: 'General' },
  { hint: 'FFmpeg & transcoding', icon: <Clapperboard aria-hidden="true" size={16} />, id: 'streaming', label: 'Streaming' },
  { hint: 'Build defaults', icon: <ListVideo aria-hidden="true" size={16} />, id: 'playout', label: 'Playout' },
  { hint: 'EPG output', icon: <CalendarDays aria-hidden="true" size={16} />, id: 'xmltv', label: 'Guide (XMLTV)' },
  { hint: 'M3U & stream URLs', icon: <Radio aria-hidden="true" size={16} />, id: 'iptv', label: 'IPTV' },
  { hint: 'Library refresh', icon: <Radar aria-hidden="true" size={16} />, id: 'scanner', label: 'Scanner' },
  { hint: 'Log levels', icon: <ScrollText aria-hidden="true" size={16} />, id: 'logging', label: 'Logging' },
  { hint: 'HDHR, sources, about', icon: <Server aria-hidden="true" size={16} />, id: 'system', label: 'System' }
];

const SECTION_IDS = new Set<string>(SECTIONS.map((section) => section.id));

function isSectionId(value: string): value is SectionId {
  return SECTION_IDS.has(value);
}

function normalizePathname(pathname: string): string {
  return pathname.replace(/\/+$/, '') || '/app';
}

function sectionFromPathname(pathname: string): SectionId {
  const normalized = normalizePathname(pathname);

  if (!normalized.startsWith(SETTINGS_BASE_PATH)) {
    return 'general';
  }

  const suffix = normalized.slice(SETTINGS_BASE_PATH.length).replace(/^\//, '');
  return isSectionId(suffix) ? suffix : 'general';
}

const LOG_LEVEL_OPTIONS: LogEventLevel[] = ['Verbose', 'Debug', 'Information', 'Warning', 'Error', 'Fatal'];

const HLS_DIRECT_FORMAT_OPTIONS: Array<{ label: string; value: OutputFormatKind }> = [
  { label: 'MPEG-TS', value: 'MpegTs' },
  { label: 'MP4', value: 'Mp4' },
  { label: 'MKV', value: 'Mkv' }
];

// The server can hold an OutputFormatKind outside the curated option list above (e.g.
// Hls). If the current value isn't one of the known options, append it as an extra
// option labeled with the raw wire value - so the select's displayed value never lies
// about what the draft actually holds.
function hlsDirectFormatOptions(current: OutputFormatKind): Array<{ label: string; value: OutputFormatKind }> {
  if (HLS_DIRECT_FORMAT_OPTIONS.some((option) => option.value === current)) {
    return HLS_DIRECT_FORMAT_OPTIONS;
  }

  return [...HLS_DIRECT_FORMAT_OPTIONS, { label: current, value: current }];
}

interface Draft {
  ffmpeg: FfmpegSettings;
  hdhr: HdhrSettings;
  iptv: IptvSettings;
  logging: LoggingSettings;
  playout: PlayoutSettings;
  scanner: ScannerSettings;
  ui: UiSettings;
  xmltv: XmltvSettings;
}

type GroupKey = keyof Draft;

const GROUP_KEYS: GroupKey[] = ['ui', 'ffmpeg', 'playout', 'xmltv', 'iptv', 'scanner', 'logging', 'hdhr'];

function draftFromData(data: SettingsScreenData): Draft {
  return {
    ffmpeg: data.ffmpeg,
    hdhr: data.hdhr,
    iptv: data.iptv,
    logging: data.logging,
    playout: data.playout,
    scanner: data.scanner,
    ui: data.ui,
    xmltv: data.xmltv
  };
}

function countChangedFields(a: Record<string, unknown>, b: Record<string, unknown>): number {
  return Object.keys(a).filter((key) => a[key] !== b[key]).length;
}

function isValidInt(value: number, min = 0): boolean {
  return Number.isInteger(value) && value >= min;
}

// Most recent lastScan timestamp across a media source's libraries, or null when none
// of its libraries have ever been scanned. ISO 8601 timestamps sort correctly as plain
// strings, so no Date parsing is needed to find the max.
function mostRecentLastScan(source: MediaSource): string | null {
  const scans = source.libraries.map((library) => library.lastScan).filter((scan): scan is string => Boolean(scan));

  if (scans.length === 0) {
    return null;
  }

  return scans.reduce((latest, scan) => (scan > latest ? scan : latest));
}

function formatLastScan(value: string): string {
  const parsed = new Date(value);

  if (Number.isNaN(parsed.getTime())) {
    return `Last scan ${value}`;
  }

  return `Last scan ${parsed.toLocaleString([], { day: 'numeric', hour: '2-digit', minute: '2-digit', month: 'short' })}`;
}

const GROUP_LABELS: Record<GroupKey, string> = {
  ffmpeg: 'Streaming',
  hdhr: 'System',
  iptv: 'IPTV',
  logging: 'Logging',
  playout: 'Playout',
  scanner: 'Scanner',
  ui: 'General',
  xmltv: 'Guide (XMLTV)'
};

async function saveGroup(key: GroupKey, draft: Draft): Promise<Partial<Draft>> {
  switch (key) {
    case 'ui':
      return { ui: await updateUiSettings(draft.ui) };
    case 'ffmpeg':
      return { ffmpeg: await updateFfmpegSettings(draft.ffmpeg) };
    case 'playout':
      return { playout: await updatePlayoutSettings(draft.playout) };
    case 'xmltv':
      return { xmltv: await updateXmltvSettings(draft.xmltv) };
    case 'iptv':
      return { iptv: await updateIptvSettings(draft.iptv) };
    case 'scanner':
      return { scanner: await updateScannerSettings(draft.scanner) };
    case 'logging':
      return { logging: await updateLoggingSettings(draft.logging) };
    case 'hdhr':
      return { hdhr: await updateHdhrSettings(draft.hdhr) };
    default:
      return {};
  }
}

/* ---------- shared row primitives ---------- */

function Row({
  children,
  control = 260,
  first = false,
  help,
  label
}: {
  children: ReactNode;
  control?: number;
  first?: boolean;
  help?: string;
  label: string;
}) {
  return (
    <div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
      <div className="ctv-settings-row-main">
        <div className="ctv-settings-row-label">{label}</div>
        {help && <div className="ctv-settings-row-help">{help}</div>}
      </div>
      <div className="ctv-settings-row-control" style={{ flex: `0 0 ${control}px` }}>
        {children}
      </div>
    </div>
  );
}

function LegacyCallout({ children }: { children: ReactNode }) {
  return (
    <div className="ctv-settings-callout">
      <ExternalLink aria-hidden="true" size={14} />
      <span>{children}</span>
      <Badge tone="neutral">Legacy UI</Badge>
    </div>
  );
}

// Inline degraded-state note for tier-2 (reference) data that failed to load. The
// screen itself stays fully rendered and editable; only the affected card/control
// swaps its data for this note.
function RefFailureNote({ children }: { children: ReactNode }) {
  return (
    <span className="ctv-settings-ref-failure" role="note">
      <TriangleAlert aria-hidden="true" size={13} />
      <span>{children}</span>
    </span>
  );
}

function PathInput({ onChange, value }: { onChange: (value: string) => void; value: string }) {
  const valid = value.startsWith('/');

  return (
    <Input
      fullWidth
      onChange={(event) => onChange(event.target.value)}
      size="sm"
      style={{ fontFamily: 'var(--font-mono)' }}
      trailing={
        valid ? (
          <CircleCheck aria-hidden="true" color="var(--status-ok)" size={14} />
        ) : (
          <Tooltip label="File not found">
            <CircleAlert aria-hidden="true" color="var(--status-error)" size={14} />
          </Tooltip>
        )
      }
      value={value}
    />
  );
}

function NumInput({
  invalid = false,
  min = 0,
  onChange,
  unit,
  value
}: {
  invalid?: boolean;
  min?: number;
  onChange: (value: number) => void;
  unit?: string;
  value: number;
}) {
  return (
    <Input
      error={invalid ? `Must be a whole number ≥ ${min}` : null}
      onChange={(event) => {
        const raw = event.target.value;
        // An empty field is NOT the same as 0 - Number('') coerces to 0, which would
        // make a cleared field silently pass validation. Route it to NaN instead so
        // isValidInt (Number.isInteger) correctly flags it as invalid.
        onChange(raw === '' ? Number.NaN : Number(raw));
      }}
      size="sm"
      style={{ fontFamily: 'var(--font-mono)', width: 120 }}
      trailing={unit ? <span className="ctv-settings-unit">{unit}</span> : undefined}
      type="number"
      value={Number.isNaN(value) ? '' : String(value)}
    />
  );
}

function Pane({ children, subtitle, title }: { children: ReactNode; subtitle: string; title: string }) {
  return (
    <div className="ctv-settings-pane">
      <div className="ctv-settings-pane-header">
        <div className="ctv-settings-pane-title">{title}</div>
        <div className="ctv-settings-pane-subtitle">{subtitle}</div>
      </div>
      {children}
    </div>
  );
}

/* ---------- panes ---------- */

function GeneralPane({
  set,
  ui
}: {
  set: (patch: Partial<UiSettings>) => void;
  ui: UiSettings;
}) {
  return (
    <Pane
      subtitle="Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher."
      title="General"
    >
      <Card padded={false}>
        <Row first help="Legacy UI color scheme." label="Theme">
          <Select
            fullWidth
            onChange={(event) => set({ isDarkMode: event.target.value === 'Dark' })}
            options={['Dark', 'Light']}
            size="sm"
            value={ui.isDarkMode ? 'Dark' : 'Light'}
          />
        </Row>
        <Row help="Display language for the legacy UI, as a culture code (e.g. en-US)." label="Language">
          <Input
            fullWidth
            onChange={(event) => set({ language: event.target.value })}
            placeholder="en-US"
            size="sm"
            value={ui.language}
          />
        </Row>
      </Card>
    </Pane>
  );
}

function StreamingPane({
  data,
  ffmpeg,
  invalidFields,
  onAddResolution,
  onDeleteResolution,
  set
}: {
  data: SettingsScreenData;
  ffmpeg: FfmpegSettings;
  invalidFields: Set<string>;
  onAddResolution: (width: number, height: number) => Promise<void>;
  onDeleteResolution: (resolution: Resolution) => void;
  set: (patch: Partial<FfmpegSettings>) => void;
}) {
  const [resWidth, setResWidth] = useState('');
  const [resHeight, setResHeight] = useState('');
  const [adding, setAdding] = useState(false);
  const [addError, setAddError] = useState<string | null>(null);

  const submitResolution = async () => {
    const width = Number(resWidth);
    const height = Number(resHeight);

    if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) {
      return;
    }

    setAdding(true);
    setAddError(null);

    try {
      await onAddResolution(width, height);
      setResWidth('');
      setResHeight('');
    } catch (error) {
      setAddError(messageFromSettingsError(error, 'Unable to add resolution'));
    } finally {
      setAdding(false);
    }
  };

  return (
    <Pane subtitle="FFmpeg engine, transcoding defaults and HLS session tuning." title="Streaming">
      <Card padded={false} title="FFmpeg">
        <Row control={320} first help="Binary used for all transcoding." label="FFmpeg path">
          <PathInput onChange={(value) => set({ fFmpegPath: value })} value={ffmpeg.fFmpegPath} />
        </Row>
        <Row control={320} help="Binary used to inspect media files." label="FFprobe path">
          <PathInput onChange={(value) => set({ fFprobePath: value })} value={ffmpeg.fFprobePath} />
        </Row>
        <Row help="Transcoding profile for new channels." label="Default profile">
          {data.referenceFailures.ffmpegProfiles ? (
            <RefFailureNote>Couldn't load FFmpeg profiles</RefFailureNote>
          ) : (
            <Select
              fullWidth
              onChange={(event) => set({ defaultFFmpegProfileId: Number(event.target.value) })}
              options={data.ffmpegProfiles.map((profile) => ({
                label: profile.name ?? `Profile ${profile.id}`,
                value: String(profile.id)
              }))}
              size="sm"
              value={String(ffmpeg.defaultFFmpegProfileId)}
            />
          )}
        </Row>
        <Row help="ISO 639-2 code of the track picked when media has multiple audio languages (e.g. eng)." label="Preferred audio language">
          <Input
            fullWidth
            onChange={(event) => set({ preferredAudioLanguageCode: event.target.value })}
            placeholder="eng"
            size="sm"
            style={{ fontFamily: 'var(--font-mono)' }}
            value={ffmpeg.preferredAudioLanguageCode}
          />
        </Row>
        <Row help="Burn in / pass through subtitles found in media files." label="Use embedded subtitles">
          <Switch checked={ffmpeg.useEmbeddedSubtitles} onChange={(next) => set({ useEmbeddedSubtitles: next })} size="sm" />
        </Row>
        <Row help="Copy text subtitles out ahead of playback (slower scans)." label="Extract embedded subtitles">
          <Switch checked={ffmpeg.extractEmbeddedSubtitles} onChange={(next) => set({ extractEmbeddedSubtitles: next })} size="sm" />
        </Row>
        <Row help="Deeper scan to auto-apply deinterlacing." label="Probe for interlaced frames">
          <Switch checked={ffmpeg.probeForInterlacedFrames} onChange={(next) => set({ probeForInterlacedFrames: next })} size="sm" />
        </Row>
        <Row help="Write a report per transcode session to the config folder." label="Save troubleshooting reports">
          <Switch checked={ffmpeg.saveReports} onChange={(next) => set({ saveReports: next })} size="sm" />
        </Row>
      </Card>

      <Card padded={false} title="Global defaults">
        <Row first help="Applied to every channel without its own watermark." label="Watermark">
          {data.referenceFailures.watermarks ? (
            <RefFailureNote>Couldn't load watermarks</RefFailureNote>
          ) : (
            <Select
              fullWidth
              onChange={(event) => set({ globalWatermarkId: event.target.value === '' ? null : Number(event.target.value) })}
              options={[
                { label: '(none)', value: '' },
                ...data.watermarks.map((watermark) => ({ label: watermark.name ?? `Watermark ${watermark.id}`, value: String(watermark.id) }))
              ]}
              size="sm"
              value={ffmpeg.globalWatermarkId == null ? '' : String(ffmpeg.globalWatermarkId)}
            />
          )}
        </Row>
        <Row help="Plays when a playout has nothing scheduled." label="Fallback filler">
          {data.referenceFailures.fillerPresets ? (
            <RefFailureNote>Couldn't load filler presets</RefFailureNote>
          ) : (
            <Select
              fullWidth
              onChange={(event) => set({ globalFallbackFillerId: event.target.value === '' ? null : Number(event.target.value) })}
              options={[
                { label: '(none)', value: '' },
                ...data.fillerPresets.map((preset) => ({ label: preset.name ?? `Filler ${preset.id}`, value: String(preset.id) }))
              ]}
              size="sm"
              value={ffmpeg.globalFallbackFillerId == null ? '' : String(ffmpeg.globalFallbackFillerId)}
            />
          )}
        </Row>
      </Card>

      <Card padded={false} title="HLS sessions">
        <Row first help="Stop a segmenter after no client has asked for segments." label="Idle timeout">
          <NumInput
            invalid={invalidFields.has('ffmpeg.hlsSegmenterIdleTimeout')}
            onChange={(value) => set({ hlsSegmenterIdleTimeout: value })}
            unit="sec"
            value={ffmpeg.hlsSegmenterIdleTimeout}
          />
        </Row>
        <Row help="Max segmenters transcoding ahead of live at once." label="Work-ahead limit">
          <NumInput
            invalid={invalidFields.has('ffmpeg.workAheadSegmenterLimit')}
            onChange={(value) => set({ workAheadSegmenterLimit: value })}
            unit="sessions"
            value={ffmpeg.workAheadSegmenterLimit}
          />
        </Row>
        <Row help="Segments ready before playback starts." label="Initial segments">
          <NumInput
            invalid={invalidFields.has('ffmpeg.initialSegmentCount')}
            onChange={(value) => set({ initialSegmentCount: value })}
            unit="segments"
            value={ffmpeg.initialSegmentCount}
          />
        </Row>
        <Row help="Container for HLS Direct channels." label="HLS Direct output format">
          <Select
            fullWidth
            onChange={(event) => set({ hlsDirectOutputFormat: event.target.value as OutputFormatKind })}
            options={hlsDirectFormatOptions(ffmpeg.hlsDirectOutputFormat)}
            size="sm"
            value={ffmpeg.hlsDirectOutputFormat}
          />
        </Row>
        <Row help="Channels in MPEG-TS mode use this script unless overridden." label="Default MPEG-TS script">
          <Input
            fullWidth
            onChange={(event) => set({ defaultMpegTsScript: event.target.value })}
            placeholder="(default template)"
            size="sm"
            style={{ fontFamily: 'var(--font-mono)' }}
            value={ffmpeg.defaultMpegTsScript}
          />
        </Row>
      </Card>

      <Card padded={false} subtitle="Extra target resolutions selectable in FFmpeg profiles" title="Custom resolutions">
        {data.resolutions.map((resolution, index) => (
          <div className="ctv-settings-flush-row" key={resolution.id} style={index === 0 ? { borderTop: 'none' } : undefined}>
            <Proportions aria-hidden="true" color="var(--text-disabled)" size={15} />
            <span className="ctv-settings-flush-row-main" style={{ fontFamily: 'var(--font-mono)' }}>
              {resolution.width} × {resolution.height}
            </span>
            <Tag>{resolution.isCustom ? 'custom' : resolution.name}</Tag>
            {resolution.isCustom && (
              <IconButton onClick={() => onDeleteResolution(resolution)} size="sm" title={`Delete ${resolution.width}×${resolution.height}`} variant="ghost">
                <Trash2 aria-hidden="true" size={14} />
              </IconButton>
            )}
          </div>
        ))}
        <div className="ctv-settings-flush-footer">
          <Input
            onChange={(event) => {
              setResWidth(event.target.value);
              setAddError(null);
            }}
            placeholder="width"
            size="sm"
            style={{ fontFamily: 'var(--font-mono)', width: 96 }}
            type="number"
            value={resWidth}
          />
          <span className="ctv-settings-unit">×</span>
          <Input
            onChange={(event) => {
              setResHeight(event.target.value);
              setAddError(null);
            }}
            placeholder="height"
            size="sm"
            style={{ fontFamily: 'var(--font-mono)', width: 96 }}
            type="number"
            value={resHeight}
          />
          <span style={{ flex: 1 }} />
          {addError && (
            <span className="ctv-field-error" role="alert" style={{ marginTop: 0 }}>
              {addError}
            </span>
          )}
          <Button
            disabled={!resWidth || !resHeight || adding}
            loading={adding}
            onClick={() => void submitResolution()}
            size="sm"
            variant="secondary"
          >
            Add resolution
          </Button>
        </div>
      </Card>

      <Card
        actions={<Badge tone="neutral">{data.ffmpegProfiles.length} profiles</Badge>}
        padded={false}
        subtitle="Fine-grained transcoding presets"
        title="FFmpeg profiles"
      >
        {data.referenceFailures.ffmpegProfiles ? (
          <div className="ctv-settings-flush-row" style={{ borderTop: 'none' }}>
            <RefFailureNote>Couldn't load FFmpeg profiles</RefFailureNote>
          </div>
        ) : (
          data.ffmpegProfiles.map((profile, index) => (
            <div className="ctv-settings-flush-row" key={profile.id} style={index === 0 ? { borderTop: 'none' } : undefined}>
              <Cpu aria-hidden="true" color="var(--ctv-accent)" size={15} />
              <span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{profile.name ?? `Profile ${profile.id}`}</span>
              {profile.id === ffmpeg.defaultFFmpegProfileId && <Badge tone="accent">Default</Badge>}
            </div>
          ))
        )}
        <div className="ctv-settings-flush-footer" style={{ display: 'flex', justifyContent: 'flex-end' }}>
          <Button
            endIcon={<ArrowRight aria-hidden="true" size={13} />}
            onClick={() => navigateToPath('/app/ffmpeg-profiles')}
            size="sm"
            variant="secondary"
          >
            Manage FFmpeg profiles
          </Button>
        </div>
      </Card>
    </Pane>
  );
}

function PlayoutPane({
  invalidFields,
  playout,
  set
}: {
  invalidFields: Set<string>;
  playout: PlayoutSettings;
  set: (patch: Partial<PlayoutSettings>) => void;
}) {
  return (
    <Pane subtitle="Defaults for how far ahead playouts are built and how gaps are handled." title="Playout">
      <Card padded={false}>
        <Row first help="How many days of programming each playout keeps ahead." label="Days to build">
          <NumInput
            invalid={invalidFields.has('playout.daysToBuild')}
            onChange={(value) => set({ daysToBuild: value })}
            unit="days"
            value={playout.daysToBuild}
          />
        </Row>
        <Row help="Skip items whose files are missing instead of failing the build." label="Skip missing items">
          <Switch checked={playout.skipMissingItems} onChange={(next) => set({ skipMissingItems: next })} size="sm" />
        </Row>
        <Row help="Max run time for external scripted-schedule processes." label="Scripted schedule timeout">
          <NumInput
            invalid={invalidFields.has('playout.scriptedScheduleTimeoutSeconds')}
            onChange={(value) => set({ scriptedScheduleTimeoutSeconds: value })}
            unit="sec"
            value={playout.scriptedScheduleTimeoutSeconds}
          />
        </Row>
      </Card>
    </Pane>
  );
}

function XmltvPane({
  invalidFields,
  set,
  xmltv
}: {
  invalidFields: Set<string>;
  set: (patch: Partial<XmltvSettings>) => void;
  xmltv: XmltvSettings;
}) {
  return (
    <Pane subtitle="Shape of the XMLTV guide data served to Jellyfin and other clients." title="Guide (XMLTV)">
      <Card padded={false}>
        <Row first help="Guide horizon exported per channel." label="Days to build">
          <NumInput
            invalid={invalidFields.has('xmltv.daysToBuild')}
            onChange={(value) => set({ daysToBuild: value })}
            unit="days"
            value={xmltv.daysToBuild}
          />
        </Row>
        <Row help="Timestamps written into the XMLTV file." label="Time zone">
          <Select
            fullWidth
            onChange={(event) => set({ timeZone: event.target.value as XmltvTimeZone })}
            options={[
              { label: 'Local', value: 'Local' },
              { label: 'UTC', value: 'Utc' }
            ]}
            size="sm"
            value={xmltv.timeZone}
          />
        </Row>
        <Row help="How block-schedule playouts are represented in the guide." label="Block behavior">
          <Select
            fullWidth
            onChange={(event) => set({ blockBehavior: event.target.value as XmltvBlockBehavior })}
            options={[
              { label: 'Split time evenly', value: 'SplitTimeEvenly' },
              { label: 'Use actual times', value: 'UseActualTimes' }
            ]}
            size="sm"
            value={xmltv.blockBehavior}
          />
        </Row>
      </Card>
    </Pane>
  );
}

function IptvPane({
  iptv,
  set
}: {
  iptv: IptvSettings;
  set: (patch: Partial<IptvSettings>) => void;
}) {
  return (
    <Pane subtitle="How ErsatzTV advertises itself to IPTV clients in M3U playlists and stream URLs." title="IPTV">
      <Card padded={false}>
        <Row
          control={340}
          first
          help="Absolute base URL clients should use to reach this server (e.g. http://192.168.1.99:8409 or https://tv.example.com/etv). Leave empty to use the address of each incoming request."
          label="Advertised base URL"
        >
          <Input
            fullWidth
            onChange={(event) => set({ baseUrl: event.target.value })}
            placeholder="(use request origin)"
            size="sm"
            style={{ fontFamily: 'var(--font-mono)' }}
            value={iptv.baseUrl}
          />
        </Row>
      </Card>
    </Pane>
  );
}

function ScannerPane({
  invalidFields,
  scanner,
  set
}: {
  invalidFields: Set<string>;
  scanner: ScannerSettings;
  set: (patch: Partial<ScannerSettings>) => void;
}) {
  return (
    <Pane subtitle="Background scanning of local libraries." title="Scanner">
      <Card padded={false}>
        <Row first help="Hours between automatic local-library scans. 0 disables automatic scanning." label="Library refresh interval">
          <NumInput
            invalid={invalidFields.has('scanner.libraryRefreshInterval')}
            onChange={(value) => set({ libraryRefreshInterval: value })}
            unit="hours"
            value={scanner.libraryRefreshInterval}
          />
        </Row>
      </Card>
      {scanner.libraryRefreshInterval === 0 && (
        <div className="ctv-settings-warn-callout">
          <TriangleAlert aria-hidden="true" color="var(--status-warn)" size={14} />
          <span>Automatic scanning is disabled — libraries only update when scanned manually.</span>
        </div>
      )}
    </Pane>
  );
}

const LOGGING_CATEGORIES: Array<{ help: string; key: keyof LoggingSettings; label: string }> = [
  { help: 'Everything without a more specific category.', key: 'defaultMinimumLogLevel', label: 'Default' },
  { help: 'Library scans and metadata refresh.', key: 'scanningMinimumLogLevel', label: 'Scanning' },
  { help: 'Playout builds and schedule evaluation.', key: 'schedulingMinimumLogLevel', label: 'Scheduling' },
  { help: 'Search index and queries.', key: 'searchingMinimumLogLevel', label: 'Searching' },
  { help: 'FFmpeg sessions and IPTV requests.', key: 'streamingMinimumLogLevel', label: 'Streaming' },
  { help: 'ASP.NET request logging.', key: 'httpMinimumLogLevel', label: 'HTTP' }
];

function LoggingPane({ logging, set }: { logging: LoggingSettings; set: (patch: Partial<LoggingSettings>) => void }) {
  return (
    <Pane subtitle="Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only." title="Logging">
      <Card padded={false}>
        {LOGGING_CATEGORIES.map((category, index) => (
          <Row control={180} first={index === 0} help={category.help} key={category.key} label={category.label}>
            <Select
              fullWidth
              onChange={(event) => set({ [category.key]: event.target.value as LogEventLevel } as Partial<LoggingSettings>)}
              options={LOG_LEVEL_OPTIONS}
              size="sm"
              value={logging[category.key] as string}
            />
          </Row>
        ))}
      </Card>
    </Pane>
  );
}

function SystemPane({
  data,
  hdhr,
  invalidFields,
  set
}: {
  data: SettingsScreenData;
  hdhr: HdhrSettings;
  invalidFields: Set<string>;
  set: (patch: Partial<HdhrSettings>) => void;
}) {
  const [copied, setCopied] = useState(false);

  const copyUuid = () => {
    void navigator.clipboard?.writeText(hdhr.uuid).then(() => {
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1500);
    });
  };

  const healthFailing = data.health.filter((check) => check.status.toLowerCase() === 'fail').length;
  const healthWarning = data.health.filter((check) => check.status.toLowerCase() === 'warn').length;
  const healthSummary = data.referenceFailures.health
    ? "Couldn't load health checks"
    : healthFailing > 0
      ? `${data.health.length} checks — ${healthFailing} failing`
      : healthWarning > 0
        ? `${data.health.length} checks — ${healthWarning} warning${healthWarning === 1 ? '' : 's'}`
        : `${data.health.length} checks — healthy`;

  return (
    <Pane subtitle="HDHomeRun emulation, connected media sources and server info." title="System">
      <Card padded={false} title="HDHomeRun">
        <Row first help="Concurrent streams advertised to clients that speak HDHR." label="Tuner count">
          <NumInput
            invalid={invalidFields.has('hdhr.tunerCount')}
            min={1}
            onChange={(value) => set({ tunerCount: value })}
            unit="tuners"
            value={hdhr.tunerCount}
          />
        </Row>
        <Row control={340} help="Identity presented to HDHR clients." label="Device UUID">
          <div className="ctv-settings-uuid-row">
            <span style={{ fontFamily: 'var(--font-mono)' }}>{hdhr.uuid}</span>
            <IconButton onClick={copyUuid} size="sm" title="Copy UUID" variant="ghost">
              {copied ? <Check aria-hidden="true" size={13} /> : <Copy aria-hidden="true" size={13} />}
            </IconButton>
          </div>
        </Row>
      </Card>

      <Card padded={false} subtitle="Read-only — add or edit connections in the legacy UI" title="Media sources">
        {data.referenceFailures.mediaSources ? (
          <div className="ctv-settings-flush-row" style={{ borderTop: 'none' }}>
            <RefFailureNote>Couldn't load media sources</RefFailureNote>
          </div>
        ) : (
          data.mediaSources.map((source, index) => {
            const lastScan = mostRecentLastScan(source);

            return (
              <div className="ctv-settings-flush-row" key={source.id} style={index === 0 ? { borderTop: 'none' } : undefined}>
                {source.kind === 'Local' ? (
                  <HardDrive aria-hidden="true" color="var(--text-secondary)" size={15} />
                ) : (
                  <Cast aria-hidden="true" color="var(--text-secondary)" size={15} />
                )}
                <div className="ctv-settings-flush-row-main">
                  <span className="ctv-settings-flush-row-title">{source.name}</span>
                  <span className="ctv-settings-flush-row-detail">{source.connectionAddress ?? 'local'}</span>
                </div>
                {/* Handoff spec calls for a StatusDot (ok/warn) here too, but the API exposes
                    no per-source health/status signal to drive one - deliberately omitted
                    rather than faked. */}
                {lastScan && <span className="ctv-settings-flush-row-detail">{formatLastScan(lastScan)}</span>}
              </div>
            );
          })
        )}
        <div className="ctv-settings-flush-footer">
          <LegacyCallout>Connections, credentials and library sync are managed in the legacy UI.</LegacyCallout>
        </div>
      </Card>

      <Card padded={false} title="About">
        <Row control={220} first label="Version">
          {data.referenceFailures.version ? (
            <RefFailureNote>Couldn't load version</RefFailureNote>
          ) : (
            <span style={{ fontFamily: 'var(--font-mono)' }}>{data.version?.appVersion ?? 'unknown'}</span>
          )}
        </Row>
        <Row control={220} help={healthSummary} label="Health checks">
          <Button endIcon={<ArrowRight aria-hidden="true" size={13} />} onClick={() => navigateToPath('/app')} size="sm" variant="secondary">
            Open Dashboard
          </Button>
        </Row>
        <Row control={220} help="Recent server log entries with level and free-text filtering." label="Logs">
          <Button endIcon={<ArrowRight aria-hidden="true" size={13} />} onClick={() => navigateToPath('/app/logs')} size="sm" variant="secondary">
            Open Logs
          </Button>
        </Row>
        <Row control={220} help="Version, environment, hardware and FFmpeg capability diagnostics." label="Troubleshooting">
          <Button endIcon={<ArrowRight aria-hidden="true" size={13} />} onClick={() => navigateToPath('/app/troubleshooting')} size="sm" variant="secondary">
            Open Troubleshooting
          </Button>
        </Row>
      </Card>
    </Pane>
  );
}

/* ---------- screen ---------- */

export function SettingsScreen() {
  const query = useSettingsScreenQuery();
  const [section, setSection] = useState<SectionId>(() => sectionFromPathname(window.location.pathname));
  const [draft, setDraft] = useState<Draft | null>(null);
  const [saved, setSaved] = useState<Draft | null>(null);
  const [saving, setSaving] = useState(false);
  const [justSaved, setJustSaved] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  const [deleteTarget, setDeleteTarget] = useState<Resolution | null>(null);
  const [deleting, setDeleting] = useState(false);
  const [deleteError, setDeleteError] = useState<string | null>(null);

  useEffect(() => {
    const onPopState = () => setSection(sectionFromPathname(window.location.pathname));

    window.addEventListener('popstate', onPopState);

    return () => window.removeEventListener('popstate', onPopState);
  }, []);

  // Adjust state during render (React's documented escape hatch for "derive initial
  // state once data arrives") rather than in an effect, so this doesn't cost an extra
  // commit. Guarded on `draft === null` so it only fires once - later data refreshes
  // (e.g. after adding/deleting a custom resolution) must not clobber in-progress edits.
  if (query.status === 'success' && draft === null) {
    const next = draftFromData(query.data);
    setDraft(next);
    setSaved(next);
  }

  // Error must be checked BEFORE the draft-null loading guard: on a tier-1 failure the
  // draft never initializes, and checking loading first would spin "Loading settings…"
  // forever instead of surfacing the error + retry.
  if (query.status === 'error') {
    return (
      <div className="ctv-settings-error" role="alert">
        <TriangleAlert aria-hidden="true" size={16} />
        <span>{query.error}</span>
        <Button onClick={query.refresh} size="sm" variant="secondary">Retry</Button>
      </div>
    );
  }

  if (query.status === 'loading' || draft === null || saved === null) {
    return (
      <div className="ctv-settings-loading">
        <span>Loading settings…</span>
      </div>
    );
  }

  const data = query.data;

  const selectSection = (id: SectionId) => {
    if (id !== section) {
      navigateToPath(`${SETTINGS_BASE_PATH}/${id}`);
    }
  };

  const set = <K extends GroupKey>(group: K, patch: Partial<Draft[K]>) => {
    // Clear the "Settings saved" pill the moment anything is edited, so it can't mask
    // the Save/Discard controls during its 1.8s display window.
    setJustSaved(false);
    setDraft((current) => (current ? { ...current, [group]: { ...current[group], ...patch } } : current));
  };

  const numericFields: Array<{ group: GroupKey; key: string; min?: number; value: number }> = [
    { group: 'ffmpeg', key: 'hlsSegmenterIdleTimeout', value: draft.ffmpeg.hlsSegmenterIdleTimeout },
    { group: 'ffmpeg', key: 'workAheadSegmenterLimit', value: draft.ffmpeg.workAheadSegmenterLimit },
    { group: 'ffmpeg', key: 'initialSegmentCount', value: draft.ffmpeg.initialSegmentCount },
    { group: 'playout', key: 'daysToBuild', value: draft.playout.daysToBuild },
    { group: 'playout', key: 'scriptedScheduleTimeoutSeconds', value: draft.playout.scriptedScheduleTimeoutSeconds },
    { group: 'xmltv', key: 'daysToBuild', value: draft.xmltv.daysToBuild },
    { group: 'scanner', key: 'libraryRefreshInterval', value: draft.scanner.libraryRefreshInterval },
    // Backend rejects a tuner count of 0 - require at least 1 here so the client
    // catches it before the round trip.
    { group: 'hdhr', key: 'tunerCount', min: 1, value: draft.hdhr.tunerCount }
  ];

  const invalidFields = new Set(
    numericFields
      .filter((field) => !isValidInt(field.value, field.min ?? 0))
      .map((field) => `${field.group}.${field.key}`)
  );

  const dirtyGroups = GROUP_KEYS.filter(
    (key) => countChangedFields(draft[key] as Record<string, unknown>, saved[key] as Record<string, unknown>) > 0
  );
  const dirtyCount = GROUP_KEYS.reduce(
    (sum, key) => sum + countChangedFields(draft[key] as Record<string, unknown>, saved[key] as Record<string, unknown>),
    0
  );

  const discard = () => {
    setDraft(saved);
    setSaveError(null);
  };

  const save = async () => {
    if (invalidFields.size > 0 || dirtyGroups.length === 0) {
      return;
    }

    setSaving(true);
    setSaveError(null);

    // allSettled (not all): one group's PUT failing after others already succeeded
    // must not leave the succeeded groups' fields stuck marked dirty. Each fulfilled
    // group's response (the server's updated DTO) is merged into `saved`/`draft`;
    // failed groups are left untouched so they stay dirty and re-save is retried.
    const results = await Promise.allSettled(dirtyGroups.map((key) => saveGroup(key, draft)));

    const merged: Partial<Draft> = {};
    const failures: Array<{ key: GroupKey; reason: unknown }> = [];

    results.forEach((result, index) => {
      const key = dirtyGroups[index];

      if (result.status === 'fulfilled') {
        Object.assign(merged, result.value);
      } else {
        failures.push({ key, reason: result.reason });
      }
    });

    if (Object.keys(merged).length > 0) {
      setSaved((current) => (current ? { ...current, ...merged } : current));
      setDraft((current) => (current ? { ...current, ...merged } : current));
    }

    if (failures.length > 0) {
      const names = failures.map((failure) => GROUP_LABELS[failure.key]).join(', ');
      setSaveError(`${names}: ${messageFromSettingsError(failures[0].reason, 'Unable to save settings')}`);
    } else {
      setJustSaved(true);
      window.setTimeout(() => setJustSaved(false), 1800);
    }

    setSaving(false);
  };

  const addResolution = async (width: number, height: number) => {
    await createResolution({ height, width });
    await query.refreshResolutions();
  };

  const confirmDeleteResolution = async () => {
    if (!deleteTarget) {
      return;
    }

    setDeleting(true);
    setDeleteError(null);

    try {
      await deleteResolution(deleteTarget.id);
      await query.refreshResolutions();
      setDeleteTarget(null);
    } catch (error) {
      // Surfaced inside the still-open ConfirmDialog (not saveError/the save bar,
      // which only mounts while the draft is dirty - with a clean draft a failed
      // DELETE would otherwise show nothing at all).
      setDeleteError(messageFromSettingsError(error, 'Unable to delete resolution'));
    } finally {
      setDeleting(false);
    }
  };

  const requestDeleteResolution = (resolution: Resolution) => {
    setDeleteError(null);
    setDeleteTarget(resolution);
  };

  return (
    <div className="ctv-settings">
      <nav aria-label="Settings sections" className="ctv-settings-rail">
        {SECTIONS.map((entry) => {
          const active = entry.id === section;

          return (
            <button
              aria-current={active ? 'true' : undefined}
              className={`ctv-settings-rail-item ctv-press${active ? ' ctv-settings-rail-item-active' : ''}`}
              key={entry.id}
              onClick={() => selectSection(entry.id)}
              type="button"
            >
              <span className="ctv-settings-rail-icon" style={{ color: active ? 'var(--ctv-accent)' : 'var(--text-secondary)' }}>
                {entry.icon}
              </span>
              <span className="ctv-settings-rail-text">
                <span className="ctv-settings-rail-label" style={{ color: active ? 'var(--text-primary)' : 'var(--text-secondary)' }}>
                  {entry.label}
                </span>
                <span className="ctv-settings-rail-hint">{entry.hint}</span>
              </span>
            </button>
          );
        })}
      </nav>

      <div className="ctv-settings-body">
        <div className="ctv-settings-scroll">
          {section === 'general' && <GeneralPane set={(patch) => set('ui', patch)} ui={draft.ui} />}
          {section === 'streaming' && (
            <StreamingPane
              data={data}
              ffmpeg={draft.ffmpeg}
              invalidFields={invalidFields}
              onAddResolution={addResolution}
              onDeleteResolution={requestDeleteResolution}
              set={(patch) => set('ffmpeg', patch)}
            />
          )}
          {section === 'playout' && (
            <PlayoutPane invalidFields={invalidFields} playout={draft.playout} set={(patch) => set('playout', patch)} />
          )}
          {section === 'xmltv' && (
            <XmltvPane invalidFields={invalidFields} set={(patch) => set('xmltv', patch)} xmltv={draft.xmltv} />
          )}
          {section === 'iptv' && <IptvPane iptv={draft.iptv} set={(patch) => set('iptv', patch)} />}
          {section === 'scanner' && (
            <ScannerPane invalidFields={invalidFields} scanner={draft.scanner} set={(patch) => set('scanner', patch)} />
          )}
          {section === 'logging' && <LoggingPane logging={draft.logging} set={(patch) => set('logging', patch)} />}
          {section === 'system' && (
            <SystemPane data={data} hdhr={draft.hdhr} invalidFields={invalidFields} set={(patch) => set('hdhr', patch)} />
          )}
        </div>

        {(dirtyCount > 0 || justSaved) && (
          <div className="ctv-settings-savebar-wrap">
            <div className="ctv-settings-savebar ctv-lift">
              {justSaved ? (
                <>
                  <CircleCheck aria-hidden="true" color="var(--status-ok)" size={15} />
                  <span className="ctv-settings-savebar-text">Settings saved</span>
                </>
              ) : (
                <>
                  <span className="ctv-settings-savebar-text">
                    {dirtyCount} unsaved change{dirtyCount === 1 ? '' : 's'}
                  </span>
                  {saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
                  <span className="ctv-settings-savebar-divider" />
                  <Button onClick={discard} size="sm" variant="ghost">
                    Discard
                  </Button>
                  <Button
                    disabled={invalidFields.size > 0 || saving}
                    loading={saving}
                    onClick={() => void save()}
                    size="sm"
                    startIcon={<Check aria-hidden="true" size={14} />}
                    variant="primary"
                  >
                    Save changes
                  </Button>
                </>
              )}
            </div>
          </div>
        )}
      </div>

      <ConfirmDialog
        busy={deleting}
        confirmLabel="Delete"
        message={
          deleteTarget ? (
            <>
              <span>{`Delete the custom resolution ${deleteTarget.width}×${deleteTarget.height}? This cannot be undone.`}</span>
              {deleteError && (
                <span className="ctv-field-error" role="alert">
                  {deleteError}
                </span>
              )}
            </>
          ) : (
            ''
          )
        }
        onCancel={() => {
          setDeleteTarget(null);
          setDeleteError(null);
        }}
        onConfirm={() => void confirmDeleteResolution()}
        open={deleteTarget !== null}
        title="Delete resolution"
        tone="danger"
      />
    </div>
  );
}
