// Per-list colour palette — a verbatim TS port of FediverseLists'
// `DistinctListColorPalette.swift` (OKLab max-min-distance / farthest-first).
//
// Why not hash list IDs to a hue? A naive FNV1a→hue map lands ~8 lists
// collision-near in hue roughly a third of the time, which defeats the whole
// point of colour-coding membership at a glance. The OKLab farthest-first
// pass spreads the assigned colours as far apart in perceptual space as the
// in-gamut candidate set allows.
//
// Pure TS, no React Native imports: the engine is unit-tested in
// `__tests__/listColors.ts`, and persistence of the per-account index map is
// the storage layer's job (`getListColorIndices` / `setListColorIndices` in
// `src/state/storage.ts`).

type OKLab = { l: number; a: number; b: number }

type PaletteColor = { hex: string; oklab: OKLab }

const MAX_PALETTE_SIZE = 96

function oklabDistance(x: OKLab, y: OKLab): number {
  const dl = x.l - y.l
  const da = x.a - y.a
  const db = x.b - y.b
  return Math.sqrt(dl * dl + da * da + db * db)
}

function srgbEncode(value: number): number {
  if (value <= 0.0031308) return 12.92 * value
  return 1.055 * value ** (1 / 2.4) - 0.055
}

function toHexChannel(value01: number): string {
  const v = Math.max(0, Math.min(255, Math.round(value01 * 255)))
  return v.toString(16).padStart(2, '0')
}

// OKLab → linear sRGB (Björn Ottosson's matrices, identical to the Swift
// source). Returns null when any channel falls outside [0, 1] — that's the
// in-gamut filter the candidate set relies on.
function hexFromOKLab(color: OKLab): string | null {
  const lPrime = color.l + 0.3963377774 * color.a + 0.2158037573 * color.b
  const mPrime = color.l - 0.1055613458 * color.a - 0.0638541728 * color.b
  const sPrime = color.l - 0.0894841775 * color.a - 1.291485548 * color.b

  const l = lPrime * lPrime * lPrime
  const m = mPrime * mPrime * mPrime
  const s = sPrime * sPrime * sPrime

  const linearRed = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
  const linearGreen = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
  const linearBlue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s

  if (
    linearRed < 0 ||
    linearRed > 1 ||
    linearGreen < 0 ||
    linearGreen > 1 ||
    linearBlue < 0 ||
    linearBlue > 1
  ) {
    return null
  }

  return `#${toHexChannel(srgbEncode(linearRed))}${toHexChannel(srgbEncode(linearGreen))}${toHexChannel(srgbEncode(linearBlue))}`
}

// EPS guards the inclusive ("through") upper bound against float drift so the
// last lightness/chroma step isn't dropped by an accumulated rounding error.
const EPS = 1e-9

function buildCandidates(): PaletteColor[] {
  const candidates: PaletteColor[] = []
  for (let lightness = 0.64; lightness <= 0.82 + EPS; lightness += 0.04) {
    for (let chroma = 0.1; chroma <= 0.22 + EPS; chroma += 0.03) {
      for (let hueDegrees = 0; hueDegrees < 360; hueDegrees += 6) {
        const hueRadians = (hueDegrees * Math.PI) / 180
        const a = chroma * Math.cos(hueRadians)
        const b = chroma * Math.sin(hueRadians)
        const oklab: OKLab = { l: lightness, a, b }
        const hex = hexFromOKLab(oklab)
        if (hex === null) continue
        candidates.push({ hex, oklab })
      }
    }
  }
  return candidates
}

function generatePalette(maxPaletteSize: number): PaletteColor[] {
  const candidates = buildCandidates()
  if (candidates.length === 0) return []

  const targetCount = Math.max(1, Math.min(maxPaletteSize, candidates.length))
  const selectedIndices: number[] = []
  const selected = new Set<number>()
  const minDistanceToSelection = new Array<number>(candidates.length).fill(
    Number.POSITIVE_INFINITY
  )

  // Seed: the medium-lightness, low-chroma anchor (closest to L 0.74, neutral).
  let seedIndex = 0
  let seedScore = Number.POSITIVE_INFINITY
  for (let i = 0; i < candidates.length; i++) {
    const { l, a, b } = candidates[i].oklab
    const score = Math.abs(l - 0.74) + Math.sqrt(a * a + b * b)
    if (score < seedScore) {
      seedScore = score
      seedIndex = i
    }
  }

  for (let iteration = 0; iteration < targetCount; iteration++) {
    let picked: number
    if (iteration === 0) {
      picked = seedIndex
    } else {
      picked = 0
      let best = Number.NEGATIVE_INFINITY
      for (let i = 0; i < candidates.length; i++) {
        if (selected.has(i)) continue
        if (minDistanceToSelection[i] > best) {
          best = minDistanceToSelection[i]
          picked = i
        }
      }
    }

    selectedIndices.push(picked)
    selected.add(picked)
    minDistanceToSelection[picked] = Number.NEGATIVE_INFINITY

    const pickedColor = candidates[picked].oklab
    for (let i = 0; i < candidates.length; i++) {
      if (selected.has(i)) continue
      const distance = oklabDistance(pickedColor, candidates[i].oklab)
      if (distance < minDistanceToSelection[i]) minDistanceToSelection[i] = distance
    }
  }

  return selectedIndices.map((i) => candidates[i])
}

// Generation is ~96 picks over ~1500 in-gamut candidates (one pass of
// distance updates per pick) — cheap, but still memoised so the organizer
// screen pays it once per JS process rather than once per mount.
let cachedPalette: PaletteColor[] | null = null
function palette(): PaletteColor[] {
  if (cachedPalette === null) cachedPalette = generatePalette(MAX_PALETTE_SIZE)
  return cachedPalette
}

/** Palette length — exposed for tests; not load-bearing at call sites. */
export function listPaletteSize(): number {
  return palette().length
}

// FNV1a-64 (BigInt) — only used by the fallback path for the (capped-out)
// 97th+ list, so it never needs to be fast.
function fnv1a64(input: string): bigint {
  const offsetBasis = 14695981039346656037n
  const prime = 1099511628211n
  const mask = 0xffffffffffffffffn
  let hash = offsetBasis
  for (let i = 0; i < input.length; i++) {
    hash ^= BigInt(input.charCodeAt(i) & 0xff)
    hash = (hash * prime) & mask
  }
  return hash
}

function hsbToHex(hue: number, saturation: number, brightness: number): string {
  const c = brightness * saturation
  const hp = hue * 6
  const x = c * (1 - Math.abs((hp % 2) - 1))
  let r = 0
  let g = 0
  let b = 0
  if (hp < 1) [r, g, b] = [c, x, 0]
  else if (hp < 2) [r, g, b] = [x, c, 0]
  else if (hp < 3) [r, g, b] = [0, c, x]
  else if (hp < 4) [r, g, b] = [0, x, c]
  else if (hp < 5) [r, g, b] = [x, 0, c]
  else [r, g, b] = [c, 0, x]
  const m = brightness - c
  return `#${toHexChannel(r + m)}${toHexChannel(g + m)}${toHexChannel(b + m)}`
}

// Deterministic last-resort colour for a list that has no valid palette index
// (e.g. the 97th list once the palette is exhausted). Matches the Swift
// `fallbackColor(for:)` shape: FNV1a → hue, fixed saturation/brightness.
function fallbackColor(listId: string): string {
  const hash = fnv1a64(listId)
  const hue = Number(hash & 0xffffn) / 65535
  return hsbToHex(hue, 0.75, 0.82)
}

/**
 * Reconcile a per-account `{ listId → paletteIndex }` map against the current
 * list catalogue: drop assignments whose list no longer exists (or whose index
 * fell out of palette range), keep the rest, and give each new list the open
 * palette index whose colour maximises its minimum OKLab distance to the
 * already-assigned colours. Pure; callers persist the result themselves.
 */
export function reconcileListColorIndices(
  listIds: string[],
  existing: Record<string, number>
): Record<string, number> {
  const p = palette()
  const validIds = new Set(listIds)

  const reconciled: Record<string, number> = {}
  for (const [id, index] of Object.entries(existing)) {
    if (validIds.has(id) && index >= 0 && index < p.length) reconciled[id] = index
  }

  const usedIndices = new Set<number>(Object.values(reconciled))
  const newIds = listIds.filter((id) => reconciled[id] === undefined).sort()

  for (const listId of newIds) {
    const assignedIndices = new Set<number>(Object.values(reconciled))
    const index =
      bestAvailableIndex(p, usedIndices, assignedIndices) ??
      firstOpenIndex(p, usedIndices) ??
      0
    reconciled[listId] = index
    usedIndices.add(index)
  }

  return reconciled
}

function firstOpenIndex(p: PaletteColor[], used: Set<number>): number | null {
  for (let i = 0; i < p.length; i++) if (!used.has(i)) return i
  return null
}

function bestAvailableIndex(
  p: PaletteColor[],
  used: Set<number>,
  assigned: Set<number>
): number | null {
  const firstOpen = firstOpenIndex(p, used)
  if (firstOpen === null) return null
  if (assigned.size === 0) return firstOpen

  const assignedColors = Array.from(assigned, (i) => p[i].oklab)
  let bestIndex = firstOpen
  let bestMinDistance = Number.NEGATIVE_INFINITY

  for (let i = 0; i < p.length; i++) {
    if (used.has(i)) continue
    const candidate = p[i].oklab
    let minDistance = Number.POSITIVE_INFINITY
    for (const other of assignedColors) {
      const d = oklabDistance(candidate, other)
      if (d < minDistance) minDistance = d
    }
    if (minDistance > bestMinDistance) {
      bestMinDistance = minDistance
      bestIndex = i
    }
  }

  return bestIndex
}

/**
 * Resolve a list's colour to a `#RRGGBB` hex string given an assignment map.
 * Falls back to a deterministic FNV1a-derived hue when the list has no valid
 * palette index (unassigned, or palette exhausted past the 96 cap).
 */
export function getListColor(
  listId: string,
  assignments: Record<string, number>
): string {
  const p = palette()
  const index = assignments[listId]
  if (index !== undefined && index >= 0 && index < p.length) return p[index].hex
  return fallbackColor(listId)
}
