// ─── Constants ───────────────────────────────────────────────────────────────

export const NOTES      = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
export const NOTES_FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']

// Semitone intervals for each scale mode
export const SCALES = {
  major:            [0, 2, 4, 5, 7, 9, 11],
  minor:            [0, 2, 3, 5, 7, 8, 10],
  dorian:           [0, 2, 3, 5, 7, 9, 10],
  phrygian:         [0, 1, 3, 5, 7, 8, 10],
  lydian:           [0, 2, 4, 6, 7, 9, 11],
  mixolydian:       [0, 2, 4, 5, 7, 9, 10],
  pentatonic_major: [0, 2, 4, 7, 9],
  pentatonic_minor: [0, 3, 5, 7, 10],
  blues:            [0, 3, 5, 6, 7, 10],
  diminished:       [0, 2, 3, 5, 6, 8, 9, 11],
  whole_tone:       [0, 2, 4, 6, 8, 10],
}

// Human-readable scale labels
export const SCALE_LABELS = {
  major:            'Major',
  minor:            'Natural Minor',
  dorian:           'Dorian',
  phrygian:         'Phrygian',
  lydian:           'Lydian',
  mixolydian:       'Mixolydian',
  pentatonic_major: 'Major Pentatonic',
  pentatonic_minor: 'Minor Pentatonic',
  blues:            'Blues',
  diminished:       'Diminished',
  whole_tone:       'Whole Tone',
}

// Krumhansl-Schmuckler key profiles (major/minor only — used for key detection)
const KS_MAJOR = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
const KS_MINOR = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]

// Chord type definitions: intervals (semitones from root) and display suffix
export const CHORD_TYPES = {
  maj:      { intervals: [0, 4, 7],        suffix: ''      },
  min:      { intervals: [0, 3, 7],        suffix: 'm'     },
  dom7:     { intervals: [0, 4, 7, 10],    suffix: '7'     },
  maj7:     { intervals: [0, 4, 7, 11],    suffix: 'maj7'  },
  min7:     { intervals: [0, 3, 7, 10],    suffix: 'm7'    },
  dim:      { intervals: [0, 3, 6],        suffix: 'dim'   },
  dim7:     { intervals: [0, 3, 6, 9],     suffix: 'dim7'  },
  half_dim: { intervals: [0, 3, 6, 10],    suffix: 'm7b5'  },
  aug:      { intervals: [0, 4, 8],        suffix: 'aug'   },
  sus4:     { intervals: [0, 5, 7],        suffix: 'sus4'  },
  sus2:     { intervals: [0, 2, 7],        suffix: 'sus2'  },
  maj6:     { intervals: [0, 4, 7, 9],     suffix: '6'     },
  min6:     { intervals: [0, 3, 7, 9],     suffix: 'm6'    },
  add9:     { intervals: [0, 2, 4, 7],     suffix: 'add9'  },
}

// Chord types considered during real-time chroma matching
const MATCH_CHORD_TYPES = [
  'maj', 'min', 'dom7', 'maj7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'sus2', 'add9',
]

// Minimum score for a chord match to be reported
const CHORD_MATCH_MIN_SCORE  = 0.42
// Minimum margin over second-best for a match to be considered unambiguous
const CHORD_MATCH_MIN_MARGIN = 0.07

// Chord quality for each scale degree, per mode
const DEGREE_QUALITIES = {
  major:      ['',  'm',   'm',   '',    '',  'm',   'dim'],
  minor:      ['m', 'dim', '',    'm',   'm', '',    ''   ],
  dorian:     ['m', 'm',   '',    '',    'm', 'dim', ''   ],
  phrygian:   ['m', '',    '',    'm',   'dim','',   'm'  ],
  lydian:     ['',  '',    'm',   'dim', '',  'm',   'm'  ],
  mixolydian: ['',  'm',   'dim', '',    'm', 'm',   ''   ],
}

const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']

// Common chord progressions by genre, expressed as semitone offsets from the root
const PROGRESSIONS = {
  // Pop
  pop:     { name: 'Pop',      rn: ['I', 'V', 'vi', 'IV'],      degrees: [0, 7, 9, 5]     },
  pop2:    { name: 'Pop',      rn: ['I', 'IV', 'vi', 'V'],      degrees: [0, 5, 9, 7]     },
  pop3:    { name: 'Pop',      rn: ['I', 'vi', 'ii', 'V'],      degrees: [0, 9, 2, 7]     },
  // Blues
  blues:   { name: 'Blues',    rn: ['I', 'IV', 'V'],             degrees: [0, 5, 7]        },
  blues2:  { name: 'Blues',    rn: ['I', 'IV', 'V', 'IV'],       degrees: [0, 5, 7, 5]     },
  blues3:  { name: 'Blues',    rn: ['I', 'I', 'IV', 'V'],        degrees: [0, 0, 5, 7]     },
  // Folk
  folk:    { name: 'Folk',     rn: ['I', 'IV', 'I', 'V'],        degrees: [0, 5, 0, 7]     },
  folk2:   { name: 'Folk',     rn: ['I', 'V', 'IV', 'I'],        degrees: [0, 7, 5, 0]     },
  folk3:   { name: 'Folk',     rn: ['I', 'ii', 'IV', 'V'],       degrees: [0, 2, 5, 7]     },
  // Jazz
  jazz:    { name: 'Jazz',     rn: ['ii', 'V', 'I'],             degrees: [2, 7, 0]        },
  jazz2:   { name: 'Jazz',     rn: ['I', 'vi', 'ii', 'V'],       degrees: [0, 9, 2, 7]     },
  jazz3:   { name: 'Jazz',     rn: ['iii', 'vi', 'ii', 'V'],     degrees: [4, 9, 2, 7]     },
  // Rock
  rock:    { name: 'Rock',     rn: ['I', 'bVII', 'IV', 'I'],     degrees: [0, 10, 5, 0]    },
  rock2:   { name: 'Rock',     rn: ['I', 'IV', 'V', 'I'],        degrees: [0, 5, 7, 0]     },
  rock3:   { name: 'Rock',     rn: ['I', 'bVII', 'bVI', 'bVII'], degrees: [0, 10, 8, 10]   },
  // '50s
  '50s':   { name: "'50s",     rn: ['I', 'vi', 'IV', 'V'],       degrees: [0, 9, 5, 7]     },
  '50s2':  { name: "'50s",     rn: ['I', 'V', 'vi', 'iii'],      degrees: [0, 7, 9, 4]     },
  // Flamenco
  flamen:  { name: 'Flamenco', rn: ['i', 'bVII', 'bVI', 'V'],   degrees: [0, 10, 8, 7]    },
  flamen2: { name: 'Flamenco', rn: ['i', 'bVI', 'bVII', 'i'],   degrees: [0, 8, 10, 0]    },
}

// ─── Internal helpers ────────────────────────────────────────────────────────

function noteName(semitone, preferFlat = false) {
  const pc = ((semitone % 12) + 12) % 12
  return preferFlat ? NOTES_FLAT[pc] : NOTES[pc]
}

function pearsonCorrelation(a, b) {
  const n = a.length
  const meanA = a.reduce((s, v) => s + v, 0) / n
  const meanB = b.reduce((s, v) => s + v, 0) / n
  let num = 0, denA = 0, denB = 0
  for (let i = 0; i < n; i++) {
    const da = a[i] - meanA
    const db = b[i] - meanB
    num  += da * db
    denA += da * da
    denB += db * db
  }
  return num / Math.sqrt(denA * denB + 1e-10)
}

// Accepts both sharp (C#) and flat (Db) spellings
function noteIndex(note) {
  const idx = NOTES.indexOf(note)
  if (idx !== -1) return idx
  return NOTES_FLAT.indexOf(note)
}

// ─── Key Detection ───────────────────────────────────────────────────────────

/**
 * detectTopKeys(noteHistory, n) → top N key candidates sorted by confidence.
 * Each entry: { root, mode, confidence }
 */
export function detectTopKeys(noteHistory, n = 3) {
  if (!noteHistory || noteHistory.length < 8) return []

  const freq = new Array(12).fill(0)
  for (const note of noteHistory) freq[((note % 12) + 12) % 12]++

  const candidates = []
  for (let root = 0; root < 12; root++) {
    const rotated  = Array.from({ length: 12 }, (_, i) => freq[(i + root) % 12])
    const scoreMaj = pearsonCorrelation(rotated, KS_MAJOR)
    const scoreMin = pearsonCorrelation(rotated, KS_MINOR)
    candidates.push({ root: noteName(root), mode: 'major', score: scoreMaj,
      confidence: Math.max(0, Math.min(1, (scoreMaj + 1) / 2)) })
    candidates.push({ root: noteName(root), mode: 'minor', score: scoreMin,
      confidence: Math.max(0, Math.min(1, (scoreMin + 1) / 2)) })
  }

  return candidates.sort((a, b) => b.score - a.score).slice(0, n)
    .map(({ root, mode, confidence }) => ({ root, mode, confidence }))
}

/**
 * detectKey(noteHistory) → { root, mode, confidence }
 * Uses Krumhansl-Schmuckler: correlates pitch-class histogram with key profiles.
 * noteHistory: array of MIDI note numbers or pitch-class integers (0–11)
 */
export function detectKey(noteHistory) {
  if (!noteHistory || noteHistory.length < 4) {
    return { root: 'C', mode: 'major', confidence: 0 }
  }

  const freq = new Array(12).fill(0)
  for (const note of noteHistory) {
    freq[((note % 12) + 12) % 12]++
  }

  let best = { root: 0, mode: 'major', score: -Infinity }

  for (let root = 0; root < 12; root++) {
    const rotated = Array.from({ length: 12 }, (_, i) => freq[(i + root) % 12])
    const scoreMaj = pearsonCorrelation(rotated, KS_MAJOR)
    const scoreMin = pearsonCorrelation(rotated, KS_MINOR)
    if (scoreMaj > best.score) best = { root, mode: 'major', score: scoreMaj }
    if (scoreMin > best.score) best = { root, mode: 'minor', score: scoreMin }
  }

  const confidence = Math.max(0, Math.min(1, (best.score + 1) / 2))
  return { root: noteName(best.root), mode: best.mode, confidence }
}

// ─── Scale helpers ───────────────────────────────────────────────────────────

export function getScale(root, mode) {
  const rootIdx = noteIndex(root)
  if (rootIdx === -1) return []
  return (SCALES[mode] ?? SCALES.major).map(i => noteName(rootIdx + i))
}

// Legacy aliases
export const getFullScale      = (root, mode) => getScale(root, mode)
export const getPentatonicScale = (root, mode) =>
  getScale(root, mode === 'minor' ? 'pentatonic_minor' : 'pentatonic_major')

/**
 * Returns all scale modes that contain every note in playedNotes.
 * Useful for suggesting compatible scales from a detected chord or melody.
 */
export function getCompatibleScales(playedNotes, root) {
  const played = new Set(playedNotes)
  return Object.entries(SCALES)
    .map(([mode]) => ({ mode, label: SCALE_LABELS[mode] ?? mode, notes: getScale(root, mode) }))
    .filter(({ notes }) => [...played].every(n => notes.includes(n)))
}

// ─── Chord helpers ───────────────────────────────────────────────────────────

export function getChordTones(chordName) {
  const match = chordName.match(/^([A-G][b#]?)(.*)$/)
  if (!match) return []
  const root = noteIndex(match[1])
  const suffix = match[2] ?? ''
  const type = Object.values(CHORD_TYPES).find(t => t.suffix === suffix) ?? CHORD_TYPES.maj
  return type.intervals.map(i => noteName(root + i))
}

export function getChordsInKey(root, mode) {
  const rootIdx = noteIndex(root)
  if (rootIdx === -1) return []
  const scale    = SCALES[mode] ?? SCALES.major
  const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major
  return scale.map((degree, i) => noteName(rootIdx + degree) + qualities[i])
}

// ─── Jam Guide: derived improv theory (L-01b) ─────────────────────────────────
//
// Three additive, pure helpers that feed the Roadmap Jam Guide. They are
// key-agnostic: chords are described as a pitch class (0–11) + a CHORD_TYPES
// quality key (e.g. 'min7', 'dom7', 'maj7', 'half_dim'), exactly the shape the
// KB progression data already uses (`degrees` + `qualities`). They reuse
// CHORD_TYPES / getChordTones rather than re-deriving intervals.
//
// Chord arg shape: { root, quality } where root is a pitch class 0–11 and
// quality is a CHORD_TYPES key. This matches the KB station model
// ({ degrees[i], qualities[i] }) so a caller maps a station to a chord with no
// string parsing.

// Internal: resolve a chord's pitch-class tone set from a CHORD_TYPES key.
// Returns the intervals mapped to absolute pitch classes, preserving the
// CHORD_TYPES interval order (index 0 = root, 1 = 3rd, last = 7th when present).
function chordTonePcs(rootPc, quality) {
  const type = CHORD_TYPES[quality] ?? CHORD_TYPES.maj
  const r = ((rootPc % 12) + 12) % 12
  return type.intervals.map(i => (r + i) % 12)
}

/**
 * guideTones(rootPc, quality) → { third, seventh, root }
 *
 * The guide tones a soloist targets: a chord's 3rd and 7th. Index 1 in every
 * CHORD_TYPES interval set is the 3rd. A chord has a TRUE 7th only if its
 * interval set contains 10 (m7) or 11 (M7) — NOT merely if it has 4 tones.
 * When there is no real 7th (triads, and 4-tone non-7th chords like add9
 * [0,2,4,7] or maj6/min6 [0,4,7,9]) we fall back to the 5th as the secondary
 * anchor and flag `hasSeventh: false` so a caller labels it honestly ("5th",
 * not "7th"). dim/dim7/aug have no perfect 5th, so they anchor on their ♭5/#5.
 *
 * Returns pitch classes (0–11) so the Roadmap TARGET lane can place dots in any
 * key. `root` is included as the third anchor the design's badges reference.
 *
 * Sanity (C): guideTones(0,'maj7') → third 4 (E), seventh 11 (B), hasSeventh:true.
 *             guideTones(7,'dom7') → third 11 (B), seventh 5 (F), hasSeventh:true.
 *             guideTones(2,'min7') → third 5 (F), seventh 0 (C), hasSeventh:true.
 *             guideTones(0,'add9') → third 4 (E), seventh 7 (G=5th), hasSeventh:false.
 *             guideTones(0,'maj6') / (0,'min6') → seventh 7 (G=5th), hasSeventh:false.
 */
export function guideTones(rootPc, quality) {
  const type   = CHORD_TYPES[quality] ?? CHORD_TYPES.maj
  const r      = ((rootPc % 12) + 12) % 12
  const ints   = type.intervals
  const third  = (r + ints[1]) % 12                      // index 1 is always the 3rd
  // A chord has a TRUE 7th only if its interval set contains 10 (m7) or 11 (M7).
  // `length >= 4` is wrong: add9 [0,2,4,7] and maj6/min6 [0,4,7,9] are 4-tone
  // chords with NO seventh, so their secondary anchor must fall back to the 5th —
  // never badge a 5th/6th as a "7". (add9 → hasSeventh:false, anchor=5th.)
  const seventhInt = ints.find(i => i === 10 || i === 11)   // m7 / M7
  const hasSeventh = seventhInt !== undefined
  // Secondary anchor: the true 7th when present; otherwise the perfect 5th (7).
  // When no perfect 5th exists either (dim/dim7 carry a ♭5=6, aug carries a #5=8),
  // anchor on whichever altered 5th the chord actually contains.
  const fifthInt = ints.includes(7) ? 7 : ints.includes(6) ? 6 : ints.includes(8) ? 8 : 7
  const seventh = (r + (hasSeventh ? seventhInt : fifthInt)) % 12
  return { third, seventh, root: r, hasSeventh }
}

/**
 * voiceLeadingPairs(chordA, chordB) → [{ from, to, semitones }]
 *
 * The voice-leading rails between two adjacent stations. For each guide tone of
 * chordA (its 3rd and 7th) it finds the nearest tone of chordB (chordB's full
 * tone set) and returns the smallest signed semitone move (negative = falls,
 * positive = rises). Only rails moving ≤2 semitones are kept — that is the
 * "smooth voice leading" band; bigger leaps are not rails. A 0-semitone rail
 * (a held common tone) is kept so the design can draw "B holds → next loop".
 *
 * Each chord is { root, quality } (pitch class + CHORD_TYPES key).
 *
 * Sanity — ii–V–I in C (the gold-standard rails):
 *   Dm7 → G7 : 7th of Dm7 (C=0) → 3rd of G7 (B=11)  ⇒ { from:0,  to:11, semitones:-1 }
 *   G7  → Cmaj7: 7th of G7 (F=5) → 3rd of Cmaj7 (E=4) ⇒ { from:5, to:4,  semitones:-1 }
 * i.e. the classic 7→3 falls a half-step, proving C→B and F→E.
 */
export function voiceLeadingPairs(chordA, chordB) {
  const a = guideTones(chordA.root, chordA.quality)
  const targets = chordTonePcs(chordB.root, chordB.quality)

  // smallest signed interval from pc x to pc y, in range (-6, 6]
  const signedStep = (x, y) => {
    let d = (((y - x) % 12) + 12) % 12
    if (d > 6) d -= 12
    return d
  }

  const rails = []
  for (const from of [a.seventh, a.third]) {        // 7th first (the headline 7→3 rail)
    let best = null
    for (const to of targets) {
      const semitones = signedStep(from, to)
      if (Math.abs(semitones) > 2) continue          // only smooth moves are rails
      if (best === null || Math.abs(semitones) < Math.abs(best.semitones)) {
        best = { from, to, semitones }
      }
    }
    if (best) rails.push(best)
  }
  return rails
}

// Default solo scale per chord quality (used when a KB pack didn't author an
// improv.scales entry for a degree). Maps a CHORD_TYPES key → a SCALES mode.
// 'locrian' is named here even though it isn't in SCALES (the KB references it
// for half-diminished); intervals are provided so a caller never has to look it
// up in SCALES for the half_dim case.
const SOLO_SCALE_BY_QUALITY = {
  maj:      'major',
  maj7:     'major',       // Ionian; packs may upgrade to Lydian via improv.scales
  maj6:     'major',
  add9:     'major',
  dom7:     'mixolydian',
  min:      'dorian',
  min7:     'dorian',
  min6:     'dorian',
  half_dim: 'locrian',
  dim:      'diminished',
  dim7:     'diminished',
  aug:      'whole_tone',
  sus4:     'mixolydian',
  sus2:     'major',
}

// Locrian isn't in SCALES (no diatonic degree uses it); supply its intervals so
// soloScale can return a complete { name, intervals } for half-diminished.
const LOCRIAN_INTERVALS = [0, 1, 3, 5, 6, 8, 10]

/**
 * soloScale(quality, mode) → { name, intervals }
 *
 * The computed default scale to solo over a chord of the given quality — the
 * fallback for packs that didn't author an improv.scales entry. Returns the
 * same shape callers already get from SCALES (a relative interval set) plus its
 * mode `name`, so the Roadmap SCALE lane can label it ("G mixolydian") and the
 * fretboard can offset the intervals against the chord root.
 *
 * `mode` (the song's key mode, e.g. 'major'/'minor') is an optional context
 * hint: a dominant chord in a minor key implies the ♭9 colour, so we nudge
 * dom7 → phrygian dominant there; otherwise it is ignored. This keeps the
 * default sensible without needing per-chord KB data.
 *
 * Sanity: soloScale('dom7')         → { name:'mixolydian', intervals:[0,2,4,5,7,9,10] }
 *         soloScale('min7')         → { name:'dorian',     intervals:[0,2,3,5,7,9,10] }
 *         soloScale('maj7')         → { name:'major',      intervals:[0,2,4,5,7,9,11] }
 *         soloScale('half_dim')     → { name:'locrian',    intervals:[0,1,3,5,6,8,10] }
 *         soloScale('dom7','minor') → phrygian-dominant intervals (♭9 over the V)
 */
export function soloScale(quality, mode) {
  // Dominant in a minor key → Phrygian dominant (the ♭9/♭13 "V of i" sound).
  if (quality === 'dom7' && mode === 'minor') {
    return { name: 'phrygian_dominant', intervals: [0, 1, 4, 5, 7, 8, 10] }
  }
  const name = SOLO_SCALE_BY_QUALITY[quality] ?? 'major'
  const intervals = name === 'locrian'
    ? LOCRIAN_INTERVALS
    : (SCALES[name] ?? SCALES.major)
  return { name, intervals }
}

// ─── Progression suggestions ─────────────────────────────────────────────────

export function getSuggestedProgressions(root, mode) {
  const rootIdx = noteIndex(root)
  if (rootIdx === -1) return []
  const scale    = SCALES[mode] ?? SCALES.major
  const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major

  return Object.values(PROGRESSIONS).map(prog => {
    const chords = prog.degrees.map(semitones => {
      const noteIdx   = (rootIdx + semitones) % 12
      const degreeIdx = scale.indexOf(semitones)
      // Chromatic degrees (e.g. bVII in rock) default to major triad
      const quality   = degreeIdx >= 0 ? qualities[degreeIdx] : ''
      return noteName(noteIdx) + quality
    })
    return { genre: prog.name, rn: prog.rn, chords }
  })
}

// ─── Chroma-based chord matching ─────────────────────────────────────────────

/**
 * matchChordFromChroma(chroma, keyInfo, bassPC?, strictDiatonic?, minScore?, minMargin?)
 * chroma: Float32Array[12], normalised 0–1 energy per pitch class.
 * Returns null when no unambiguous winner is found (transition/silence).
 */
export function matchChordFromChroma(
  chroma,
  keyInfo,
  bassPC         = null,
  strictDiatonic = false,
  minScore       = CHORD_MATCH_MIN_SCORE,
  minMargin      = CHORD_MATCH_MIN_MARGIN,
) {
  if (!keyInfo?.root) return null

  const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))

  let best        = { name: null, score: -Infinity }
  let secondScore = -Infinity

  for (let r = 0; r < 12; r++) {
    for (const typeKey of MATCH_CHORD_TYPES) {
      const type      = CHORD_TYPES[typeKey]
      const chordName = noteName(r) + type.suffix

      if (strictDiatonic && !diatonicSet.has(chordName)) continue

      const tones = new Set(type.intervals.map(i => (r + i) % 12))

      // Skip if root has no meaningful energy — chord without its root is unreliable
      if (chroma[r] < 0.08) continue

      let inEnergy = 0, outEnergy = 0
      for (let pc = 0; pc < 12; pc++) {
        if (pc === r) {
          inEnergy  += chroma[pc] * 1.5  // root weight reduced: 2→1.5 (less root bias)
        } else if (tones.has(pc)) {
          inEnergy  += chroma[pc]
        } else {
          outEnergy += chroma[pc]
        }
      }

      if (inEnergy + outEnergy < 0.05) continue

      // Stricter outEnergy penalty (0.7 vs 0.5) — wrong notes hurt more
      const coverageScore = inEnergy / (inEnergy + outEnergy * 0.7)
      const bassBonus     = bassPC !== null && r === bassPC ? 0.15 : 0
      const diatonicBonus = diatonicSet.has(chordName)     ? 0.15 : 0

      const finalScore = coverageScore + bassBonus + diatonicBonus

      if (finalScore > best.score) {
        secondScore = best.score
        best        = { name: chordName, score: finalScore }
      } else if (finalScore > secondScore) {
        secondScore = finalScore
      }
    }
  }

  return (best.score >= minScore && best.score - secondScore >= minMargin)
    ? best.name
    : null
}

// ─── Roman numeral notation ───────────────────────────────────────────────────

/**
 * Converts a chord name to its Roman numeral relative to a key.
 * Chromatic (borrowed) chords get a flat prefix, e.g. Bb in C major → ♭VII.
 */
export function toRomanNumeral(chordName, keyRoot, keyMode) {
  if (!chordName || !keyRoot) return '?'

  const match = chordName.match(/^([A-G][b#]?)(.*)$/)
  if (!match) return '?'
  const [, root, quality] = match

  const chordRootIdx = noteIndex(root)
  const keyRootIdx   = noteIndex(keyRoot)
  if (chordRootIdx < 0 || keyRootIdx < 0) return '?'

  const semitones = ((chordRootIdx - keyRootIdx) + 12) % 12
  const scale     = SCALES[keyMode] ?? SCALES.major
  const degreeIdx = scale.indexOf(semitones)

  let rn
  if (degreeIdx >= 0) {
    rn = ROMAN_NUMERALS[degreeIdx]
  } else {
    // Chromatic chord: flat the nearest diatonic degree above it
    const nearestAbove = scale.findIndex(d => d > semitones)
    const refDegree    = nearestAbove >= 0 ? nearestAbove : 0
    rn = '♭' + ROMAN_NUMERALS[refDegree]
  }

  const isMinorQuality = /^m(?!aj)/.test(quality) || quality === 'dim' || quality === 'm7b5'
  return isMinorQuality ? rn.toLowerCase() : rn
}

// ─── Repeating progression detection ──────────────────────────────────────────

// True if arr has a "weak period" p < arr.length — i.e. arr[i] === arr[i-p] for
// every i ≥ p, meaning arr is a prefix of some p-periodic infinite sequence.
// This rejects not only exact repetitions ([A,B,A,B], p=2) but also self-overlap
// fragments/rotations of a shorter loop ([A,B,A], p=2; [C,G,Am,F,C], p=4) that
// would otherwise mint ghost candidates out of a short vamp. A genuine loop is
// never weak-periodic: a loop whose tail restates its head would produce an
// adjacent duplicate at the cycle seam, which the window collapse removes.
function hasShorterPeriod(arr) {
  for (let p = 1; p < arr.length; p++) {
    let periodic = true
    for (let i = p; i < arr.length; i++) {
      if (arr[i] !== arr[i - p]) { periodic = false; break }
    }
    if (periodic) return true
  }
  return false
}

// Match one occurrence of `cand` in `win` anchored at `start` (the first chord
// must match exactly), tolerating at most ONE edit per cycle: a substitution
// (one chord misdetected) or an insertion (one foreign chord slipped between two
// loop chords). The remainder after the edit must match exactly. Returns
// { end, matched, editPos } — `matched` = window indices that matched a loop
// chord, `editPos` = window index of the edit (-1 if the occurrence is exact) —
// or null if no match.
function matchLoopOccurrence(win, start, cand) {
  if (win[start] !== cand[0]) return null
  const matched = [start]
  let i = start + 1
  for (let j = 1; j < cand.length; j++) {
    if (i >= win.length) return null
    if (win[i] === cand[j]) { matched.push(i); i++; continue }

    // First mismatch — the single allowed edit. Fork the two readings; each
    // requires the rest of the candidate to match exactly from where it lands.
    const exactFrom = (wi, cj) => {
      const tail = []
      for (; cj < cand.length; cj++, wi++) {
        if (wi >= win.length || win[wi] !== cand[cj]) return null
        tail.push(wi)
      }
      return { end: wi, tail }
    }
    const ins = exactFrom(i + 1, j)     // win[i] is a foreign inserted chord
    const sub = exactFrom(i + 1, j + 1) // win[i] is cand[j] misdetected
    const hit = ins ?? sub              // insertion keeps one more matched chord
    if (!hit) return null
    return { end: hit.end, matched: [...matched, ...hit.tail], editPos: i }
  }
  return { end: i, matched, editPos: -1 }
}

// Returns the lexicographically smallest rotation so the same loop always
// produces the same string regardless of where in the cycle we currently are.
function canonicalize(pattern) {
  let best = pattern
  for (let i = 1; i < pattern.length; i++) {
    const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
    if (rot.join('\0') < best.join('\0')) best = rot
  }
  return best
}

/**
 * detectRepeatingProgression(history) → chord[] or null   (task L-30)
 *
 * Finds the loop the musician is playing NOW in the recent chord history.
 * Candidates are contiguous slices (lengths 2–8) of the last-32 window with
 * consecutive duplicate commits collapsed; candidates that are self-overlaps
 * of a shorter period are rejected (see hasShorterPeriod). Each candidate is
 * scored by recency-weighted COVERAGE: non-overlapping occurrences are counted
 * with at most one substitution or insertion per cycle, every matched chord
 * adds its recency weight, every edit subtracts the weight at the edit slot.
 * Linear coverage (not reps × len²) means a ghost pattern straddling noise can
 * never outscore the true loop, and exponential recency decay means the current
 * section outscores a longer stale one. Requires ≥2 EXACT occurrences: an
 * edit-tolerant occurrence corroborates a loop but cannot establish it — a
 * loop means the sequence came back exactly, and a ghost slice that absorbs a
 * noise chord into itself rarely recurs exactly (only phase-locked corruption
 * of the same slot by the same chord can make one recur — and such data is
 * genuinely periodic at that longer length). Returns the canonical
 * (rotation-normalised) best pattern.
 */
export function detectRepeatingProgression(history) {
  if (!history || history.length < 6) return null

  // Collapse consecutive duplicate commits — a chord re-committed back-to-back
  // is the same loop slot, not two. Non-adjacent repeats (e.g. Em … Em inside a
  // 7-chord form) are meaningful and untouched.
  const raw = history.slice(-32)
  const win = raw.filter((c, i) => i === 0 || c !== raw[i - 1])
  const n = win.length
  if (n < 4) return null // shortest loop (2 chords) × 2 reps

  // Recency weight per window slot: newest chord weighs 1, each step back
  // decays by 0.9 (half-life ≈ 6.6 chords).
  const RECENCY = 0.9
  const weight = Array.from({ length: n }, (_, i) => RECENCY ** (n - 1 - i))

  let best = null
  let bestScore = 0

  for (let len = 2; len <= 8; len++) {
    if (len * 2 > n) break
    const seen = new Set()

    for (let start = 0; start <= n - len; start++) {
      const candidate = win.slice(start, start + len)
      const key = candidate.join('\0')
      if (seen.has(key)) continue
      seen.add(key)

      if (hasShorterPeriod(candidate)) continue

      let exactOccurrences = 0
      let score = 0
      let i = 0
      while (i < n) {
        const occ = matchLoopOccurrence(win, i, candidate)
        // A 2-chord candidate may not take a substitution (1 matched chord is
        // no evidence); insertions keep matched === len and stay allowed.
        if (occ && occ.matched.length >= 2) {
          if (occ.editPos < 0) exactOccurrences++
          for (const p of occ.matched) score += weight[p]
          if (occ.editPos >= 0) score -= weight[occ.editPos]
          i = occ.end
        } else {
          i++
        }
      }

      if (exactOccurrences < 2) continue // implies occurrences ≥ 2

      if (score > bestScore) {
        bestScore = score
        best = candidate
      }
    }
  }

  return best ? canonicalize(best) : null
}

// ─── Debug / analysis helpers ─────────────────────────────────────────────────

/**
 * Returns top N chord candidates with full score breakdown for the given chroma.
 */
export function getChordCandidates(chroma, keyInfo, bassPC = null, topN = 8) {
  if (!keyInfo?.root || !chroma) return []
  const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
  const candidates  = []

  for (let r = 0; r < 12; r++) {
    for (const typeKey of MATCH_CHORD_TYPES) {
      const type      = CHORD_TYPES[typeKey]
      const chordName = noteName(r) + type.suffix
      const tones     = new Set(type.intervals.map(i => (r + i) % 12))

      let inEnergy = 0, outEnergy = 0
      for (let pc = 0; pc < 12; pc++) {
        if      (pc === r)        inEnergy  += chroma[pc] * 2
        else if (tones.has(pc))   inEnergy  += chroma[pc]
        else                      outEnergy += chroma[pc]
      }
      if (inEnergy + outEnergy < 0.05) continue

      const coverage     = inEnergy / (inEnergy + outEnergy * 0.5)
      const bassBonus    = bassPC !== null && r === bassPC ? 0.15 : 0
      const diatBonus    = diatonicSet.has(chordName) ? 0.15 : 0
      const score        = coverage + bassBonus + diatBonus
      candidates.push({ name: chordName, score, coverage, bassBonus, diatBonus, diatonic: diatonicSet.has(chordName) })
    }
  }
  return candidates.sort((a, b) => b.score - a.score).slice(0, topN)
}

/**
 * Analyses note history: returns normalised pitch-class frequencies and
 * top K-S key candidates with correlation scores.
 */
export function getNoteHistoryAnalysis(noteHistory) {
  const freq = new Array(12).fill(0)
  if (!noteHistory?.length) return { freq, topKeys: [] }

  for (const note of noteHistory) freq[((note % 12) + 12) % 12]++
  const total      = noteHistory.length
  const normalized = freq.map(f => f / total)

  const candidates = []
  for (let root = 0; root < 12; root++) {
    const rotated = Array.from({ length: 12 }, (_, i) => normalized[(i + root) % 12])
    candidates.push({ root: noteName(root), mode: 'major', score: pearsonCorrelation(rotated, KS_MAJOR) })
    candidates.push({ root: noteName(root), mode: 'minor', score: pearsonCorrelation(rotated, KS_MINOR) })
  }
  return {
    freq:    normalized,
    total,
    topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
  }
}

// ─── Utilities ───────────────────────────────────────────────────────────────

export function intervalName(semitones) {
  const names = [
    'Unison', 'Minor 2nd', 'Major 2nd', 'Minor 3rd', 'Major 3rd',
    'Perfect 4th', 'Tritone', 'Perfect 5th', 'Minor 6th',
    'Major 6th', 'Minor 7th', 'Major 7th',
  ]
  return names[((semitones % 12) + 12) % 12] ?? 'Unknown'
}

export function transposeChord(chordName, semitones) {
  const match = chordName.match(/^([A-G][b#]?)(.*)$/)
  if (!match) return chordName
  return noteName(noteIndex(match[1]) + semitones) + match[2]
}

export function transposeProgression(chords, semitones) {
  return chords.map(c => transposeChord(c, semitones))
}
