import type { SkinBranding, SkinColors } from '@hermes/shared/skin'

import { desaturate, grayOf, liftForContrast, mix, parseColor, relativeLuminance, toHex } from './lib/color.js'

export interface ThemeColors {
  primary: string
  accent: string
  border: string
  text: string
  muted: string
  completionBg: string
  completionCurrentBg: string
  completionMetaBg: string
  completionMetaCurrentBg: string

  label: string
  ok: string
  error: string
  warn: string

  /** Tool-call markers (● bullet, tool spinner). Defaults to `accent`. */
  tool: string
  /** Reasoning/thinking body text. Defaults to `muted`. */
  thinking: string

  /** Code-block syntax highlight. Default to accent/text/border/muted. */
  syntaxString: string
  syntaxNumber: string
  syntaxKeyword: string
  syntaxComment: string

  prompt: string
  sessionLabel: string
  sessionBorder: string

  statusBg: string
  statusFg: string
  statusGood: string
  statusWarn: string
  statusBad: string
  statusCritical: string
  selectionBg: string

  diffAdded: string
  diffRemoved: string
  diffAddedWord: string
  diffRemovedWord: string

  shellDollar: string
}

export interface ThemeBrand {
  name: string
  icon: string
  prompt: string
  welcome: string
  goodbye: string
  tool: string
  helpHeader: string
}

export interface Theme {
  color: ThemeColors
  brand: ThemeBrand
  bannerLogo: string
  bannerHero: string
}

// ── Color math ───────────────────────────────────────────────────────
//
// All generic color computation lives in lib/color.ts (the color primitive);
// this file keeps only the ANSI-256 remapping that is specific to the
// limited-palette Apple Terminal path. contrastRatio/ensureContrast are
// re-exported for existing consumers (tests, /theme-info).

export { contrastRatio, ensureContrast } from './lib/color.js'

const XTERM_6_LEVELS = [0, 95, 135, 175, 215, 255] as const
const ANSI_LIGHT_MAX_LUMINANCE = 0.72
const ANSI_LIGHT_TARGET_LUMINANCE = 0.34
const ANSI_LIGHT_MIN_SATURATION = 0.22
const ANSI_MUTED_BUCKET = 245

const ANSI_NORMALIZED_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
  'text',
  'label',
  'ok',
  'error',
  'warn',
  'prompt',
  'statusFg',
  'statusGood',
  'statusWarn',
  'statusBad',
  'statusCritical',
  'shellDollar',
  'tool'
]

const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder', 'thinking']

function xtermEightBitRgb(colorNumber: number): [number, number, number] {
  if (colorNumber >= 232) {
    const value = 8 + (colorNumber - 232) * 10

    return [value, value, value]
  }

  if (colorNumber >= 16) {
    const offset = colorNumber - 16

    return [
      XTERM_6_LEVELS[Math.floor(offset / 36) % 6]!,
      XTERM_6_LEVELS[Math.floor(offset / 6) % 6]!,
      XTERM_6_LEVELS[offset % 6]!
    ]
  }

  return [0, 0, 0]
}

const rgbLuminance = (red: number, green: number, blue: number): number =>
  relativeLuminance(toHex([red, green, blue])) ?? 0

function rgbToHsl(red: number, green: number, blue: number): [number, number, number] {
  const rn = red / 255
  const gn = green / 255
  const bn = blue / 255
  const max = Math.max(rn, gn, bn)
  const min = Math.min(rn, gn, bn)
  const lightness = (max + min) / 2

  if (max === min) {
    return [0, 0, lightness]
  }

  const delta = max - min
  const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)

  const hue =
    max === rn ? (gn - bn) / delta + (gn < bn ? 6 : 0) : max === gn ? (bn - rn) / delta + 2 : (rn - gn) / delta + 4

  return [hue / 6, saturation, lightness]
}

function circularDistance(a: number, b: number): number {
  const distance = Math.abs(a - b)

  return Math.min(distance, 1 - distance)
}

// Mirrors @hermes/ink's colorize.ts. Keep local: app code compiles from
// ui-tui/src, while @hermes/ink is bundled separately from packages/.
function richEightBitColorNumber(red: number, green: number, blue: number): number {
  const [, saturation, lightness] = rgbToHsl(red, green, blue)

  if (saturation < 0.15) {
    const gray = Math.round(lightness * 25)

    return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray
  }

  const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40
  const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40
  const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40

  return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue)
}

function bestReadableAnsiColor(red: number, green: number, blue: number): number {
  const [hue, saturation, lightness] = rgbToHsl(red, green, blue)
  let bestColor = richEightBitColorNumber(red, green, blue)
  let bestScore = Number.POSITIVE_INFINITY

  for (let colorNumber = 16; colorNumber <= 255; colorNumber += 1) {
    const [candidateRed, candidateGreen, candidateBlue] = xtermEightBitRgb(colorNumber)
    const candidateLuminance = rgbLuminance(candidateRed, candidateGreen, candidateBlue)

    if (candidateLuminance > ANSI_LIGHT_MAX_LUMINANCE) {
      continue
    }

    const [candidateHue, candidateSaturation, candidateLightness] = rgbToHsl(
      candidateRed,
      candidateGreen,
      candidateBlue
    )

    const saturationFloorPenalty =
      candidateSaturation < ANSI_LIGHT_MIN_SATURATION ? (ANSI_LIGHT_MIN_SATURATION - candidateSaturation) * 3 : 0

    const score =
      circularDistance(candidateHue, hue) * 4 +
      Math.abs(candidateSaturation - Math.max(ANSI_LIGHT_MIN_SATURATION, saturation)) * 0.8 +
      Math.abs(candidateLightness - Math.min(lightness, ANSI_LIGHT_TARGET_LUMINANCE)) * 2 +
      saturationFloorPenalty

    if (score < bestScore) {
      bestColor = colorNumber
      bestScore = score
    }
  }

  return bestColor
}

function normalizeAnsiForeground(color: string): string {
  const rgb = parseColor(color)

  if (!rgb) {
    return color
  }

  const richAnsi = richEightBitColorNumber(rgb[0], rgb[1], rgb[2])
  const richRgb = xtermEightBitRgb(richAnsi)

  const ansi =
    rgbLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE
      ? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2])
      : richAnsi

  return `ansi256(${ansi})`
}

// ── Defaults ─────────────────────────────────────────────────────────

const BRAND: ThemeBrand = {
  name: 'Hermes Agent',
  icon: '⚕',
  prompt: '❯',
  welcome: 'Type your message or /help for commands.',
  goodbye: 'Goodbye! ⚕',
  tool: '┊',
  helpHeader: '(^_^)? Commands'
}

const cleanPromptSymbol = (s: string | undefined, fallback: string) => {
  const cleaned = String(s ?? '')
    .replace(/\s+/g, ' ')
    .trim()

  return cleaned || fallback
}

// ── Seeds → palette ──────────────────────────────────────────────────
//
// A palette is BUILT, not enumerated: skins/base themes supply identity seeds
// (text, primary, accent, semantic hues) and every secondary tone is derived
// by the mix ladder against the background. This is the desktop token system
// (seeds → color-mix → tokens) in terminal form — "dim" is definitionally a
// derivative of the theme's own base colors and can never be incoherent.

export interface ThemeSeeds {
  accent: string
  /** Identity fill override: active list-row chip (derived when omitted). */
  activeRow?: string
  bg: string
  border?: string
  error: string
  /** Identity tone override: muted/dim text (derived when omitted). */
  muted?: string
  ok: string
  primary: string
  prompt?: string
  /** Identity fill override: text-selection highlight (derived when omitted). */
  selection?: string
  shellDollar: string
  statusBad: string
  statusCritical: string
  statusGood: string
  statusWarn: string
  /** Identity fill override: panel/status surface (derived when omitted). */
  surface?: string
  text: string
  warn: string
}

const DIFF_DARK = {
  diffAdded: 'rgb(220,255,220)',
  diffRemoved: 'rgb(255,220,220)',
  diffAddedWord: 'rgb(36,138,61)',
  diffRemovedWord: 'rgb(207,34,46)'
}

const DIFF_LIGHT = {
  diffAdded: 'rgb(200,240,200)',
  diffRemoved: 'rgb(240,200,200)',
  diffAddedWord: 'rgb(27,94,32)',
  diffRemovedWord: 'rgb(183,28,28)'
}

export function buildPalette(seeds: ThemeSeeds, isLight: boolean): ThemeColors {
  const tones = deriveTones(seeds)
  const surface = seeds.surface ?? tones.surface
  const activeRow = seeds.activeRow ?? (seeds.surface ? mix(surface, seeds.accent, 0.22) : tones.activeRow)
  const muted = seeds.muted ?? tones.muted

  return {
    primary: seeds.primary,
    accent: seeds.accent,
    border: seeds.border ?? tones.border,
    text: seeds.text,
    muted,
    completionBg: surface,
    completionCurrentBg: activeRow,
    completionMetaBg: surface,
    completionMetaCurrentBg: activeRow,

    label: tones.label,
    ok: seeds.ok,
    error: seeds.error,
    warn: seeds.warn,

    // Element tokens: independently settable, but default to their semantic
    // parents (tool marker → accent, reasoning body → muted).
    tool: seeds.accent,
    thinking: muted,

    // Code-syntax tokens default to brand tokens (unchanged highlighting)
    // but are independently skinnable.
    syntaxString: seeds.accent,
    syntaxNumber: seeds.text,
    syntaxKeyword: seeds.border ?? tones.border,
    syntaxComment: muted,

    prompt: seeds.prompt ?? seeds.text,
    // sessionLabel/sessionBorder track the muted tone — "same role, same
    // colour" by design (#11300).
    sessionLabel: muted,
    sessionBorder: muted,

    statusBg: surface,
    statusFg: tones.statusFg,
    statusGood: seeds.statusGood,
    statusWarn: seeds.statusWarn,
    statusBad: seeds.statusBad,
    statusCritical: seeds.statusCritical,
    selectionBg: seeds.selection ?? tones.selection,

    ...(isLight ? DIFF_LIGHT : DIFF_DARK),
    shellDollar: seeds.shellDollar
  }
}

export const DARK_SEEDS: ThemeSeeds = {
  accent: '#FFBF00',
  // The classic Hermes navy surfaces are IDENTITY, not derivation drift —
  // keep them as explicit fill seeds (the ladder derives them for skins
  // that don't care).
  activeRow: '#333355',
  bg: '#101014',
  border: '#CD7F32',
  error: '#ef5350',
  ok: '#4caf50',
  primary: '#FFD700',
  prompt: '#FFF8DC',
  selection: '#3a3a55',
  shellDollar: '#4dabf7',
  statusBad: '#FF8C00',
  statusCritical: '#FF6B6B',
  statusGood: '#8FBC8F',
  statusWarn: '#FFD700',
  surface: '#1a1a2e',
  text: '#FFF8DC',
  warn: '#ffa726'
}

// Light-terminal seeds: darker golds/ambers that stay legible on white.
// The classic light-mode Hermes look was never hand-authored: for years the
// TUI emitted the DARK golds and hosts with xterm's minimumContrastRatio
// (Cursor defaults to 4.5) lifted them against white — hue and saturation
// kept, luminance clamped. These seeds are those exact lifts
// (liftForContrast(dark, '#ffffff', 4.5)), so hosts WITHOUT a contrast pass
// render the same thing Cursor always showed. Text/prompt stay ink — body
// copy historically rendered in the terminal's default near-black fg.
export const LIGHT_SEEDS: ThemeSeeds = {
  accent: '#956E00',
  bg: '#ffffff',
  border: '#A56628',
  error: '#C14240',
  ok: '#367E39',
  primary: '#867000',
  prompt: '#2B2014',
  shellDollar: '#377BB3',
  statusBad: '#A65A00',
  statusCritical: '#B94D4D',
  statusGood: '#5C7A5C',
  statusWarn: '#867000',
  text: '#3D2F13',
  warn: '#956115'
}

export const DARK_THEME: Theme = {
  color: buildPalette(DARK_SEEDS, false),
  brand: BRAND,
  bannerLogo: '',
  bannerHero: ''
}

export const LIGHT_THEME: Theme = {
  color: buildPalette(LIGHT_SEEDS, true),
  brand: BRAND,
  bannerLogo: '',
  bannerHero: ''
}

// ── Background-aware readability adaptation ─────────────────────────
//
// Mirrors the desktop app's theme contract (apps/desktop/src/themes): skins
// contribute accent IDENTITY; readability against the actual background is
// the theme engine's job, enforced in one place. Two guards, in the desktop's
// vocabulary:
//
//   * `ensureContrast` — foreground-role colors are step-mixed toward the
//     readable pole (black on light, white on dark) until they clear a
//     minimum WCAG contrast ratio against the real (or assumed) background.
//     Hue survives; washout doesn't.
//   * Fill polarity — background-role colors (completion menu, status bar,
//     selection) must match the terminal's polarity. Unlike the desktop, the
//     TUI does not own its canvas — panels sit directly on the terminal's
//     background — so a wrong-polarity fill (navy menu on a white terminal)
//     falls back to the base palette even when the skin authored it.

// Display shim — the "rendering gotcha" layer, calibrated against the look
// the maintainers standardized on (pixel-sampled from the reference
// screenshot): the beloved cross-polarity rendering is the AUTHORED palette
// displayed RAW — slate's ~1.5:1 pastels on white read as deliberate airy
// hierarchy, not a bug. So the floors are barely-visible rescues only:
//   * DISPLAY 1.45 sits just above slate-pastel territory (#c9d1d9 = 1.54,
//     passes raw, byte-identical) but just below true invisibility
//     (default's cream #FFF8DC = 1.08, gets rescued).
//   * SEMANTIC 2.2 for alert colors (ok/error/warn/status) — they carry
//     meaning and must never vanish.
// The lift itself is xterm.js's own multiplicative algorithm
// (liftForContrast), so on hosts that run their own minimumContrastRatio the
// two adjustments agree instead of fighting.
// Foreground floors are polarity-aware. On a DARK background the authored
// palette is already bright, so a modest floor only rescues the rare dark
// tone. On a LIGHT background — which in practice means a TRANSPARENT Cursor/
// terminal window compositing over a light editor, where xterm applies NO
// contrast lift of its own (there is no solid bg to measure against) — the
// beloved classic look is the authored palette rendered essentially RAW:
// vivid #FFD700 gold (~1.36:1), not a WCAG-darkened mustard. So the light
// floor is a near-invisible rescue only (catches cream #FFF8DC at 1.08 but
// leaves the golds untouched). Pixel-sampled target: #F5C242 (L61 S90),
// which the previous 1.45 floor crushed to #867000 (L26) — the reported mud.
const DISPLAY_MIN_CONTRAST = 1.45
const SEMANTIC_MIN_CONTRAST = 2.2
const LIGHT_DISPLAY_MIN_CONTRAST = 1.18
const LIGHT_SEMANTIC_MIN_CONTRAST = 1.6

const DISPLAY_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
  'primary',
  'accent',
  'text',
  'label',
  'prompt',
  'statusFg',
  'border',
  'muted',
  'sessionLabel',
  'sessionBorder',
  'shellDollar'
]

const SEMANTIC_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
  'ok',
  'error',
  'warn',
  'statusGood',
  'statusWarn',
  'statusBad',
  'statusCritical'
]

const ADAPTIVE_BACKGROUNDS: readonly (keyof ThemeColors)[] = [
  'completionBg',
  'completionCurrentBg',
  'completionMetaBg',
  'completionMetaCurrentBg',
  'statusBg',
  'selectionBg'
]

// Fill polarity limits: on light terminals a fill must stay light, and vice
// versa — there is no readable middle for a panel fill on the wrong pole.
const LIGHT_BG_MIN_LUMINANCE = 0.4
const DARK_BG_MAX_LUMINANCE = 0.35

function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors {
  const out = { ...colors }
  const displayFloor = isLight ? LIGHT_DISPLAY_MIN_CONTRAST : DISPLAY_MIN_CONTRAST
  const semanticFloor = isLight ? LIGHT_SEMANTIC_MIN_CONTRAST : SEMANTIC_MIN_CONTRAST

  for (const key of DISPLAY_FOREGROUNDS) {
    out[key] = liftForContrast(out[key], bg, displayFloor)
  }

  for (const key of SEMANTIC_FOREGROUNDS) {
    out[key] = liftForContrast(out[key], bg, semanticFloor)
  }

  for (const key of ADAPTIVE_BACKGROUNDS) {
    const luminance = relativeLuminance(out[key])

    if (luminance === null) {
      continue
    }

    if (isLight ? luminance < LIGHT_BG_MIN_LUMINANCE : luminance > DARK_BG_MAX_LUMINANCE) {
      out[key] = base[key]
    }
  }

  return out
}

/** The background hex adaptation measures contrast against: the OSC-11
 *  answer when known (cached in HERMES_TUI_BACKGROUND), else the mode's
 *  assumed pole. */
function referenceBackground(isLight: boolean, env: NodeJS.ProcessEnv = process.env): string {
  const cached = (env.HERMES_TUI_BACKGROUND ?? '').trim()

  if (cached && backgroundLuminance(cached) !== null) {
    return cached.startsWith('#') ? cached : `#${cached}`
  }

  return isLight ? '#ffffff' : '#101014'
}

// ── Derived tone ladder (the desktop color-mix system) ──────────────
//
// A theme is a handful of SEEDS (text, primary, accent, border, status hues);
// every secondary tone — muted text, labels, surfaces, selection chips — is a
// color-mix derivative of those seeds against the real terminal background,
// exactly like the desktop's `--theme-*` seeds → `--ui-*` color-mix ladder in
// apps/desktop/src/styles.css. Skins therefore cannot ship an incoherent
// "dim": if they don't author a tone, it is DERIVED from their own identity,
// never inherited from another skin's palette.
//
// Mix knobs are the single source of truth for tone hierarchy. (The classic
// prompt_toolkit CLI still reads the built-in skins' complete authored
// palettes; those were generated with the same math.)

export interface ThemeTones {
  /** Secondary/dim text: receded accent (dark) / primary-ink blend (light). */
  muted: string
  /** Field labels: one step brighter than muted, same family. */
  label: string
  /** Status-bar default text: the gray of slightly-receded text. */
  statusFg: string
  /** Raised panel fill: background nudged toward the (softened) accent. */
  surface: string
  /** Active list-row chip: surface tinted with accent. */
  activeRow: string
  /** Text-selection highlight: bg tinted with the theme's blue (light) or accent (dark). */
  selection: string
  /** Border fallback: accent receded toward the background. */
  border: string
}

/**
 * The fitted tone ladder. Knobs are REVERSE-ENGINEERED from the original
 * hand-tuned palettes (grid-search over mix/desaturate formula families
 * against the pre-refactor literals + every authored skin palette; see the
 * "reproduces the original hand-tuned tones" test for the contract):
 *
 *   dark muted  #CC9B1F ≈ desaturate(mix(accent, bg, .19), .16)  (err 3)
 *   dark label  #DAA520 ≈ desaturate(mix(accent, bg, .13), .16)  (err 3)
 *   dark status #C0C0C0 = grayOf(mix(text, bg, .24))             (err 0)
 *   light muted #946C08 ≈ desaturate(accent, .05)                (err 2)
 *   light label #8E6B13 ≈ desaturate(mix(accent, text, .03), .15) (err 2)
 *   light status #6F6F6F = grayOf(mix(text, bg, .30))            (err 1)
 *   light surface #F5F5F5 ≈ bg + softened accent                 (err 5)
 *   light chip  #E0D1BF = mix(surface, accent, .25)              (err 8)
 *   light selection #D4E4F7 ≈ mix(bg, shellDollar, .20)          (err 7)
 *
 * The light targets are the LIFT CANON: liftForContrast(dark literal,
 * white, 4.5) — what xterm's minimumContrastRatio showed on light hosts
 * for years — not hand-picked browns (those read as desaturated mud).
 *
 * The classic dark navy fills (#1a1a2e/#333355/#3a3a55) are IRREDUCIBLE from
 * gold seeds — the search bottoms out at gray, err 10–17 — so they remain
 * explicit identity seeds on DARK_SEEDS rather than pretending to be math.
 */
export function deriveTones(seeds: {
  accent: string
  bg: string
  primary: string
  shellDollar?: string
  text: string
}): ThemeTones {
  const { accent, bg, text } = seeds
  const isLight = (relativeLuminance(bg) ?? 0) > 0.5
  // Fill tint keeps most of the accent's chroma — a heavier desaturate here
  // read as washed-out ("a little too desat") next to authored fills.
  const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09)

  return {
    // Light knobs are fitted to the lift canon (xterm minimumContrastRatio
    // 4.5 of the classic dark golds against white — see LIGHT_SEEDS), not
    // to ink blends: muted #946C08 ≈ desat(accent .05), label #8E6B13 ≈
    // desat(mix(accent, text, .03), .15), statusFg #6F6F6F ≈ gray 30% lift.
    muted: isLight ? desaturate(accent, 0.05) : desaturate(mix(accent, bg, 0.19), 0.16),
    label: isLight ? desaturate(mix(accent, text, 0.03), 0.15) : desaturate(mix(accent, bg, 0.13), 0.16),
    statusFg: grayOf(mix(text, bg, isLight ? 0.3 : 0.24)),
    surface,
    activeRow: mix(surface, accent, 0.25),
    selection: isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.2) : mix(surface, accent, 0.28),
    border: mix(accent, bg, 0.25)
  }
}

const TRUE_RE = /^(?:1|true|yes|on)$/
const FALSE_RE = /^(?:0|false|no|off)$/

// TERM_PROGRAM fallback allow-list for terminals whose default profile is
// light and which may not expose COLORFGBG. This currently includes Apple
// Terminal. Explicit HERMES_TUI_THEME / COLORFGBG signals above still win,
// so dark Apple Terminal profiles that advertise a dark background stay dark.
const LIGHT_DEFAULT_TERM_PROGRAMS = new Set<string>(['Apple_Terminal'])

// Best-effort RGB → luminance check.  Currently only accepts a 3- or
// 6-digit hex value (with or without a leading `#`); the env var name
// `HERMES_TUI_BACKGROUND` is intentionally generic so a future OSC11
// query helper can cache its answer there too, but additional formats
// (rgb()/hsl()/named colours) would need explicit parsing here first.
const LUMA_LIGHT_THRESHOLD = 0.6

// Strict allow-list: parseInt(..., 16) silently truncates at the first
// non-hex character (e.g. `fffgff` would parse as `fff` and yield a
// false-positive "white" reading), so reject anything that doesn't match
// the canonical 3- or 6-digit shape up front.
const HEX_3_RE = /^[0-9a-f]{3}$/
const HEX_6_RE = /^[0-9a-f]{6}$/

function backgroundLuminance(raw: string): null | number {
  const v = raw.trim().toLowerCase()

  if (!v) {
    return null
  }

  const hex = v.startsWith('#') ? v.slice(1) : v

  const rgb = HEX_6_RE.test(hex)
    ? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
    : HEX_3_RE.test(hex)
      ? [parseInt(hex[0]! + hex[0]!, 16), parseInt(hex[1]! + hex[1]!, 16), parseInt(hex[2]! + hex[2]!, 16)]
      : null

  if (!rgb) {
    return null
  }

  // Rec. 709 luma — close enough for "is this background bright".
  return (0.2126 * rgb[0]! + 0.7152 * rgb[1]! + 0.0722 * rgb[2]!) / 255
}

// Pick light vs dark with ordered, explainable signals (#11300):
//
//   1. `HERMES_TUI_LIGHT` boolean — `1`/`true`/`yes`/`on` → light;
//      `0`/`false`/`no`/`off` → dark.  Either explicit value wins
//      regardless of any later signal.
//   2. `HERMES_TUI_THEME` named override — `light` / `dark` win over
//      every signal below.
//   3. `HERMES_TUI_BACKGROUND` hex hint (3- or 6-digit) — luminance
//      ≥ LUMA_LIGHT_THRESHOLD → light.
//   4. `COLORFGBG` last field — XFCE / rxvt / Terminal.app emit
//      slot 7 or 15 on light profiles; 0–15 ranges are otherwise
//      treated as authoritatively dark so the TERM_PROGRAM
//      allow-list below cannot override an explicit dark profile.
//   5. `TERM_PROGRAM` light-default allow-list.
//
// Anything we can't decide stays dark — the default Hermes palette
// is the dark one.
export function detectLightMode(
  env: NodeJS.ProcessEnv = process.env,
  // Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM
  // precedence rule even though the production allow-list is empty.
  lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS
): boolean {
  const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()

  if (TRUE_RE.test(lightFlag)) {
    return true
  }

  if (FALSE_RE.test(lightFlag)) {
    return false
  }

  const themeFlag = (env.HERMES_TUI_THEME ?? '').trim().toLowerCase()

  if (themeFlag === 'light') {
    return true
  }

  if (themeFlag === 'dark') {
    return false
  }

  const bgHint = backgroundLuminance(env.HERMES_TUI_BACKGROUND ?? '')

  if (bgHint !== null) {
    return bgHint >= LUMA_LIGHT_THRESHOLD
  }

  const colorfgbg = (env.COLORFGBG ?? '').trim()

  if (colorfgbg) {
    // Validate as a decimal integer before coercing — `Number('')` is 0,
    // so a malformed `COLORFGBG='15;'` would otherwise look like an
    // authoritative dark slot and incorrectly block the TERM_PROGRAM
    // allow-list.  Anything that isn't pure digits falls through.
    const lastField = colorfgbg.split(';').at(-1) ?? ''

    if (/^\d+$/.test(lastField)) {
      const bg = Number(lastField)

      if (bg === 7 || bg === 15) {
        return true
      }

      // Slots 0–6 and 8–14 are the dark half of the 0–15 ANSI range.
      // When COLORFGBG is set we trust it as authoritative — a non-light
      // value here shouldn't get overridden by the TERM_PROGRAM allow-list.
      if (bg >= 0 && bg < 16) {
        return false
      }
    }
  }

  const termProgram = (env.TERM_PROGRAM ?? '').trim()

  return lightDefaultTermPrograms.has(termProgram)
}

function shouldNormalizeAnsiLightTheme(env: NodeJS.ProcessEnv = process.env, isLight = detectLightMode(env)): boolean {
  const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase()
  const termProgram = (env.TERM_PROGRAM ?? '').trim()

  return termProgram === 'Apple_Terminal' && colorTerm !== 'truecolor' && colorTerm !== '24bit' && isLight
}

export function normalizeThemeForAnsiLightTerminal(
  theme: Theme,
  env: NodeJS.ProcessEnv = process.env,
  isLight = detectLightMode(env)
): Theme {
  if (!shouldNormalizeAnsiLightTheme(env, isLight)) {
    return theme
  }

  const color = { ...theme.color }

  for (const key of ANSI_NORMALIZED_FOREGROUNDS) {
    color[key] = normalizeAnsiForeground(color[key])
  }

  for (const key of ANSI_MUTED_FOREGROUNDS) {
    color[key] = `ansi256(${ANSI_MUTED_BUCKET})`
  }

  return { ...theme, color }
}

const DEFAULT_LIGHT_MODE = detectLightMode()

export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal(
  DEFAULT_LIGHT_MODE ? LIGHT_THEME : DARK_THEME,
  process.env,
  DEFAULT_LIGHT_MODE
)

/**
 * The skinless theme for the CURRENT light-mode signals. Unlike the frozen
 * module-load DEFAULT_THEME, this re-reads the environment — so it picks up
 * the OSC-11 background answer cached into HERMES_TUI_BACKGROUND after
 * startup. Used when the terminal background arrives before (or without) a
 * gateway skin.
 */
export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = process.env): Theme {
  const isLight = detectLightMode(env)

  return normalizeThemeForAnsiLightTerminal(isLight ? LIGHT_THEME : DARK_THEME, env, isLight)
}

// ── Skin → Theme ─────────────────────────────────────────────────────

/** The skin's authored canvas as a #-prefixed hex, or null when absent/junk. */
const authoredBackground = (raw: string | undefined): null | string => {
  const v = (raw ?? '').trim()

  return backgroundLuminance(v) === null ? null : v.startsWith('#') ? v : `#${v}`
}

/**
 * A skin that authors a background OWNS its polarity: the TUI paints the
 * terminal with it (applySkin → OSC-11), so contrast adaptation, the derived
 * tone ladder, and ANSI light-terminal normalization must all run against the
 * skin's canvas — not the host profile the skin just covered. (A pure-black
 * skin on a light Apple Terminal otherwise gets its text remapped for a light
 * background it painted over: invisible.) Host detection still governs skins
 * without a background — they render on the terminal's own surface.
 */
export function skinIsLight(colors: SkinColors, env: NodeJS.ProcessEnv = process.env): boolean {
  const authored = backgroundLuminance(colors['background'] ?? '')

  return authored === null ? detectLightMode(env) : authored >= LUMA_LIGHT_THRESHOLD
}

export function fromSkin(
  colors: SkinColors,
  branding: SkinBranding,
  bannerLogo = '',
  bannerHero = '',
  toolPrefix = '',
  helpHeader = ''
): Theme {
  // Polarity: the skin's own canvas when it authors one (see skinIsLight);
  // otherwise live host detection (not the module-load snapshot — by the time
  // the gateway skin arrives, the OSC-11 probe has usually answered and cached
  // itself into HERMES_TUI_BACKGROUND. See #applySkin / syncThemeToTerminalBackground).
  const skinBg = authoredBackground(colors['background'])
  const isLight = skinIsLight(colors)
  const bg = skinBg ?? referenceBackground(isLight)
  const base = isLight ? LIGHT_SEEDS : DARK_SEEDS
  const d = isLight ? LIGHT_THEME : DARK_THEME
  const c = (k: string) => colors[k]

  const hasSkinColors = Object.keys(colors).length > 0

  // 1. Seeds: the skin's identity. Anything it doesn't define comes from the
  //    base seeds for this polarity. The base's IDENTITY FILLS (Hermes navy
  //    surfaces, gold muted) only carry over for the skinless default — a
  //    skin with its own identity derives its fills from its own seeds.
  const identityFills: Partial<ThemeSeeds> = hasSkinColors
    ? {}
    : { activeRow: base.activeRow, muted: base.muted, selection: base.selection, surface: base.surface }

  const seeds: ThemeSeeds = {
    ...identityFills,
    accent: c('ui_accent') ?? c('banner_accent') ?? base.accent,
    bg,
    border: c('ui_border') ?? c('banner_border') ?? base.border,
    error: c('ui_error') ?? base.error,
    ok: c('ui_ok') ?? base.ok,
    primary: c('ui_primary') ?? c('banner_title') ?? base.primary,
    prompt: c('prompt') ?? c('banner_text') ?? base.prompt,
    shellDollar: c('shell_dollar') ?? base.shellDollar,
    statusBad: c('status_bar_bad') ?? base.statusBad,
    statusCritical: c('status_bar_critical') ?? c('ui_error') ?? base.statusCritical,
    statusGood: c('status_bar_good') ?? c('ui_ok') ?? base.statusGood,
    statusWarn: c('status_bar_warn') ?? c('ui_warn') ?? base.statusWarn,
    text: c('ui_text') ?? c('banner_text') ?? base.text,
    warn: c('ui_warn') ?? base.warn
  }

  // 2. Derive: every secondary tone is a color-mix of the seeds against the
  //    REAL background — dim is a derivative of the skin's own identity.
  const derived = buildPalette(seeds, isLight)

  // 3. Authored tone overrides: a skin may still hand-tune any tone; the
  //    derived ladder is the default, not a cage. Chip/selection re-derive
  //    from the FINAL surface so dependents stay coherent with overrides.
  // `background` is theme-sdk's cross-surface base: the TUI paints the
  // terminal with it (applySkin → setTerminalBackground), so panels/status
  // must sit on it too — fall the surface back to it below completion_menu_bg.
  const surface = c('completion_menu_bg') ?? c('background') ?? derived.completionBg

  // Re-mix the chip only when the skin authored its own surface; otherwise
  // the derived value already carries the identity seeds (e.g. Hermes navy).
  const activeRow =
    c('completion_menu_current_bg') ??
    (c('completion_menu_bg') ? mix(surface, seeds.accent, 0.22) : derived.completionCurrentBg)

  const assembled: ThemeColors = {
    ...derived,
    muted: c('banner_dim') ?? derived.muted,
    label: c('ui_label') ?? derived.label,
    completionBg: surface,
    completionCurrentBg: activeRow,
    completionMetaBg: c('completion_menu_meta_bg') ?? surface,
    completionMetaCurrentBg: c('completion_menu_meta_current_bg') ?? activeRow,
    sessionLabel: c('session_label') ?? c('banner_dim') ?? derived.sessionLabel,
    sessionBorder: c('session_border') ?? c('banner_dim') ?? derived.sessionBorder,
    statusBg: c('status_bar_bg') ?? surface,
    statusFg: c('status_bar_text') ?? c('ui_text') ?? c('banner_text') ?? derived.statusFg,
    selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg,
    // Element tokens + skinnable diffs (theme-sdk): overridable, else the
    // derived defaults (tool→accent, thinking→muted, diff_* → DIFF_* ladder).
    // thinking tracks the EFFECTIVE muted (banner_dim override included), not
    // just derived.muted, so recoloring muted carries the reasoning body with it.
    tool: c('ui_tool') ?? derived.tool,
    thinking: c('ui_thinking') ?? c('banner_dim') ?? derived.thinking,
    diffAdded: c('diff_added') ?? derived.diffAdded,
    diffRemoved: c('diff_removed') ?? derived.diffRemoved,
    diffAddedWord: c('diff_added_word') ?? derived.diffAddedWord,
    diffRemovedWord: c('diff_removed_word') ?? derived.diffRemovedWord,
    // Code-syntax tokens: overridable, else the derived brand-token defaults.
    syntaxString: c('syntax_string') ?? derived.syntaxString,
    syntaxNumber: c('syntax_number') ?? derived.syntaxNumber,
    syntaxKeyword: c('syntax_keyword') ?? derived.syntaxKeyword,
    syntaxComment: c('syntax_comment') ?? c('banner_dim') ?? derived.syntaxComment
  }

  // 4. Guard: contrast floors against the real background + fill polarity.
  //    Wrong-polarity fills fall back to the DERIVED value, which is
  //    polarity-correct by construction (mixed from the background itself).
  //    ANSI-limited light Apple Terminal keeps its bespoke ansi256
  //    normalization (below) instead.
  const adapted = shouldNormalizeAnsiLightTheme(process.env, isLight)
    ? assembled
    : adaptColorsToBackground(assembled, isLight, derived, bg)

  return normalizeThemeForAnsiLightTerminal(
    {
      // The element tokens theme-sdk introduced (ui_primary, ui_text,
      // ui_border, ui_ok/warn/error, ui_tool, ui_thinking, shell_dollar,
      // status_bar_*, diff_*) are read into `seeds`/`assembled` above and
      // flow through buildPalette → adaptColorsToBackground, so `adapted`
      // already honors them AND applies #20379's contrast/polarity machinery.
      // Emitting a hand-mapped color block here would bypass that adaptation
      // and regress theme quality.
      color: adapted,

      brand: {
        name: branding.agent_name ?? d.brand.name,
        icon: d.brand.icon,
        prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
        welcome: branding.welcome ?? d.brand.welcome,
        goodbye: branding.goodbye ?? d.brand.goodbye,
        tool: toolPrefix || d.brand.tool,
        helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader)
      },

      bannerLogo,
      bannerHero
    },
    process.env,
    isLight
  )
}
