#!/usr/bin/env node
/*
  IRON/OS build script.

  Usage:
    node scripts/build.js <build-input.js | build-input.json>

  The input file is the small part Claude writes: the APP config, the exercise
  library (LIB), the programme (PROGRAM), an accent choice and an output path.
  See scripts/build-input.example.js for the shape.

  Everything else is derived here from the skill's own files:
    assets/app-template.html      the app
    assets/exercise-art.json      three drawn frames per exercise (CC BY-SA 4.0)
    references/exercise-index.md  the 302 entry catalogue, parsed from its tables

  No dependencies. Works from any working directory because every skill file
  is located relative to this script.
*/

'use strict';

const fs = require('fs');
const path = require('path');

const SKILL_DIR = path.resolve(__dirname, '..');
const TEMPLATE = path.join(SKILL_DIR, 'assets', 'app-template.html');
const ART_JSON = path.join(SKILL_DIR, 'assets', 'exercise-art.json');
const INDEX_MD = path.join(SKILL_DIR, 'references', 'exercise-index.md');
const ICONS = ['icon.png', 'apple-touch-icon.png'];
const EXPECTED_CATALOGUE = 302;

const ACCENTS = {
  brass: { accent: '#e8a020', hi: '#ffc457', on: '#211500' },
  red:   { accent: '#e63b2e', hi: '#ff6a5e', on: '#ffffff' },
  ice:   { accent: '#38bdf8', hi: '#7dd3fc', on: '#06202e' },
  green: { accent: '#4ade80', hi: '#86efac', on: '#052e12' }
};
const LOG_TYPES = ['weight_reps', 'reps', 'time', 'assisted'];
const MODES = ['deficit', 'maintenance', 'surplus'];

const warnings = [];
function warn(msg) { warnings.push(msg); }
function fail(msg) {
  console.error('BUILD FAILED: ' + msg);
  process.exit(1);
}

/* ---------- 1. Input ---------- */

const inputArg = process.argv[2];
if (!inputArg) {
  console.error('Usage: node scripts/build.js <build-input.js | build-input.json>');
  process.exit(1);
}
const inputPath = path.resolve(process.cwd(), inputArg);
if (!fs.existsSync(inputPath)) fail('input file not found: ' + inputPath);

let input;
try {
  input = inputPath.toLowerCase().endsWith('.json')
    ? JSON.parse(fs.readFileSync(inputPath, 'utf8'))
    : require(inputPath);
} catch (e) {
  fail('could not load ' + inputPath + ': ' + e.message);
}

const APP_IN = input.APP;
const LIB = input.LIB;
const PROGRAM = input.PROGRAM;
const accentIn = input.accent || 'brass';
const outRel = input.out || input.outputPath;

if (!APP_IN || typeof APP_IN !== 'object') fail('input must export an APP object');
if (!LIB || typeof LIB !== 'object' || !Object.keys(LIB).length) fail('input must export a non-empty LIB object');
if (!Array.isArray(PROGRAM) || !PROGRAM.length) fail('input must export a non-empty PROGRAM array');
if (!outRel) fail('input must export "out": the path of the HTML file to write');

const outPath = path.resolve(path.dirname(inputPath), outRel);

/* ---------- 2. Skill files ---------- */

for (const p of [TEMPLATE, ART_JSON, INDEX_MD]) {
  if (!fs.existsSync(p)) fail('skill file missing: ' + p);
}
let html = fs.readFileSync(TEMPLATE, 'utf8');
const art = JSON.parse(fs.readFileSync(ART_JSON, 'utf8'));
const index = fs.readFileSync(INDEX_MD, 'utf8');

/* ---------- 3. Catalogue from exercise-index.md ---------- */

/* Rows look like:
   | `bench-press` | Bench Press | Barbell | weight × reps | Triceps, Shoulders |
   Primary muscle comes from the "## Muscle (N)" heading above the row.
   Stretch rows carry a "*(stretch)*" suffix on the name, which is stripped. */
const CATALOG = {};
const VOCAB = [];
let section = null;
const rowRe = /^\| `([a-z0-9-]+)` \| (.+?) \| (.+?) \| (.+?) \| (.*?) \|\s*$/;
for (const line of index.split('\n')) {
  const h = line.match(/^## (.+?) \(\d+\)\s*$/);
  if (h) { section = h[1].trim(); VOCAB.push(section); continue; }
  if (/^## /.test(line)) { section = null; continue; }
  const m = line.match(rowRe);
  if (!m || !section) continue;
  const logs = m[4].trim().toLowerCase();
  const t = logs.includes('weight') ? 'weight_reps'
    : logs.includes('time') ? 'time'
    : logs.includes('assisted') ? 'assisted'
    : 'reps';
  const secondaries = m[5].split(',')
    .map(function (s) { return s.trim(); })
    .filter(function (s) { return s && s !== String.fromCharCode(8212) && s !== '-'; });   /* the index marks "none" with a dash */
  CATALOG[m[1]] = {
    n: m[2].replace(/\s*\*\(.+?\)\*/g, '').trim(),
    e: m[3].trim(),
    m: section,
    t: t,
    s: secondaries
  };
}
const catalogueCount = Object.keys(CATALOG).length;
if (catalogueCount !== EXPECTED_CATALOGUE) {
  fail('parsed ' + catalogueCount + ' catalogue entries from exercise-index.md, expected ' + EXPECTED_CATALOGUE +
    '. The index tables have changed shape or the file is damaged.');
}
const vocabLower = VOCAB.map(function (v) { return v.toLowerCase(); });

/* ---------- 4. Template defaults for APP ---------- */

function findBlock(src, startMarker, endMarker) {
  const start = src.indexOf(startMarker);
  if (start === -1) fail('template marker not found: ' + startMarker);
  const end = src.indexOf(endMarker, start + startMarker.length);
  if (end === -1) fail('template block never closes: ' + startMarker);
  return { start: start, end: end + endMarker.length };
}
function replaceBlock(src, startMarker, endMarker, replacement) {
  const b = findBlock(src, startMarker, endMarker);
  return src.slice(0, b.start) + replacement + src.slice(b.end);
}

let APP_DEFAULTS = {};
try {
  const b = findBlock(html, 'const APP = {', '\n};');
  APP_DEFAULTS = new Function(html.slice(b.start, b.end) + '\nreturn APP;')();
} catch (e) {
  warn('could not read the APP defaults from the template (' + e.message + '); using the input APP as given');
}
const APP = Object.assign({}, APP_DEFAULTS, APP_IN);

/* ---------- 5. Validation ---------- */

if (MODES.indexOf(APP.defaultMode) === -1) {
  fail('APP.defaultMode must be one of ' + MODES.join(', ') + ' (got "' + APP.defaultMode + '")');
}
if (APP.configVersion !== undefined && (!Number.isInteger(APP.configVersion) || APP.configVersion < 1)) {
  fail('APP.configVersion must be a positive integer');
}
if (APP.configVersion === undefined) warn('APP.configVersion not set; the app will treat it as 1. Set it and increase it on every rebuild.');
if (APP.cloudSync === undefined) APP.cloudSync = false;
if (!Array.isArray(APP.excluded)) fail('APP.excluded must be an array of catalogue slugs');
const unknownExcluded = APP.excluded.filter(function (s) { return !CATALOG[s] && !art[s]; });
if (unknownExcluded.length) fail('APP.excluded has slugs that are not in the exercise index: ' + unknownExcluded.join(', '));

const seenIds = {};
PROGRAM.forEach(function (day, i) {
  if (!day || typeof day !== 'object') fail('PROGRAM[' + i + '] is not an object');
  if (day.id !== i + 1) fail('PROGRAM ids must run 1, 2, 3 ... in order; PROGRAM[' + i + '].id is ' + day.id);
  if (seenIds[day.id]) fail('duplicate PROGRAM id ' + day.id);
  seenIds[day.id] = true;
  if (!day.name) fail('PROGRAM day ' + day.id + ' has no name');
  if (!Array.isArray(day.slots) || !day.slots.length) fail('PROGRAM day ' + day.id + ' has no slots');
  day.slots.forEach(function (id) {
    if (!LIB[id]) fail('PROGRAM day ' + day.id + ' uses slot "' + id + '" which is not in LIB');
  });
});

const usedIds = {};
PROGRAM.forEach(function (d) { d.slots.forEach(function (id) { usedIds[id] = true; }); });

let filledLog = [];
Object.keys(LIB).forEach(function (id) {
  const ex = LIB[id];
  if (!ex || typeof ex !== 'object') fail('LIB["' + id + '"] is not an object');
  if (!ex.name) fail('LIB["' + id + '"] has no name');
  if (!usedIds[id]) warn('LIB entry "' + id + '" is not used by any PROGRAM day');
  if (!ex.sets || typeof ex.sets !== 'object') {
    fail('LIB["' + id + '"] has no sets object ({deficit, maintenance, surplus})');
  }
  MODES.forEach(function (m) {
    if (typeof ex.sets[m] !== 'number') fail('LIB["' + id + '"].sets.' + m + ' must be a number (0 hides the slot)');
  });
  if (!ex.muscle) {
    warn('LIB entry "' + id + '" has no muscle; the weekly volume tab will not count it');
  } else if (vocabLower.indexOf(String(ex.muscle).toLowerCase()) === -1) {
    warn('LIB entry "' + id + '" has muscle "' + ex.muscle + '" which is not in the index vocabulary. ' +
      'Swap ("same muscle first") and the weekly volume tab depend on it. Use one of: ' + VOCAB.join(', '));
  }
  if (!Array.isArray(ex.sec)) {
    warn('LIB entry "' + id + '" has no sec array; weekly totals will understate the work. ' +
      'Copy the "Also works" column from exercise-index.md (use [] if there genuinely are none)');
  }
  const slug = ex.art || id;
  if (ex.log !== undefined && LOG_TYPES.indexOf(ex.log) === -1) {
    fail('LIB["' + id + '"].log must be one of ' + LOG_TYPES.join(', '));
  }
  if (ex.log === undefined && CATALOG[slug] && CATALOG[slug].t !== 'weight_reps') {
    ex.log = CATALOG[slug].t;
    filledLog.push(id + ' -> ' + ex.log);
  }
});

/* ---------- 6. Artwork ---------- */

const ART = {};
const noArt = [];
Object.keys(LIB).forEach(function (id) {
  const slug = LIB[id].art || id;
  if (art[slug]) ART[id] = art[slug];
  else noArt.push(id + (LIB[id].art ? ' (art: ' + LIB[id].art + ')' : ''));
});
if (noArt.length) {
  warn('no artwork for: ' + noArt.join(', ') + '. Those cards show a neutral placeholder. ' +
    'If the movement exists under another slug in exercise-index.md, add art: \'<slug>\' to the LIB entry.');
}

/* Everything not inlined goes into the JSON tag, keyed by library slug, so
   swapping can reach all 302. A slug used through an "art" alias stays here
   too, because the swap list looks it up by slug, not by the custom id. */
const extra = {};
Object.keys(art).forEach(function (slug) {
  if (!ART[slug]) extra[slug] = art[slug];
});

/* ---------- 7. Accent ---------- */

let accent;
if (typeof accentIn === 'string') {
  accent = ACCENTS[accentIn.toLowerCase()];
  if (!accent) fail('accent must be one of ' + Object.keys(ACCENTS).join(', ') + ' or an object {accent, hi, on}');
} else if (accentIn && typeof accentIn === 'object') {
  accent = accentIn;
  ['accent', 'hi', 'on'].forEach(function (k) {
    if (!/^#[0-9a-fA-F]{6}$/.test(accent[k] || '')) fail('accent.' + k + ' must be a six digit hex colour like #e8a020');
  });
} else {
  fail('accent must be a preset name or an object');
}
function rgb(hex) {
  return [1, 3, 5].map(function (i) { return parseInt(hex.slice(i, i + 2), 16); }).join(',');
}

/* ---------- 8. Assemble ---------- */

function js(value) {
  /* JSON is valid JS for plain data. Escape "<" so no sequence inside a string
     can ever close the script tag early. */
  return JSON.stringify(value).replace(/</g, '\\u003c');
}
function jsPretty(value) {
  return JSON.stringify(value, null, 1).replace(/</g, '\\u003c');
}

html = replaceBlock(html, 'const APP = {', '\n};', 'const APP = ' + JSON.stringify(APP, null, 2) + ';');
html = replaceBlock(html, 'const LIB = {', '\n};', 'const LIB = ' + jsPretty(LIB) + ';');
html = replaceBlock(html, 'const PROGRAM = [', '\n];', 'const PROGRAM = ' + jsPretty(PROGRAM) + ';');

const ART_MARKER = 'const ART = {};   /* BUILD: insert chosen exercise artwork here */';
const CAT_MARKER = 'const CATALOG = {};   /* BUILD: insert the full catalogue here */';
if (html.indexOf(ART_MARKER) === -1) fail('template marker not found: ' + ART_MARKER);
if (html.indexOf(CAT_MARKER) === -1) fail('template marker not found: ' + CAT_MARKER);
html = html.replace(ART_MARKER, 'const ART = ' + js(ART) + ';');
html = html.replace(CAT_MARKER, 'const CATALOG = ' + js(CATALOG) + ';');

/* Accent: the three variables, the rgba tints derived from the old accent, and
   the theme-color meta. */
const rootMatch = html.match(/--accent:(#[0-9a-fA-F]{6});\s*--accent-hi:(#[0-9a-fA-F]{6});\s*--on-accent:(#[0-9a-fA-F]{6});/);
if (!rootMatch) fail('could not find the --accent variables in the template :root block');
const oldRgb = rgb(rootMatch[1]);
html = html.replace(rootMatch[0],
  '--accent:' + accent.accent + '; --accent-hi:' + accent.hi + '; --on-accent:' + accent.on + ';');
html = html.split('rgba(' + oldRgb + ',').join('rgba(' + rgb(accent.accent) + ',');
const themeColor = accent.theme || accent.accent;
if (!/<meta name="theme-color" content="#[0-9a-fA-F]{6}" \/>/.test(html)) fail('theme-color meta tag not found in template');
html = html.replace(/<meta name="theme-color" content="#[0-9a-fA-F]{6}" \/>/,
  '<meta name="theme-color" content="' + themeColor + '" />');

/* Title and home screen name follow the app name. */
html = html.replace(/<meta name="apple-mobile-web-app-title" content="[^"]*" \/>/,
  '<meta name="apple-mobile-web-app-title" content="' + String(APP.name).replace(/"/g, '&quot;') + '" />');
html = html.replace(/<title>[^<]*<\/title>/, '<title>' + String(APP.name).replace(/</g, '&lt;') + '</title>');

/* Extra artwork as a JSON script tag before </body>. */
const extraTag = '<script type="application/json" id="art-extra">' + js(extra) + '</script>\n';
const bodyClose = html.lastIndexOf('</body>');
if (bodyClose === -1) fail('template has no closing body tag');
html = html.slice(0, bodyClose) + extraTag + html.slice(bodyClose);

/* ---------- 9. Syntax check ---------- */

const scriptRe = /<script(\s[^>]*)?>([\s\S]*?)<\/script>/g;
let sm, checked = 0;
while ((sm = scriptRe.exec(html)) !== null) {
  const attrs = sm[1] || '';
  if (/type\s*=/.test(attrs) && !/text\/javascript|application\/javascript/.test(attrs)) continue;
  try {
    new Function(sm[2]);
    checked++;
  } catch (e) {
    fail('generated script does not parse: ' + e.message + '. Check LIB and PROGRAM in ' + inputPath);
  }
}
if (!checked) fail('no JavaScript block found to check; the template is damaged');

/* ---------- 10. Write ---------- */

fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, html);

ICONS.forEach(function (name) {
  const src = path.join(SKILL_DIR, 'assets', name);
  const dst = path.join(path.dirname(outPath), name);
  if (!fs.existsSync(src)) { warn('icon missing from the skill package: ' + src); return; }
  if (path.resolve(src) === path.resolve(dst)) return;
  fs.copyFileSync(src, dst);
});

if (APP.nutrition && APP.nutrition.plan) {
  const planPath = path.resolve(path.dirname(outPath), APP.nutrition.plan);
  if (!fs.existsSync(planPath)) {
    warn('APP.nutrition.plan points at ' + APP.nutrition.plan + ' but that file is not beside the app yet');
  }
}

/* ---------- 11. Report ---------- */

const sizeMb = (fs.statSync(outPath).size / (1024 * 1024)).toFixed(1);
console.log('Built ' + outPath + ' (' + sizeMb + ' MB): ' + Object.keys(LIB).length + ' programme exercises over ' +
  PROGRAM.length + ' days, ' + Object.keys(ART).length + ' with artwork inline, ' + catalogueCount + ' in the swap catalogue, ' +
  warnings.length + ' warning' + (warnings.length === 1 ? '' : 's') + '.');
if (filledLog.length) console.log('Log type taken from the index for: ' + filledLog.join(', '));
warnings.forEach(function (w) { console.log('WARNING: ' + w); });
