import { app, safeStorage } from 'electron'
import Store from 'electron-store'
import { RENAMED_SETTINGS_KEYS } from './settingsMigration'
import { parseSettingsField } from './settingsSchema'
import { sanitizeOptions } from './settingsOptions'
import { logger } from './logger'
import { ACCENT_COLORS, DEFAULT_SETTINGS, type Settings, type DownloadOptions } from '@shared/ipc'

// The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared
// contract — single-sourced so the renderer FALLBACK and the preview mock can't
// drift from main (C1). The rationale for individual defaults (SR1/SR2/SR3) lives
// alongside DEFAULT_SETTINGS in shared/ipc.ts.
const DEFAULTS: Settings = DEFAULT_SETTINGS

/**
 * Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at
 * startup and whenever the toggle changes. On Windows this writes/removes a
 * per-user registry Run entry — no admin needed, matching the app's no-elevation
 * stance. Best-effort: a failure here just means the toggle didn't take effect.
 */
export function applyLaunchAtStartup(enabled: boolean): void {
  try {
    app.setLoginItemSettings({ openAtLogin: enabled })
  } catch {
    /* non-fatal — e.g. unsupported platform */
  }
}

// sanitizeOptions moved to settingsOptions.ts (CC9) so the field schemas can
// reuse it without an import cycle; re-imported above for the legacy-file
// migration below.

// Constructed lazily — electron-store needs app paths. (`userData` resolves
// pre-ready too, which the W15 hardware-acceleration gate in index.ts relies on.)
let store: Store<Settings> | null = null
function getStore(): Store<Settings> {
  if (!store) {
    store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
    // CC1 key-rename migration: move any legacy key still on disk to its new
    // name, once. `has()` reports defaults as present, so probe the raw store
    // object instead; idempotent because the old key is deleted afterwards.
    const raw = store.store as unknown as Record<string, unknown>
    const legacy = RENAMED_SETTINGS_KEYS.filter(([oldKey]) => oldKey in raw)
    if (legacy.length > 0) {
      const untyped = store as unknown as {
        set(k: string, v: unknown): void
        delete(k: string): void
      }
      for (const [oldKey, newKey] of legacy) {
        untyped.set(newKey, raw[oldKey])
        untyped.delete(oldKey)
      }
    }
  }
  return store
}

// Decrypted-settings cache — avoids repeated DPAPI calls on hot paths such as
// buildCommand, the maxConcurrent check, completion notify, and the system-theme
// bridge (PERF1). Invalidated by every setSettings write and by the one-time
// migrateSecretsAtRest so callers always see the current values.
let cachedSettings: Settings | null = null

// --- Credential encryption at rest ------------------------------------------
// proxy / youtubePoToken can carry secrets (a proxy password, API tokens).
// They're stored encrypted via Electron safeStorage (DPAPI on Windows) so a
// leaked settings.json doesn't expose them. They're decrypted before reaching
// the renderer; backup export strips them entirely (see backup.ts, M22).
// Exported so backup.ts shares this one list rather than keeping a parallel copy.
export const SECRET_KEYS = ['proxy', 'youtubePoToken'] as const

// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
// legacy plaintext value (written before encryption existed, or while safeStorage
// was unavailable) and migrate it on the next write.
const ENC_PREFIX = 'enc:v1:'

/** Encrypt a secret for storage. Falls back to plaintext where safeStorage is unavailable. */
function encryptSecret(plain: string): string {
  if (!plain) return ''
  try {
    if (safeStorage.isEncryptionAvailable()) {
      return ENC_PREFIX + safeStorage.encryptString(plain).toString('base64')
    }
  } catch {
    /* fall through — store plaintext, as it was before encryption existed */
  }
  // R7: OS encryption (DPAPI/keychain) is unavailable, so this credential is about
  // to be written in cleartext. Warn rather than fall back silently — a leaked
  // settings.json would then expose the proxy password / API token. (DPAPI is
  // effectively always present on Windows, so this should never fire in practice.)
  logger.warn('OS secret encryption unavailable — storing credential as plaintext')
  return plain
}

/** Decrypt a stored secret. Legacy plaintext is returned as-is; an undecryptable blob → ''. */
function decryptSecret(stored: string): string {
  if (!stored.startsWith(ENC_PREFIX)) return stored
  try {
    return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
  } catch {
    // Different user/machine or a corrupt blob — drop it rather than surface
    // ciphertext into the UI or onto a yt-dlp command line.
    return ''
  }
}

/** A copy of settings with the credential fields decrypted for in-process use. */
function withDecryptedSecrets(raw: Settings): Settings {
  const out = { ...raw }
  for (const key of SECRET_KEYS) out[key] = decryptSecret(raw[key] ?? '')
  return out
}

/**
 * One-time (per launch) migration: re-store any credential still held as legacy
 * plaintext as ciphertext. Lets settings files written before at-rest encryption
 * get protected without a write on the hot getSettings() path. No-op when there's
 * nothing to migrate or safeStorage is unavailable.
 */
export function migrateSecretsAtRest(): void {
  let available = false
  try {
    available = safeStorage.isEncryptionAvailable()
  } catch {
    return
  }
  if (!available) return
  const s = getStore()
  for (const key of SECRET_KEYS) {
    const raw = s.get(key) ?? ''
    if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw))
  }
  cachedSettings = null
}

export function getSettings(): Settings {
  if (cachedSettings) return cachedSettings
  const s = getStore()
  // getSettings() is on hot paths (buildCommand, notification checks, the system-
  // theme bridge, several IPC handlers). electron-store writes to disk on every
  // `set`, so only write when something actually changed — otherwise this churns
  // the settings file on every read. (audit P1)
  const cur = s.store
  // videoFolder/audioFolder are intentionally left blank by default — an empty value
  // routes that kind into Documents\Video / Documents\Audio (see buildCommand).
  // Only an explicit user choice (Settings → folders) overrides that.
  // Migrate settings files that predate downloadOptions (or hold a partial one),
  // but only persist when sanitizing actually altered the stored value.
  const sanitized = sanitizeOptions(cur.downloadOptions)
  if (!downloadOptionsEqual(sanitized, cur.downloadOptions)) {
    s.set('downloadOptions', sanitized)
  }
  // Coerce an accentColor left over from a renamed/removed preset (e.g. an old
  // 'toffee' or 'indigo' default) onto the current default, so it resolves cleanly.
  if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) {
    s.set('accentColor', DEFAULTS.accentColor)
  }
  // Migrate the legacy audio-quality label 'Best (MP3)' to the format-agnostic
  // 'Best' so the dropdown matches and the label no longer names a format (M18).
  if (cur.defaultAudioQuality === 'Best (MP3)') {
    s.set('defaultAudioQuality', 'Best')
  }
  // Hand callers (and, via IPC, the renderer) plaintext credentials — they're
  // only encrypted on disk (see withDecryptedSecrets / encryptSecret).
  cachedSettings = withDecryptedSecrets(s.store)
  return cachedSettings
}

/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
  if (!b || typeof b !== 'object') return false
  const o = b as Partial<DownloadOptions>
  const keys = Object.keys(a) as (keyof DownloadOptions)[]
  for (const k of keys) {
    if (k === 'sponsorBlockCategories') {
      const av = a[k]
      const bv = o[k]
      if (!Array.isArray(bv) || av.length !== bv.length) return false
      for (let i = 0; i < av.length; i++) if (av[i] !== bv[i]) return false
    } else if (a[k] !== o[k]) {
      return false
    }
  }
  return true
}

// Validate each key before persisting — the renderer is the only caller today,
// but settings flow into process spawning (maxConcurrent) and the native window
// background (theme), so an out-of-range or malformed value shouldn't get stored.
export function setSettings(partial: Partial<Settings>): Settings {
  const s = getStore()
  try {
    applySettings(s, partial)
  } catch (e) {
    // R5: a write failure (disk full, read-only profile) must not crash the IPC
    // handler or surface as an unhandled rejection. Log it; getSettings() below
    // returns the store's ACTUAL persisted state, so the renderer's reconciliation
    // (M34) reflects what truly saved instead of the optimistic value.
    logger.error('settings write failed', e)
  }
  cachedSettings = null
  return getSettings()
}

function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
  for (const key of Object.keys(partial) as (keyof Settings)[]) {
    const value = partial[key]
    if (value === undefined) continue
    // One schema layer validates AND coerces every field (CC9) — the parsed
    // output is exactly what gets stored; a failed parse skips the key so the
    // stored value stays. Side effects that don't belong in a pure schema stay
    // here: credential fields are encrypted at rest (see encryptSecret; they're
    // stripped from backup exports via SECRET_KEYS), and launchAtStartup syncs
    // the OS run-at-sign-in entry.
    const parsed = parseSettingsField(key, value)
    if (!parsed.ok) continue
    if (SECRET_KEYS.includes(key as (typeof SECRET_KEYS)[number])) {
      s.set(key, encryptSecret(parsed.value as string) as Settings[typeof key])
    } else {
      s.set(key, parsed.value)
    }
    if (key === 'launchAtStartup') applyLaunchAtStartup(parsed.value as boolean)
  }
}
