/* test-core.js — drives core-from-patched.js under Node.
   Goal: prove the algorithm survived extraction and behaves sanely
   before any markup work begins. */

const { Component } = require("./core-from-patched.js");

let pass = 0, fail = 0;
const results = [];
function t(name, fn) {
  try { fn(); pass++; results.push(["PASS", name, ""]); }
  catch (e) { fail++; results.push(["FAIL", name, e.message]); }
}
function eq(a, b, msg) {
  const A = JSON.stringify(a), B = JSON.stringify(b);
  if (A !== B) throw new Error((msg ? msg + ": " : "") + "got " + A + " want " + B);
}
function ok(c, msg) { if (!c) throw new Error(msg || "expected truthy"); }

const c = new Component({});
const names = c.BOOKS.map(b => b[0]);
const range = (a, b) => names.slice(names.indexOf(a), names.indexOf(b) + 1);
const sel = (books, freq) => ({ name: "x", books, freq });

/* ---------- 1. reference data ---------- */
t("66 books, 1189 chapters", () => {
  eq(c.BOOKS.length, 66);
  eq(c.chaptersOf(names), 1189);
});
t("OT 929 / NT 260", () => {
  eq(c.chaptersOf(range("Genesis", "Malachi")), 929);
  eq(c.chaptersOf(range("Matthew", "Revelation")), 260);
});

/* ---------- 2. conservation: every chapter-reading is dealt exactly once ---------- */
function dealt(s) {
  let n = 0;
  for (let i = 0; i < 365; i++) n += c.dayChapters(s, i).length;
  return n;
}
t("conservation freq=1 (whole Bible) = 1189", () => eq(dealt(sel(names, 1)), 1189));
t("conservation freq=0.5 (OT) = round(929*.5) = 465", () => eq(dealt(sel(range("Genesis", "Malachi"), 0.5)), 465));
t("conservation freq=12 (Psalms) = 1800", () => eq(dealt(sel(["Psalms"], 12)), 1800));
t("conservation freq=4 (Gospels) = 356", () => eq(dealt(sel(range("Matthew", "John"), 4)), 356));
t("conservation freq=52 (Proverbs) = 1612", () => eq(dealt(sel(["Proverbs"], 52)), 1612));

/* ---------- 3. freq=1 covers the canon once, in order, no gaps/repeats ---------- */
t("freq=1 yields canonical order, each chapter exactly once", () => {
  const s = sel(names, 1), seen = [];
  for (let i = 0; i < 365; i++) c.dayChapters(s, i).forEach(x => seen.push(x.book + " " + x.ch));
  eq(seen.length, 1189, "count");
  eq(seen[0], "Genesis 1"); eq(seen[1188], "Revelation 22");
  eq(new Set(seen).size, 1189, "duplicates present");
});

/* ---------- 4. sparse plans produce genuine rest days ---------- */
t("Revelation at 0.5/yr = 11 readings, most days empty", () => {
  const s = sel(["Revelation"], 0.5);
  eq(dealt(s), 11);
  let empty = 0;
  for (let i = 0; i < 365; i++) if (!c.dayChapters(s, i).length) empty++;
  eq(empty, 354, "empty-day count");
});

/* ---------- 5. multi-pass cycling wraps with mod C ---------- */
t("Psalms x12 wraps to Psalm 1 after 150", () => {
  const s = sel(["Psalms"], 12), seq = [];
  for (let i = 0; i < 365 && seq.length < 152; i++) c.dayChapters(s, i).forEach(x => seq.push(x.ch));
  eq(seq[0], 1); eq(seq[149], 150); eq(seq[150], 1, "did not wrap");
});

/* ---------- 6. formatChapters ---------- */
const ch = (b, n) => ({ book: b, ch: n });
t("contiguous run collapses to a range", () =>
  eq(c.formatChapters([ch("Psalms", 1), ch("Psalms", 2), ch("Psalms", 3)]), "Psalm 1–3"));
t("Psalms renders singular 'Psalm'", () =>
  eq(c.formatChapters([ch("Psalms", 42)]), "Psalm 42"));
t("multiple books joined with '; '", () =>
  eq(c.formatChapters([ch("Genesis", 1), ch("Genesis", 2), ch("Matthew", 1)]), "Genesis 1–2; Matthew 1"));
t("single-chapter books drop the number", () => {
  eq(c.formatChapters([ch("Jude", 1)]), "Jude");
  eq(c.formatChapters([ch("Obadiah", 1)]), "Obadiah");
});
t("non-contiguous same book stays split", () =>
  eq(c.formatChapters([ch("Psalms", 1), ch("Psalms", 5)]), "Psalm 1; Psalm 5"));
t("abbreviated variant for print", () =>
  eq(c.formatChapters([ch("Genesis", 1), ch("Genesis", 2), ch("Psalms", 1), ch("Matthew", 1)], true),
     "Gn 1–2; Ps 1; Mt 1"));
t("empty day formats to empty string", () => eq(c.formatChapters([]), ""));

/* ---------- 7. M'Cheyne (four-track, matches the 1842 calendar) ---------- */
const MCH = () => c.TEMPLATES.find(x => x.name === "M'Cheyne");

t("M'Cheyne has four tracks, one per bookmark", () => eq(MCH().sels.length, 4));

t("M'Cheyne day 1 = Genesis 1 · Matthew 1 · Ezra 1 · Acts 1", () => {
  // The historic calendar's actual opening line.
  const got = MCH().sels.map(s => c.formatChapters(c.dayChapters(s, 0)));
  eq(got, ["Genesis 1", "Matthew 1", "Ezra 1", "Acts 1"]);
});

t("M'Cheyne day 365 closes on 2 Chr 36 · Rev 22 · Mal 4 · John 21", () => {
  // The calendar's closing line. Even distribution makes the last day double
  // up, so assert the final chapter of each track rather than the exact string.
  const last = MCH().sels.map(s => {
    const d = c.dayChapters(s, 364);
    return d[d.length - 1];
  });
  eq(last.map(x => x.book + " " + x.ch),
     ["2 Chronicles 36", "Revelation 22", "Malachi 4", "John 21"]);
});

t("M'Cheyne: no track is ever empty on any day", () => {
  const sels = MCH().sels;
  let bad = [];
  for (let i = 0; i < 365; i++)
    sels.forEach((s, k) => { if (!c.dayChapters(s, i).length) bad.push("day " + i + " track " + k); });
  ok(bad.length === 0, bad.slice(0, 5).join(", "));
});

t("M'Cheyne: 4 chapters on most days, never fewer", () => {
  const sels = MCH().sels;
  const hist = {};
  let min = 99;
  for (let i = 0; i < 365; i++) {
    const n = sels.reduce((a, s) => a + c.dayChapters(s, i).length, 0);
    hist[n] = (hist[n] || 0) + 1; min = Math.min(min, n);
  }
  eq(min, 4, "some day dropped below four readings");
  ok(hist[4] >= 270, "only " + hist[4] + " days at exactly 4 chapters");
});

t("M'Cheyne whole-year load = 1599 ch (4.38/day), unchanged", () => {
  const total = MCH().sels.reduce((a, s) => a + dealt(s), 0);
  eq(total, 1599);
  ok(Math.abs(total / 365 - 4.38) < 0.01, "pace off: " + total / 365);
});

t("M'Cheyne year totals: OT once, NT twice, Psalms twice", () => {
  const counts = new Map();
  MCH().sels.forEach(s => {
    for (let i = 0; i < 365; i++)
      c.dayChapters(s, i).forEach(x => {
        const k = x.book + " " + x.ch;
        counts.set(k, (counts.get(k) || 0) + 1);
      });
  });
  eq(counts.size, 1189, "not every chapter is covered");
  const NTset = new Set(range("Matthew", "Revelation"));
  const bad = [];
  counts.forEach((v, k) => {
    const book = k.slice(0, k.lastIndexOf(" "));
    const want = (book === "Psalms" || NTset.has(book)) ? 2 : 1;
    if (v !== want) bad.push(k + " read " + v + "x, want " + want);
  });
  ok(bad.length === 0, bad.slice(0, 5).join("; "));
});

t("M'Cheyne: the two OT tracks partition the OT exactly once", () => {
  const [fam1, , sec1] = MCH().sels;
  const union = new Set([...fam1.books, ...sec1.books]);
  eq(union.size, fam1.books.length + sec1.books.length, "OT tracks overlap");
  eq(c.chaptersOf(fam1.books) + c.chaptersOf(sec1.books), 779, "OT-minus-Psalms mismatch");
  ok(!fam1.books.includes("Psalms") && !sec1.books.includes("Psalms"),
     "Psalms leaked into an OT track");
});

/* ---------- 8. addSelection overlap resolution ---------- */
function withSels(sels) { const k = new Component({}); k.state = { view: "setup", selections: sels, startDate: null, checks: {}, viewDay: 0 }; return k; }
const shape = k => k.state.selections.map(s => [s.name, c.chaptersOf(s.books)]);

t("smaller selection wins; larger renamed 'Rest of'", () => {
  const k = withSels([{ name: "Whole Bible", baseName: "Whole Bible", books: names, freq: 1 }]);
  k.addSelection("Gospels", range("Matthew", "John"), 4);
  eq(shape(k), [["Rest of Whole Bible", 1189 - 89], ["Gospels", 89]]);
});
t("adding a larger selection over a smaller one becomes 'Rest of'", () => {
  const k = withSels([{ name: "Psalms", baseName: "Psalms", books: ["Psalms"], freq: 12 }]);
  k.addSelection("Old Testament", range("Genesis", "Malachi"), 1);
  eq(shape(k), [["Psalms", 150], ["Rest of OT", 779]]);
});
t("adding a book inside a section splits that section", () => {
  // This is the intended picker behaviour ("— in another section"), not a no-op.
  const k = withSels([{ name: "Gospels", baseName: "Gospels", books: range("Matthew", "John"), freq: 4 }]);
  k.addSelection("John", ["John"], 12);
  eq(shape(k), [["Rest of Gospels", 68], ["John", 21]]);
});
t("addition fully covered by smaller selections is a no-op", () => {
  const k = withSels(range("Matthew", "John").map(b => ({ name: b, baseName: b, books: [b], freq: 1 })));
  const before = shape(k);
  k.addSelection("Gospels", range("Matthew", "John"), 4);
  eq(shape(k), before, "a fully-covered add mutated state");
});
t("no chapter is ever lost or duplicated across selections", () => {
  const k = withSels([{ name: "Whole Bible", baseName: "Whole Bible", books: names, freq: 1 }]);
  k.addSelection("Gospels", range("Matthew", "John"), 4);
  k.addSelection("Psalms", ["Psalms"], 12);
  k.addSelection("Proverbs", ["Proverbs"], 12);
  const all = k.state.selections.flatMap(s => s.books);
  eq(all.length, 66, "book count drifted");
  eq(new Set(all).size, 66, "a book appears in two selections");
});
t("empty selections are pruned, never left at 0 books", () => {
  const k = withSels([{ name: "Gospels", baseName: "Gospels", books: range("Matthew", "John"), freq: 4 }]);
  k.addSelection("New Testament", range("Matthew", "Revelation"), 1);
  ok(k.state.selections.every(s => s.books.length > 0), "a zero-book selection survived");
});

/* ---------- 9. streak ---------- */
const dense = () => withSels([{ name: "Bible", books: names, freq: 1 }]); // 3–4 ch every day
t("streak counts consecutive fully-read days", () => {
  const k = dense();
  for (let i = 0; i <= 9; i++) k.state.checks["d" + i + "s0"] = true;
  eq(k.streak(9), 10);
});
t("today incomplete does not break yesterday's streak", () => {
  const k = dense();
  for (let i = 0; i <= 8; i++) k.state.checks["d" + i + "s0"] = true;
  eq(k.streak(9), 9);
});
t("a missed day breaks the streak", () => {
  const k = dense();
  for (let i = 0; i <= 9; i++) if (i !== 5) k.state.checks["d" + i + "s0"] = true;
  eq(k.streak(9), 4);
});
t("KNOWN: streak counts reading-days, not calendar-days", () => {
  // NT-in-a-year schedules only 260 of 365 days. Ten straight calendar days
  // fully read reports 7, because the 3 rest days are skipped, not counted.
  // Correct per spec, but the "day streak" label over-promises. Product call.
  const k = withSels([{ name: "NT", books: range("Matthew", "Revelation"), freq: 1 }]);
  for (let i = 0; i <= 9; i++) k.state.checks["d" + i + "s0"] = true;
  eq(k.streak(9), 7);
});
t("rest days do not break the streak", () => {
  const k = withSels([{ name: "Rev", books: ["Revelation"], freq: 0.5 }]);
  const scheduled = [];
  for (let i = 0; i < 365; i++) if (k.dayChapters(k.state.selections[0], i).length) scheduled.push(i);
  const upTo = scheduled.filter(i => i <= scheduled[3]);
  upTo.forEach(i => k.state.checks["d" + i + "s0"] = true);
  eq(k.streak(scheduled[3]), upTo.length, "rest days interrupted the count");
});

/* ---------- 10. reader passage ids ---------- */
t("chapterIds enumerates one id per chapter (replaces passageIds in v5)", () => {
  eq(c.chapterIds([ch("Psalms",1), ch("Psalms",2), ch("Psalms",3)]), ["PSA.1", "PSA.2", "PSA.3"]);
  eq(c.chapterIds([ch("Genesis",1)]), ["GEN.1"]);
});
t("chapterIds spans books in order and de-dupes", () => {
  eq(c.chapterIds([ch("Genesis",1), ch("Genesis",2), ch("Matthew",1), ch("Genesis",1)]),
     ["GEN.1", "GEN.2", "MAT.1"]);
});
t("labelForCid round-trips a chapter id to its display label", () => {
  c.osis("Genesis");                       // prime the OSIS map
  eq(c.labelForCid("PSA.23"), "Psalm 23");
  eq(c.labelForCid("MAT.5"), "Matthew 5");
  eq(c.labelForCid("JUD.1"), "Jude");
});
t("every book has an OSIS code", () => {
  const missing = names.filter(n => !c.osis(n));
  eq(missing, [], "books without OSIS");
});

/* ---------- 11. freq ladder + day index ---------- */
t("freq ladder labels", () => {
  eq(c.FREQ_STEPS, [0.5, 1, 2, 3, 4, 6, 12, 24, 52]);
  eq(c.FREQ_STEPS.map(f => c.freqLabel(f)),
     ["every 2 years", "once a year", "2× a year", "3× a year", "4× a year",
      "6× a year", "once a month", "24× a year", "once a week"]);
});
t("todayIndex clamps to 0–364", () => {
  const today = new Date(); const iso = d => d.toISOString().slice(0, 10);
  eq(c.todayIndex(iso(today)), 0);
  const future = new Date(today.getTime() + 5 * 86400000);
  eq(c.todayIndex(iso(future)), 0, "future start should clamp to 0");
  const old = new Date(today.getTime() - 900 * 86400000);
  eq(c.todayIndex(iso(old)), 364, "should clamp to 364");
});

/* ---------- 12. save/load round-trip (the two functions Rock replaces) ---------- */
t("save/load round-trips the plan shape", () => {
  const k = withSels([{ name: "Psalms", books: ["Psalms"], freq: 12 }]);
  k.state.startDate = "2026-07-31"; k.state.checks = { d0s0: true }; k.state.view = "today";
  k.save();
  const back = new Component({}).load();
  eq(back.selections[0].name, "Psalms");
  eq(back.startDate, "2026-07-31");
  eq(back.checks, { d0s0: true });
});

/* ---------- report ---------- */
const w = Math.max(...results.map(r => r[1].length));
results.forEach(([s, n, m]) =>
  console.log((s === "PASS" ? "  ok  " : "  FAIL") + "  " + n.padEnd(w) + (m ? "   " + m : "")));
console.log("\n" + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
