'use babel';

import { decodeCC } from './lcxl-map';
import { nearestKeywordBefore, classifyBinding, classifyEffectForCC, GROUPS } from './tidal-keywords';
import { classifyRole } from './role-classifier';

// Orbit declarations are top-level (column 0) in real ParVagues tracks.
// Match `d1 $ ...`, `d12 $ ...`, OR `d4` on a line by itself (with the `$`
// continuing on the next indented line — seen in bullet_train.tidal).
const ORBIT_START_RE = /^d(\d+)(?:\s|\$|$)/;
const CC_RE = /"\^(\d+)"/g;
// Group definitions can appear as:
//   let gMute1 = (...)        -- single let
//   let                       -- multi-let block (BootTidal.hs)
//       gMute1 = (...)
//       gMute2 = (...)
// We accept either: optional leading `let` then the group name.
const LET_GROUP_RE = /^\s*(?:let\s+)?(gMask|gMute[123]|gM[123]|gF[123])\s*=/;
const GROUP_NAMES = ['gMask', 'gMute1', 'gMute2', 'gMute3', 'gM1', 'gM2', 'gM3', 'gF1', 'gF2', 'gF3'];
const GROUP_USAGE_RE = new RegExp('\\b(' + GROUP_NAMES.join('|') + ')\\b', 'g');

// Find the balanced-paren group starting at offset `start` in s
// (s[start] must be '('). Returns the substring between the parens, or null.
function balancedParen(s, start) {
  if (s[start] !== '(') return null;
  let depth = 0;
  for (let i = start; i < s.length; i++) {
    if (s[i] === '(') depth++;
    else if (s[i] === ')') {
      depth--;
      if (depth === 0) return s.slice(start + 1, i).trim();
    }
  }
  return null;
}

// Pull out a short context string for a "^NN" binding so the HUD pill
// can show effect + the meaningful value, not just the effect name.
// Ontology-driven — what matters depends on what wraps the CC:
//   struct "pattern"  → the rhythmic pattern (what "on" actually plays)
//   mask "pattern"    → the mask pattern (what's let through)
//   range MIN MAX     → MIN→MAX (the value sweep)
//   ply/slow/fast N   → "ply N" / "slow N" (the multiplier)
//   sometimesBy P (X) → P% + (truncated X)
//   anything else     → first quoted string, else first 14 chars verbatim
function ontologyContent(inner) {
  if (!inner) return null;
  let m;
  // struct / mask: the quoted pattern IS the rhythm
  if ((m = inner.match(/^(struct|mask)\s+"([^"]+)"/))) return m[2];
  // # effect VALUE — sometimesBy / etc. apply a Tidal modifier expressed
  // as `# name X`. Include the value so the user can see what kind of
  // squiz/cut/etc. at a glance, not just the effect name.
  //   # squiz "<1 2 3 1>"  → squiz <1 2 3 1>
  //   # cut 41             → cut 41
  //   # n "29"             → n 29
  if ((m = inner.match(/^#\s+([a-zA-Z_]\w*)(?:\s+(.+))?$/))) {
    const effect = m[1];
    const rest = (m[2] || '').trim();
    if (!rest) return effect;
    const qm = rest.match(/^"([^"]+)"$/);
    return qm ? `${effect} ${qm[1]}` : `${effect} ${rest}`;
  }
  // ply / slow / fast / chop / slice / loopAt / iter / stut / echo
  if ((m = inner.match(/^(ply|slow|fast|chop|slice|loopAt|iter|stut|echo)\s+("[^"]+"|\S+)/))) {
    return `${m[1]} ${m[2].replace(/"/g, '')}`;
  }
  // sometimesBy P (X) — keep probability when explicit
  if ((m = inner.match(/^(sometimesBy|someCyclesBy)\s+([0-9.]+)/))) {
    return `${Math.round(parseFloat(m[2]) * 100)}%`;
  }
  // Generic: first quoted string anywhere
  if ((m = inner.match(/"([^"]+)"/))) return m[1];
  return inner;
}

function extractBindingContent(line, ccIndex, ccLen) {
  // Range case: `range MIN MAX "^NN"` — value-sweep idiom.
  const before = line.slice(0, ccIndex);
  const rangeM = before.match(/range\s+(-?[\d.]+|"[^"]+")\s+(-?[\d.]+|"[^"]+")\s*$/);
  if (rangeM) {
    return rangeM[1].replace(/"/g, '') + '→' + rangeM[2].replace(/"/g, '');
  }
  // Trigger case: `<trigger> "^NN" (...)` — the paren content is the action.
  const after = line.slice(ccIndex + ccLen);
  const parenStart = after.search(/\(/);
  if (parenStart >= 0) {
    const inner = balancedParen(after, parenStart);
    if (inner) return ontologyContent(inner);
  }
  return null;
}

// Detect the bare sample/synth string for an orbit block.
// Returns the most informative candidate, or null.
function extractSound(blockLines) {
  let bareCandidate = null;
  let nameCandidate = null;

  // Both candidates must come from the orbit's TOP-LEVEL composition chain,
  // not from a string nested inside parens (e.g. inside a midiOn (...)
  // alternative branch). Top-level lines look like `  $ "..."` or `  # "..."`.
  for (let i = 0; i < blockLines.length; i++) {
    const line = blockLines[i];
    if (line.trim().startsWith('--')) continue;

    const topBare = line.match(/^\s*\$\s+"([^"]+)"/);
    if (topBare) bareCandidate = topBare[1];

    const topHash = line.match(/^\s*#\s+"([^"]+)"/);
    if (topHash) {
      const v = topHash[1];
      if (/^[a-zA-Z_][\w]*(:\d+)?$/.test(v) || /^\[[^\]]+\]$/.test(v)) {
        nameCandidate = v;
      }
    }
  }

  // Synth/sample identity (# "name") wins over the bare pattern when
  // present — the user wants to see "bassWarsaw"/"orage", not "<1 2 3>".
  return nameCandidate || bareCandidate || null;
}

function extractInlineLabel(headerLine) {
  const m = headerLine.match(/^d\d+.*?--\s*(.+?)\s*$/);
  if (!m) return null;
  let label = m[1].trim();
  // Strip self-notes ("TODO …", "FIXME …") — they're not part of the
  // track's identity, just reminders from the author.
  label = label.replace(/\s+(TODO|FIXME|HACK|XXX|NOTE)\b.*$/i, '').trim();
  return label || null;
}

// Collect which file-level groups (gMask, gMute1-3, gM1-3, gF1-3) this
// orbit composes with. Used by the HUD to render "who's affected" lists
// on the group cells (e.g. F1 cell shows [d2 d3 d8] — orbits whose mute
// toggles when CC 73 fires).
function extractOrbitUses(blockLines) {
  const out = new Set();
  for (const line of blockLines) {
    if (line.trim().startsWith('--')) continue;
    let m;
    GROUP_USAGE_RE.lastIndex = 0;
    while ((m = GROUP_USAGE_RE.exec(line)) !== null) out.add(m[1]);
  }
  return Array.from(out);
}

function extractBindingsFromBlock(blockLines, ctx) {
  const bindings = [];
  for (const line of blockLines) {
    if (line.trim().startsWith('--')) continue;

    let m;
    CC_RE.lastIndex = 0;
    while ((m = CC_RE.exec(line)) !== null) {
      const cc = Number(m[1]);
      const prefix = line.slice(0, m.index);
      const keyword = nearestKeywordBefore(prefix);

      // If the keyword is a GROUP name (gM1 etc.), the binding is inherited
      // from the file-level let; skip it for per-orbit bindings.
      if (keyword && GROUPS.includes(keyword)) continue;

      const control = decodeCC(cc);
      const effect = classifyEffectForCC(line, m.index);
      const content = extractBindingContent(line, m.index, m[0].length);

      // De-duplicate within an orbit by (cc + effect + content): same
      // binding collapses, but different patterns under the same effect
      // (e.g. midiOn vs midiOff structs on same CC) stay separate.
      const key = `${cc}|${effect}|${content || ''}`;
      if (!bindings.some(b => `${b.cc}|${b.effect}|${b.content || ''}` === key)) {
        bindings.push({ cc, control, effect, content });
      }
    }
  }
  return bindings;
}

function extractGroups(allLines) {
  const groups = {};
  for (const line of allLines) {
    const m = line.match(LET_GROUP_RE);
    if (!m) continue;
    const name = m[1];
    // Find first "^NN" on the RHS.
    CC_RE.lastIndex = 0;
    const cm = CC_RE.exec(line);
    if (!cm) continue;
    const cc = Number(cm[1]);
    const control = decodeCC(cc);
    const effect = classifyEffectForCC(line, cm.index);
    groups[name] = { cc, control, effect };
  }
  return groups;
}

export function parse(text) {
  const errors = [];
  let lines;
  try {
    lines = (text || '').split(/\r?\n/);
  } catch (e) {
    return { groups: {}, orbits: [], errors: [`split failed: ${e.message}`] };
  }

  // Pass 1: file-level groups.
  let groups = {};
  try {
    groups = extractGroups(lines);
  } catch (e) {
    errors.push(`groups: ${e.message}`);
  }

  // Pass 2: identify orbit block boundaries.
  const blocks = [];
  for (let i = 0; i < lines.length; i++) {
    const m = lines[i].match(ORBIT_START_RE);
    if (m) blocks.push({ n: Number(m[1]), startLine: i });
  }
  // Close blocks: end = next start, or EOF.
  for (let i = 0; i < blocks.length; i++) {
    blocks[i].endLine = (i + 1 < blocks.length) ? blocks[i + 1].startLine : lines.length;
  }

  const orbits = [];
  for (const b of blocks) {
    try {
      const blockLines = lines.slice(b.startLine, b.endLine);
      const name = extractInlineLabel(lines[b.startLine]);
      const sound = extractSound(blockLines);
      const bindings = extractBindingsFromBlock(blockLines, { groups });
      const uses = extractOrbitUses(blockLines);
      orbits.push({ n: b.n, name, sound, bindings, uses, startLine: b.startLine + 1 });
    } catch (e) {
      errors.push(`d${b.n}: ${e.message}`);
    }
  }

  // Tracks frequently have scratch alternates at the bottom (e.g.
  // `d1 $ n "e e a e" # s "bass"`). Treating each as a separate orbit
  // produces duplicates in the HUD. Merge by orbit number: union the
  // bindings, take the FIRST seen sound/name (top-of-file wins).
  const byN = {};
  for (const o of orbits) {
    if (!byN[o.n]) {
      byN[o.n] = { ...o, bindings: [...o.bindings], uses: [...(o.uses || [])] };
    } else {
      const merged = byN[o.n];
      for (const b of o.bindings) {
        if (!merged.bindings.some(x => x.cc === b.cc && x.effect === b.effect)) {
          merged.bindings.push(b);
        }
      }
      for (const u of (o.uses || [])) {
        if (!merged.uses.includes(u)) merged.uses.push(u);
      }
      if (!merged.sound) merged.sound = o.sound;
      if (!merged.name)  merged.name  = o.name;
    }
  }
  const dedupedOrbits = Object.values(byN).sort((a, b) => a.n - b.n);

  // Classify each orbit's role (rhythm/bass/lead/pad/riser) for HUD color.
  for (const o of dedupedOrbits) {
    const r = classifyRole(o);
    o.role = r.role;
    o.roleSource = r.source;
    o.roleMatched = r.matched;
  }

  return { groups, orbits: dedupedOrbits, errors };
}
