'use babel';

import { encodeCC } from './lcxl-map';

// ParVagues convention: each orbit has a default physical "home" on the
// LaunchControl XL surface. Per user spec:
//   d1..d5, d7, d8 → faders D2..D8 (row D, volumes)
//   d6           → mid-knob B3   (cc31) — exception, off the fader row
//   d9           → top-knob A8   (cc20)
//   d10..d12     → top-knobs A1..A3 (cc13..15)
//
// The HUD reflects this by putting orbit labels exactly where the user's
// hands sit on the controller: row-A orbits in a TOP header band, row-D
// orbits in a BOTTOM header band, anything else (e.g. d6) as a chip
// embedded directly in the cell.
const ORBIT_CONVENTION = {
  1:  { lane: 2, row: 'D' },
  2:  { lane: 3, row: 'D' },
  3:  { lane: 4, row: 'D' },
  4:  { lane: 5, row: 'D' },
  5:  { lane: 6, row: 'D' },
  6:  { lane: 3, row: 'B' },
  7:  { lane: 7, row: 'D' },
  8:  { lane: 8, row: 'D' },
  9:  { lane: 8, row: 'A' },
  10: { lane: 1, row: 'A' },
  11: { lane: 2, row: 'A' },
  12: { lane: 3, row: 'A' }
};

const ROWS = ['A', 'B', 'C', 'D', 'E', 'F'];

// Canonical group definitions. Most ParVagues tracks don't `let`-define
// gMute1/gMask/gF1/etc. in the .tidal file — they inherit them from
// BootTidal.hs. We render the canonical cells unconditionally so the
// group rows F1-F3 / E1 / C1-C3 are always present, and let any local
// `let` override the defaults.
const CANONICAL_GROUPS = {
  gMask:  { cc: 41, control: { row: 'E', lane: 1, label: 'E1' }, effect: 'on' },
  gMute1: { cc: 73, control: { row: 'F', lane: 1, label: 'F1' }, effect: 'mute' },
  gMute2: { cc: 74, control: { row: 'F', lane: 2, label: 'F2' }, effect: 'mute' },
  gMute3: { cc: 75, control: { row: 'F', lane: 3, label: 'F3' }, effect: 'mute' },
  gF1:    { cc: 49, control: { row: 'C', lane: 1, label: 'C1' }, effect: 'djf' },
  gF2:    { cc: 50, control: { row: 'C', lane: 2, label: 'C2' }, effect: 'djf' },
  gF3:    { cc: 51, control: { row: 'C', lane: 3, label: 'C3' }, effect: 'djf' }
};

function effectiveGroups(state) {
  const out = {};
  for (const [name, def] of Object.entries(CANONICAL_GROUPS)) {
    out[name] = (state.groups && state.groups[name]) || def;
  }
  // Allow any additional non-canonical groups parsed from the file too.
  if (state.groups) {
    for (const [name, g] of Object.entries(state.groups)) {
      if (!out[name]) out[name] = g;
    }
  }
  return out;
}

function el(tag, opts = {}) {
  const e = document.createElement(tag);
  if (opts.cls) e.className = opts.cls;
  if (opts.text != null) e.textContent = opts.text;
  if (opts.title) e.title = opts.title;
  if (opts.style) for (const k in opts.style) e.style[k] = opts.style[k];
  return e;
}

function truncate(s, n) {
  if (!s) return '';
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
}

// Build a sparse 2D map: grid[lane][row] = [ {effect, orbit?, group?, cc} ]
function buildGrid(state) {
  const grid = {};
  const placeCell = (lane, row, payload) => {
    if (!lane || row === '?') return;
    grid[lane] ??= {};
    grid[lane][row] ??= [];
    grid[lane][row].push(payload);
  };
  // Index orbits by number so binding pills can pick up the orbit's role.
  const orbitsByN = {};
  for (const o of state.orbits || []) orbitsByN[o.n] = o;

  for (const o of state.orbits || []) {
    for (const b of o.bindings || []) {
      placeCell(b.control.lane, b.control.row, {
        effect: b.effect, cc: b.cc, orbit: o.n, content: b.content,
        role: o.role
      });
    }
  }
  // Render every canonical group cell (gMask/gMute*/gF*) — main.js merges
  // BootTidal.hs into state.groups, and effectiveGroups falls back to
  // hardcoded canonicals if BootTidal.hs wasn't found.
  for (const [name, g] of Object.entries(effectiveGroups(state))) {
    const userNs = orbitsAffectedByGroup(name, state.orbits || []);
    placeCell(g.control.lane, g.control.row, {
      effect: g.effect, cc: g.cc, group: name,
      users: userNs,
      userRoles: userNs.map(n => orbitsByN[n] ? orbitsByN[n].role : null)
    });
  }
  return grid;
}

// Which orbits are affected when this group's CC toggles.
//   gMute1 (cc73): orbits using gMute1 directly OR gM1 (= gMask . gMute1)
//   gMask  (cc41): orbits using gMask OR any gMx (gM1/gM2/gM3 all chain through gMask)
//   gF1    (cc49): orbits using gF1 (djf bus 1 sweep)
function orbitsAffectedByGroup(groupName, orbits) {
  const equiv = {
    gMask:  ['gMask', 'gM1', 'gM2', 'gM3'],
    gMute1: ['gMute1', 'gM1'],
    gMute2: ['gMute2', 'gM2'],
    gMute3: ['gMute3', 'gM3'],
    gM1:    ['gMute1', 'gM1'],
    gM2:    ['gMute2', 'gM2'],
    gM3:    ['gMute3', 'gM3'],
    gF1:    ['gF1'],
    gF2:    ['gF2'],
    gF3:    ['gF3']
  };
  const targets = new Set(equiv[groupName] || [groupName]);
  return orbits
    .filter(o => o.uses && o.uses.some(u => targets.has(u)))
    .map(o => o.n)
    .sort((a, b) => a - b);
}

// Every orbit's label is rendered INSIDE its convention cell. Lookup:
// inline[lane][row] = [orbit, …]. unplaced are orbits not in the
// convention map (rare — typically d13+ or off-spec numbering).
function classifyPlacements(orbits) {
  const inline = {};
  const unplaced = [];
  for (const o of orbits) {
    const conv = ORBIT_CONVENTION[o.n];
    if (!conv) { unplaced.push(o); continue; }
    const inLane = (inline[conv.lane] ??= {});
    (inLane[conv.row] ??= []).push(o);
  }
  return { inline, unplaced };
}

// Column accent: prefer the fader-row orbit (row D), then top-knob (A),
// then any inline orbit. Used for orbit-color cell tinting.
function laneAccentOrbit(lane, placements) {
  const inl = placements.inline[lane];
  if (!inl) return null;
  if (inl['D'] && inl['D'].length) return inl['D'][0];
  if (inl['A'] && inl['A'].length) return inl['A'][0];
  for (const row of ROWS) {
    if (inl[row] && inl[row].length) return inl[row][0];
  }
  return null;
}

function orbitChip(o, opts = {}) {
  const roleCls = o.role ? ` pv-role-${o.role}` : '';
  const chip = el('span', { cls: `pv-orbit-chip pv-orbit-d${o.n}${roleCls}` });
  chip.appendChild(el('span', { cls: 'pv-orbit-n', text: `d${o.n}` }));
  if (o.name && !opts.noName) {
    chip.appendChild(el('span', {
      cls: 'pv-orbit-name', text: truncate(o.name, opts.nameMax || 22),
      title: o.name
    }));
  }
  if (o.sound && !opts.noSound) {
    chip.appendChild(el('span', {
      cls: 'pv-orbit-sound',
      text: `"${truncate(o.sound, opts.soundMax || 20)}"`,
      title: o.sound
    }));
  }
  return chip;
}

function headerCell(lane, orbits, position) {
  const cell = el('div', { cls: `pv-col-head pv-col-head-${position}` });
  if (!orbits || !orbits.length) {
    cell.appendChild(el('span', { cls: 'pv-dim', text: '·' }));
    return cell;
  }
  for (const o of orbits) cell.appendChild(orbitChip(o));
  return cell;
}

function bindingPill(c, primaryOrbitN) {
  const pill = el('span', { cls: 'pv-effect' });
  let badge;
  let titleSuffix = c.content ? ` · ${c.content}` : '';

  if (c.group) {
    pill.classList.add('pv-from-group');
    badge = groupShortName(c.group);
    const userList = (c.users && c.users.length) ? c.users.map(n => 'd' + n).join(' ') : 'none';
    pill.title = `CC ${c.cc} · ${c.group} → ${c.effect}\nAffects: ${userList}`;

    pill.appendChild(el('span', { cls: 'pv-effect-badge', text: badge }));
    if (c.users && c.users.length) {
      // Compact format: `d 2 3 8` where the `d` is gray and each digit is
      // colored by its orbit's role. For two-digit orbit numbers (10-12)
      // we use a thin separator so they don't run together.
      const hasDouble = c.users.some(n => n >= 10);
      const list = el('span', { cls: 'pv-effect-content pv-group-users' });
      list.appendChild(el('span', { cls: 'pv-orbit-list-d', text: 'd' }));
      c.users.forEach((n, i) => {
        const role = (c.userRoles && c.userRoles[i]) || null;
        const sep = (i > 0 && hasDouble) ? '·' : '';
        if (sep) list.appendChild(el('span', { cls: 'pv-orbit-list-sep', text: sep }));
        list.appendChild(el('span', {
          cls: `pv-orbit-digit pv-orbit-d${n}` + (role ? ` pv-role-${role}` : ''),
          text: String(n),
          title: `d${n} affected by ${c.group} (CC ${c.cc})${role ? ' · ' + role : ''}`
        }));
      });
      pill.appendChild(list);
    }
    return pill;
  } else {
    pill.classList.add(`pv-orbit-d${c.orbit}`);
    if (c.role) pill.classList.add(`pv-role-${c.role}`);
    badge = c.effect;
    pill.title = `CC ${c.cc} · d${c.orbit} → ${c.effect}${titleSuffix}`;
    // "Off-lane" = the binding's orbit (d{c.orbit}) is NOT the column's
    // home orbit (d{primaryOrbitN}). Visual cue is the pill's color
    // stripe matching its own orbit, contrasting with the column tint.
    if (primaryOrbitN && c.orbit !== primaryOrbitN) {
      pill.classList.add('pv-off-lane');
      pill.title += `  (d${c.orbit}'s binding, displayed in d${primaryOrbitN}'s column)`;
    }
  }

  pill.appendChild(el('span', { cls: 'pv-effect-badge', text: badge }));
  if (c.content) {
    pill.appendChild(el('span', {
      cls: 'pv-effect-content',
      text: truncate(c.content, 22),
      title: c.content
    }));
  }
  return pill;
}

function bodyCell(lane, row, cells, inlineOrbits, accentOrbit) {
  const wrap = el('div', { cls: 'pv-cell' });
  wrap.dataset.lane = lane;
  wrap.dataset.row = row;

  // Special-case: D1 fader = global Ardour master volume, not an orbit.
  // Show a faint "Master" label so the lane reads coherently.
  if (row === 'D' && lane === 1 && (!inlineOrbits || !inlineOrbits.length)) {
    wrap.classList.add('pv-cell-master');
    wrap.appendChild(el('span', { cls: 'pv-master-label', text: 'Master', title: 'Ardour master volume' }));
    if (cells && cells.length) {
      for (const c of cells) wrap.appendChild(bindingPill(c, accentOrbit ? accentOrbit.n : null));
    }
    return wrap;
  }

  // Inline orbit labels (e.g. d6 at B3, d1 at D2, d10 at A1) appear FIRST
  // so the cell reads "d4 bassWarsaw [pills…]" left-to-right.
  if (inlineOrbits && inlineOrbits.length) {
    for (const o of inlineOrbits) {
      const roleCls = o.role ? ` pv-role-${o.role}` : '';
      const chip = el('span', { cls: `pv-inline-orbit pv-orbit-d${o.n}${roleCls}` });
      chip.appendChild(el('span', { cls: 'pv-orbit-n', text: `d${o.n}` }));
      if (o.name) {
        chip.appendChild(el('span', {
          cls: 'pv-orbit-name', text: truncate(o.name, 18), title: o.name
        }));
      }
      if (o.sound) {
        chip.appendChild(el('span', {
          cls: 'pv-orbit-sound', text: truncate(o.sound, 16), title: o.sound
        }));
      }
      const roleNote = o.role ? `\nrole: ${o.role}${o.roleMatched ? ` (matched "${o.roleMatched}")` : ''}` : '';
      chip.title = `d${o.n}${o.name ? ' ' + o.name : ''}${o.sound ? ' "' + o.sound + '"' : ''}${roleNote}`;
      wrap.appendChild(chip);
    }
  }

  if (!cells || !cells.length) {
    if (!inlineOrbits || !inlineOrbits.length) {
      wrap.classList.add('pv-cell-empty');
      wrap.appendChild(el('span', { cls: 'pv-cell-dot', text: '·' }));
    }
    return wrap;
  }

  // De-duplicate by effect+source so two midiOn-same-cc don't double up.
  const seen = new Set();
  const uniq = cells.filter(c => {
    const k = c.effect + (c.group ?? '') + (c.orbit ?? '');
    if (seen.has(k)) return false;
    seen.add(k); return true;
  });

  for (const c of uniq) {
    wrap.appendChild(bindingPill(c, accentOrbit ? accentOrbit.n : null));
  }
  return wrap;
}

// Map gMask/gMute1-3/gM1-3/gF1-3 to perf-readable suffixes so the group
// pill reads "mute1" / "mute2" instead of a generic "mute*".
function groupShortName(name) {
  if (!name) return '';
  if (name === 'gMask')   return 'mask';
  const m = name.match(/^gMute([123])$/);
  if (m) return 'mute' + m[1];
  const m2 = name.match(/^gM([123])$/);
  if (m2) return 'mute' + m2[1];   // gM1 = gMask . gMute1 → same channel
  const m3 = name.match(/^gF([123])$/);
  if (m3) return 'djf' + m3[1];
  return name;
}

function buildGridUI(state) {
  const grid = buildGrid(state);
  const placements = classifyPlacements(state.orbits || []);
  const root = el('div', { cls: 'pv-grid' });

  // 6 rows A B C D E F. Every orbit label sits IN its convention cell
  // (faders for d1-d8, top knobs for d9-d12, mid knob B3 for d6) — no
  // separate header bands. D1 cell shows "Master" since that fader is
  // routed to the Ardour master, not a Tidal orbit.
  for (const row of ROWS) {
    root.appendChild(el('div', { cls: `pv-row-label pv-row-label-${row}`, text: row }));
    for (let lane = 1; lane <= 8; lane++) {
      const cells = grid[lane]?.[row];
      const inlineOrbits = placements.inline[lane]?.[row];
      const accent = laneAccentOrbit(lane, placements);
      const cell = bodyCell(lane, row, cells, inlineOrbits, accent);
      if (accent) cell.classList.add(`pv-col-owned-d${accent.n}`);
      root.appendChild(cell);
    }
  }

  return root;
}

// Orphans: orbits not in the convention map — usually d13+ or off-spec
// numbering. Render as a small footer below the grid.
function buildOrphansRow(state) {
  const placements = classifyPlacements(state.orbits || []);
  if (!placements.unplaced.length) return null;
  const row = el('div', { cls: 'pv-orphans-row' });
  row.appendChild(el('span', { cls: 'pv-section-label', text: 'unplaced' }));
  for (const o of placements.unplaced) row.appendChild(orbitChip(o));
  return row;
}

function errorsBadge(errors) {
  if (!errors || !errors.length) return null;
  return el('span', {
    cls: 'pv-error-badge',
    text: `⚠ ${errors.length} parse warning${errors.length > 1 ? 's' : ''}`,
    title: errors.join('\n')
  });
}

// state: parser output, OR null to render empty.
export function render(root, state, opts = {}) {
  while (root.firstChild) root.removeChild(root.firstChild);

  if (opts.idle) {
    root.appendChild(el('span', { cls: 'pv-dim', text: opts.idleMessage || 'ParVagues HUD — no .tidal active' }));
    return;
  }
  if (!state) {
    root.appendChild(el('span', { cls: 'pv-dim', text: 'ParVagues HUD — empty' }));
    return;
  }

  root.appendChild(buildGridUI(state));

  const orphans = buildOrphansRow(state);
  if (orphans) root.appendChild(orphans);

  const badge = errorsBadge(state.errors);
  if (badge) root.appendChild(badge);
}
