/* WITNESS · cross-journey-ledger   (ledger W-004)
   The promise (canary desc): "Waiter serves ONE item + cancels ONE item of a 4-item order;
   the patron ledger shows Served/Cancelled/Ordered simultaneously in real-time and survives
   a hard reload (STR-681/682)."

   Previously DEFERRED D4 (1/13 steps): no staff account existed for this fixture. The
   2026-09-05 credential pack gives Canary Mosaic its own waiter, so diner and waiter now sit
   on ONE board and the whole journey is walkable.

   SCOPING: the waiter Orders list carries ~17 live cards from the scheduled fleet, and the
   last attempt died on a bare text match. A card names its own table ("T10092 ..."), so I
   scope to my table and refuse to act unless EXACTLY ONE card matches. A first attempt at a
   set-difference was my own bug (I snapshotted while on the Tables tab, where no order card
   renders) - recorded here so nobody reads that stop as a product finding. */
import { test, expect } from "@playwright/test";
import { journal, look, arriveAsDiner, signInWaiter, DWELL, roleContext, closeRole,
         account, HOUSE, table, PATRON } from "../lib-witness.mjs";
import fs from "node:fs";

const RID = HOUSE.restaurantId;
const T   = table(10093);          // "No guests" on the board at 19:12 UTC; throws if outside the block

const ids = (page, prefix) => page.evaluate(p =>
  [...new Set([...document.querySelectorAll(`[data-testid^="${p}"]`)]
    .map(e => e.getAttribute("data-testid").slice(p.length)))], prefix);
const allIds = page => page.evaluate(() =>
  [...new Set([...document.querySelectorAll("[data-testid]")].map(e => e.getAttribute("data-testid")))].sort());

test("cross-journey-ledger", async ({ browser }) => {
  const jr = journal("cross-journey-ledger");
  jr.target({ app: "patron (mosaic_v2) + waiter", baseUrl: PATRON, restaurantId: RID, table: T,
              substitution: "none - this IS the canary's own fixture, walked with its own waiter account" });
  jr.note("**The promise:** a waiter serves one dish and cancels another out of a four-dish order, "
        + "and the diner's phone shows all three states at once — served, cancelled, still coming — "
        + "and still does after the page is reloaded.");

  const cP = await roleContext(browser, jr, "patron");
  const cW = await roleContext(browser, jr, "waiter", "Desktop Chrome");
  const p = await cP.newPage(), w = await cW.newPage();

  let verdict = "INCOMPLETE", why = "", leftBehind = `nothing`;
  const dishes = [];            // names of the four I ordered
  let before = [], myOrder = "", served = "", cancelled = "";
  const notes = {};

  try {
    /* 1 */
    await jr.step("PATRON", `I sit down at table ${T} and the menu comes up`, async id => {
      const gates = await arriveAsDiner(p, RID, T, HOUSE.layout);
      await expect(p.locator('[data-testid^="m2-add-"]').first(),
                   "there should be something I can order").toBeVisible({ timeout: 30000 });
      const t = await look(p, jr, "patron", id, "menu on arrival");
      const n = await p.locator('[data-testid^="m2-add-"]').count();
      return `gates: ${gates.join(", ") || "none"}; ${n} dishes I can add. ${t.slice(0, 200)}`;
    });

    /* 2 */
    await jr.step("PATRON", "I choose four dishes", async id => {
      for (let i = 0; i < 4; i++) {
        const add = p.locator('[data-testid^="m2-add-"]').nth(i);
        const iid = (await add.getAttribute("data-testid")).replace("m2-add-", "");
        const name = await p.locator(`[data-testid="m2-text-${iid}"]`).innerText()
                        .catch(() => `dish ${i + 1}`);
        await add.click({ timeout: 25000 });
        await p.waitForTimeout(2500);
        /* Some dishes ask a question before they go in the basket. Answer it as a person would. */
        const confirm = p.getByRole("button", { name: /add to (order|cart)|^add$|confirm/i }).first();
        if (await confirm.count()) { await confirm.click({ timeout: 15000 }).catch(() => {}); await p.waitForTimeout(2500); }
        dishes.push(name.replace(/\s+/g, " ").slice(0, 40).trim());
      }
      await p.waitForTimeout(DWELL);
      const t = await look(p, jr, "patron", id, "four dishes chosen");
      leftBehind = `an open basket at table ${T}`;
      return `I picked: ${dishes.join(" | ")}. ${t.slice(0, 200)}`;
    });

    /* 3 */
    await jr.step("WAITER", "The waiter can see somebody is at my table", async id => {
      const a = account(HOUSE.waiter);
      await signInWaiter(w, a.email, a.password);
      await w.getByTestId("tab-tables").click({ timeout: 20000 }).catch(() => {});
      await w.waitForTimeout(DWELL);
      const card = await w.getByTestId(`table-card-${T}`).innerText();
      await look(w, jr, "waiter", id, `tables board, table ${T}`);
      const txt = card.replace(/\s+/g, " ");
      notes.tableCardAtStep3 = txt;
      if (/no guests/i.test(txt))
        throw new Error(`I am sitting at table ${T} with four dishes in my basket and the waiter's `
                      + `board still says "No guests". Card reads: ${txt}`);
      return `table ${T} card reads "${txt}"`;
    });

    /* 4 */
    await jr.step("PATRON", "I place the order for all four", async id => {
      await p.locator('[data-testid="m2-dock-trigger"], [data-testid="m2-view-order"], [data-testid="m2-dock-cart"]')
             .first().click({ timeout: 25000 });
      await p.waitForTimeout(DWELL);
      await look(p, jr, "patron", id + "a", "my basket before ordering");
      await p.locator('[data-testid="order-now-btn"]').first().click({ timeout: 25000 });
      await p.waitForTimeout(DWELL + 5000);
      const t = await look(p, jr, "patron", id, "after placing the order");
      leftBehind = `a live session and a four-dish order at table ${T}`;
      return t.slice(0, 300);
    });

    /* 5 */
    await jr.step("WAITER", "My order shows up on the waiter's list", async id => {
      await w.reload({ waitUntil: "domcontentloaded" });
      await w.waitForTimeout(DWELL);
      await w.getByTestId("tab-orders").click({ timeout: 20000 }).catch(() => {});
      await w.waitForTimeout(DWELL);
      /* CONTROL first: the list must actually be loaded, or "my order is missing" would be
         indistinguishable from "I looked before anything rendered". Other tables' orders
         are the proof that this screen is live. */
      const anyCard = w.locator('[data-testid^="order-card-"]');
      await expect(anyCard.first(), "the orders list should have loaded at all")
        .toBeVisible({ timeout: 60000 });
      const total = await anyCard.count();
      notes.totalCardsOnList = total;

      /* A card names its own table. Now wait, as a waiter would, for mine to arrive. */
      const mine = w.locator('[data-testid^="order-card-"]').filter({ hasText: new RegExp(`\\bT${T}\\b`) });
      let n = 0;
      for (let i = 0; i < 12 && n !== 1; i++) {          // up to ~2 minutes of watching
        n = await mine.count();
        if (n === 1) break;
        await w.waitForTimeout(10000);
        if (i % 3 === 2) { await w.reload({ waitUntil: "domcontentloaded" }); await w.waitForTimeout(DWELL); }
      }
      notes.cardsForMyTable = n;
      notes.listSample = await w.evaluate(() => [...document.querySelectorAll('[data-testid^="order-card-"]')]
        .slice(0, 4).map(e => e.innerText.replace(/\s+/g, " ").slice(0, 90)));
      const t0 = await look(w, jr, "waiter", id + "a", "the orders list I am watching");
      if (n === 0)
        throw new Error(`My phone says all four dishes are ORDERED, and after two minutes the waiter's `
                      + `list still has no card for table ${T}. The list itself is live - it is showing `
                      + `${total} orders for other tables. Top of the list: ${(notes.listSample || []).join(" // ")}`);
      if (n !== 1)
        throw new Error(`The waiter's list shows ${n} cards for table ${T}. I cannot tell which is mine, `
                      + `so I will not act on any of them.`);
      myOrder = (await mine.first().getAttribute("data-testid")).replace("order-card-", "");
      await mine.first().scrollIntoViewIfNeeded().catch(() => {});
      const t = await look(w, jr, "waiter", id, "orders list with my order on it");
      const txt = (await mine.first().innerText()).replace(/\s+/g, " ");
      notes.myCardText = txt.slice(0, 500);
      notes.myCardIds = await w.evaluate(o => [...document.querySelectorAll(`[data-testid="order-card-${o}"] [data-testid]`)]
                                             .map(e => e.getAttribute("data-testid")), myOrder);
      jr.note(`> **My order card carries these controls:** \`${notes.myCardIds.join("\`, \`")}\``);
      return `card ${myOrder.slice(0, 8)} of ${total} on the list reads: ${txt.slice(0, 300)} — ${t.slice(0, 80)} ${t0.slice(0,1)}`;
    });

    /* 6 */
    await jr.soft("WAITER", "The waiter puts it into the till and starts the kitchen", async id => {
      const pos = w.getByTestId(`pos-entered-${myOrder}`);
      const act = w.getByTestId(`order-status-action-${myOrder}`);
      const hadPos = await pos.count() > 0, hadAct = await act.count() > 0;
      if (hadPos) { await pos.click({ timeout: 20000 }); await w.waitForTimeout(DWELL); }
      if (hadAct) { await act.click({ timeout: 20000 }).catch(() => {}); await w.waitForTimeout(DWELL); }
      const t = await look(w, jr, "waiter", id, "after till + kitchen start");
      const txt = (await w.getByTestId(`order-card-${myOrder}`).innerText()).replace(/\s+/g, " ");
      if (!hadPos && !hadAct)
        throw new Error("there is no till control and no kitchen-start control on my order card at all. "
                      + `Card reads: ${txt.slice(0, 240)}`);
      return `${hadPos ? "pressed the till button; " : "no till button on this card; "}`
           + `${hadAct ? "pressed the status control; " : "no status control; "}card now reads: ${txt.slice(0, 260)}`;
    });

    /* 7 — the crux. Per-ITEM serve. */
    await jr.step("WAITER", "The waiter serves ONE of my four dishes", async id => {
      /* Look wherever a waiter would: the order card first, then the table sheet. */
      let scope = "orders list";
      let perItem = await ids(w, "mark-served-btn-");
      if (!perItem.length) {
        await w.getByTestId("tab-tables").click({ timeout: 20000 }).catch(() => {});
        await w.waitForTimeout(DWELL);
        await w.getByTestId(`table-card-${T}`).click({ timeout: 25000 }).catch(() => {});
        await w.waitForTimeout(DWELL);
        scope = "table sheet";
        perItem = await ids(w, "mark-served-btn-");
        notes.tableSheetIds = await allIds(w);
        jr.note(`> **On the table sheet the waiter has:** \`${notes.tableSheetIds.join("`, `")}\``);
      }
      const t = await look(w, jr, "waiter", id, `looking for a way to serve one dish (${scope})`);
      if (!perItem.length) {
        notes.serveSearch = (await allIds(w)).filter(x => /serve|deliver|item|status/i.test(x));
        jr.note(`> **Everything on screen that mentions serving, delivering, an item or a status:** `
              + `\`${notes.serveSearch.join("\`, \`") || "nothing"}\``);
      }
      if (!perItem.length)
        throw new Error("I can find no way to serve a SINGLE dish. On the orders list the only serve "
                      + `control is order-mark-served-${myOrder.slice(0, 8)}, which is the whole order. `
                      + `Screen reads: ${t.slice(0, 240)}`);
      served = perItem[0];
      await w.getByTestId(`mark-served-btn-${served}`).click({ timeout: 20000 });
      await w.waitForTimeout(DWELL);
      const conf = w.getByRole("button", { name: /^(serve|mark served|confirm|yes)/i }).first();
      if (await conf.count()) { await conf.click({ timeout: 15000 }).catch(() => {}); await w.waitForTimeout(DWELL); }
      const t2 = await look(w, jr, "waiter", id + "b", "after serving one dish");
      if (await w.getByTestId(`mark-served-btn-${served}`).count())
        throw new Error(`I pressed serve on one dish and its serve button is still there. ${t2.slice(0, 240)}`);
      return `served item ${served.slice(0, 8)} from the ${scope}. Screen: ${t2.slice(0, 240)}`;
    });

    /* 8 */
    await jr.step("PATRON", "My phone shows one dish served and three still coming", async id => {
      await p.waitForTimeout(15000);
      await p.locator('[data-testid="m2-dock-trigger"], [data-testid="m2-view-order"], [data-testid="m2-dock-cart"]')
             .first().click({ timeout: 25000 }).catch(() => {});
      await p.waitForTimeout(DWELL);
      const ordered = p.getByRole("tab", { name: /ordered/i }).or(p.getByText(/^ordered$/i)).first();
      if (await ordered.count()) { await ordered.click({ timeout: 15000 }).catch(() => {}); await p.waitForTimeout(DWELL); }
      const t = await look(p, jr, "patron", id, "my running order after one was served");
      notes.ledgerIds = await allIds(p);
      const nServed = (t.match(/served/gi) || []).length;
      if (!/served/i.test(t))
        throw new Error("a dish was served and my phone never says so. My running order reads: "
                      + t.slice(0, 300));
      return `my ledger reads: ${t.slice(0, 400)} (the word "served" appears ${nServed}x)`;
    });

    /* 9 */
    await jr.step("WAITER", "The waiter cancels a DIFFERENT one of my dishes", async id => {
      let perItem = await ids(w, "cancel-item-btn-");
      if (!perItem.length) {
        await w.getByTestId("tab-tables").click({ timeout: 20000 }).catch(() => {});
        await w.waitForTimeout(DWELL);
        await w.getByTestId(`table-card-${T}`).click({ timeout: 25000 }).catch(() => {});
        await w.waitForTimeout(DWELL);
        perItem = await ids(w, "cancel-item-btn-");
      }
      const t = await look(w, jr, "waiter", id, "looking for a way to cancel one dish");
      if (!perItem.length) {
        notes.cancelSearch = (await allIds(w)).filter(x => /cancel|remove|void|item/i.test(x));
        jr.note(`> **Everything on screen that mentions cancelling, removing or an item:** `
              + `\`${notes.cancelSearch.join("\`, \`") || "nothing"}\``);
      }
      const target = perItem.filter(x => x !== served)[0];
      if (!target)
        throw new Error("I can find no way to cancel a single dish that is not the one already served. "
                      + `Screen reads: ${t.slice(0, 240)}`);
      cancelled = target;
      await w.getByTestId(`cancel-item-btn-${cancelled}`).click({ timeout: 20000 });
      await w.waitForTimeout(DWELL);
      const conf = w.getByRole("button", { name: /^(remove|cancel item|confirm|yes)/i }).first();
      if (await conf.count()) { await conf.click({ timeout: 15000 }).catch(() => {}); await w.waitForTimeout(DWELL); }
      const t2 = await look(w, jr, "waiter", id + "b", "after cancelling one dish");
      if (await w.getByTestId(`cancel-item-btn-${cancelled}`).count())
        throw new Error(`I pressed cancel and that dish still has its cancel button. ${t2.slice(0, 240)}`);
      return `cancelled item ${cancelled.slice(0, 8)}. Screen: ${t2.slice(0, 240)}`;
    });

    /* 10 — the whole point of the journey. */
    await jr.step("PATRON", "My phone shows all three at once: served, cancelled, still coming", async id => {
      await p.waitForTimeout(15000);
      const t = await look(p, jr, "patron", id, "served + cancelled + ordered together");
      const hasServed = /served/i.test(t), hasCancel = /cancel/i.test(t);
      const hasPending = /(ordered|in the kitchen|on its way|preparing)/i.test(t);
      notes.mixedLedger = t.slice(0, 600);
      if (!(hasServed && hasCancel && hasPending))
        throw new Error("my ledger does not show all three states together. served="
          + `${hasServed}, cancelled=${hasCancel}, still-coming=${hasPending}. It reads: ${t.slice(0, 400)}`);
      return `all three states are on screen together: ${t.slice(0, 400)}`;
    });

    /* 11 */
    await jr.step("PATRON", "I reload the page and the same three states are still there", async id => {
      await p.reload({ waitUntil: "domcontentloaded" });
      await p.waitForTimeout(DWELL + 8000);
      const gates = await arriveAsDiner(p, RID, T, HOUSE.layout).catch(() => []);
      await p.locator('[data-testid="m2-dock-trigger"], [data-testid="m2-view-order"], [data-testid="m2-dock-cart"]')
             .first().click({ timeout: 25000 }).catch(() => {});
      await p.waitForTimeout(DWELL);
      const ordered = p.getByRole("tab", { name: /ordered/i }).or(p.getByText(/^ordered$/i)).first();
      if (await ordered.count()) { await ordered.click({ timeout: 15000 }).catch(() => {}); await p.waitForTimeout(DWELL); }
      const t = await look(p, jr, "patron", id, "same ledger after a hard reload");
      const ok = /served/i.test(t) && /cancel/i.test(t) && /(ordered|in the kitchen|on its way|preparing)/i.test(t);
      if (!ok)
        throw new Error("after reloading, my ledger no longer shows the three states. It reads: "
                      + t.slice(0, 400));
      return `after the reload it still reads: ${t.slice(0, 400)}`;
    });

    /* 12 */
    await jr.step("WAITER", "The waiter serves the last two dishes", async id => {
      await w.reload({ waitUntil: "domcontentloaded" });
      await w.waitForTimeout(DWELL + 3000);
      await w.getByTestId("tab-tables").click({ timeout: 20000 }).catch(() => {});
      await w.waitForTimeout(DWELL);
      await w.getByTestId(`table-card-${T}`).click({ timeout: 25000 }).catch(() => {});
      await w.waitForTimeout(DWELL);
      let left = await ids(w, "mark-served-btn-");
      const n0 = left.length;
      for (const it of left) {
        await w.getByTestId(`mark-served-btn-${it}`).click({ timeout: 20000 }).catch(() => {});
        await w.waitForTimeout(3000);
        const conf = w.getByRole("button", { name: /^(serve|mark served|confirm|yes)/i }).first();
        if (await conf.count()) { await conf.click({ timeout: 15000 }).catch(() => {}); await w.waitForTimeout(3000); }
      }
      await w.waitForTimeout(DWELL);
      const t = await look(w, jr, "waiter", id, "after serving the rest");
      const remaining = (await ids(w, "mark-served-btn-")).length;
      if (remaining)
        throw new Error(`I served every dish I could and ${remaining} still have a serve button. ${t.slice(0, 240)}`);
      return `served the remaining ${n0}. Screen: ${t.slice(0, 240)}`;
    });

    /* 13 */
    await jr.step("PATRON", "My phone finally reads three served and one cancelled", async id => {
      await p.waitForTimeout(15000);
      const t = await look(p, jr, "patron", id, "the finished ledger");
      const nServed = (t.match(/served/gi) || []).length;
      notes.finalLedger = t.slice(0, 600);
      if (!/cancel/i.test(t))
        throw new Error(`the cancelled dish has disappeared from my ledger. It reads: ${t.slice(0, 400)}`);
      if (/\b(ordered|in the kitchen|on its way|preparing)\b/i.test(t) && nServed < 2)
        throw new Error("everything has been served but my phone still says dishes are coming. "
                      + t.slice(0, 400));
      return `final ledger: ${t.slice(0, 400)}`;
    });

    verdict = "HONEST_GREEN";
    why = `Walked all thirteen steps on Canary Mosaic table ${T} with the fixture's own waiter: `
        + "four dishes ordered, one served, one cancelled, the diner's phone showed served, "
        + "cancelled and still-coming together and kept them across a hard reload.";
  } catch (e) {
    why = String(e.message || e).split("\n")[0];
  } finally {
    fs.writeFileSync(`${jr.dir}/NOTES.json`, JSON.stringify({ dishes, myOrder, served, cancelled, ...notes }, null, 1));
    /* Give the table back. A walk that poisons its own runway ruins the next one. */
    try {
      await w.getByTestId("tab-tables").click({ timeout: 15000 }).catch(() => {});
      await w.waitForTimeout(3000);
      await w.getByTestId(`table-card-${T}`).click({ timeout: 15000 }).catch(() => {});
      await w.waitForTimeout(3000);
      await w.getByTestId("table-detail-tab-bill").click({ timeout: 10000 }).catch(() => {});
      await w.waitForTimeout(2000);
      const label = w.getByText(/swipe to close/i).first();
      if (await label.count()) {
        const b = await label.boundingBox();
        if (b) {
          const y = b.y + b.height / 2, x0 = Math.max(12, b.x - 24), x1 = b.x + b.width + 60;
          await w.mouse.move(x0, y); await w.mouse.down();
          for (let i = 1; i <= 14; i++) { await w.mouse.move(x0 + ((x1 - x0) * i) / 14, y); await w.waitForTimeout(45); }
          await w.mouse.up(); await w.waitForTimeout(DWELL);
        }
      }
      await w.reload({ waitUntil: "domcontentloaded" }); await w.waitForTimeout(DWELL);
      await w.getByTestId("tab-tables").click({ timeout: 15000 }).catch(() => {});
      await w.waitForTimeout(3000);
      const card = await w.getByTestId(`table-card-${T}`).innerText().catch(() => "");
      await look(w, jr, "waiter", "z9", "board after I tried to give the table back");
      leftBehind = /no guests/i.test(card)
        ? `nothing — table ${T} reads "No guests" again`
        : `table ${T} still reads "${card.replace(/\s+/g, " ").slice(0, 90)}" — I could not close it`;
    } catch { leftBehind += " (I could not reach the close control)"; }
    jr.note(`> **What I left behind:** ${leftBehind}.`);
    jr.finish(verdict, why);
    await closeRole(cP, jr, "patron"); await closeRole(cW, jr, "waiter");
  }
  expect(verdict, why).toBe("HONEST_GREEN");
});
