/* WITNESS · mosaic_v2-session-cooldown (W-064)                          — QRATE-UAT-1
 *
 * WHY THIS WAS PARKED: my previous attempt died on a Playwright trace ENOENT during context
 * teardown - my harness, not the product. `closeRole` no longer lets a teardown clash destroy
 * a completed walk, so the row can be driven.
 *
 * WHAT THE JOURNEY PROMISES (from the canary's own card): after a waiter closes a table there
 * is a 5-minute cooldown (RECENT_CLOSED_WINDOW_MIN=5) with three branches -
 *   m2scd-04  IN-COOLDOWN  a fresh QR scan with NO session id must land on ScanQR/Farewell,
 *                          never the mosaic_v2 menu.
 *   m2scd-05  STALE ID     a reload still carrying the dead session= must land on ScanQR too.
 *   m2scd-06  POST-COOLDOWN a fresh scan after the window must be ALLOWED to start a new
 *                          session and render the menu (trapping it re-creates the infinite loop).
 *
 * TWO HONEST DEPARTURES FROM THE CANARY, both stated in the notes:
 *  1. The canary closes the session by API and ages `updatedAt` with a backdate call. I have no
 *     API credential and would not use one - I close it the way a WAITER does, by swiping the
 *     close on the Bill tab, and I wait the five minutes on the wall clock like a real diner.
 *  2. Substitution: Anant + daniel@, because no canary waiter password exists here. Same as the
 *     already-walked mosaic_v2-session-close row.
 *
 * NO ORDER IS PLACED. W-063 established the close is unlocked on an EMPTY table, so this walk
 * needs no order, touches no bill, and presses no payment control.
 */
import { test, expect } from "@playwright/test";
import { journal, look, roleContext, closeRole, signInWaiter, arriveAsDiner,
         account, DWELL, PATRON, WAITER, PHONE, DESK } from "../lib-witness.mjs";
import { placeOwnOrder } from "./split-lib.mjs";

const ANANT = "097c229c-a1cb-4ba7-b603-800e46936133";
// Table 7, not table 2: the board showed table 2 already carrying two guests and a bill in
// "Settling - 0/1 covered" from earlier walks, so its close was legitimately locked. That was
// table state left by me, not a product fault. Table 7 reads "No guests".
let TABLE = null;   // chosen from the board at run time - see the first step
const COOLDOWN_MIN = 5;

const sessionOf = url => (String(url).match(/[?&]session=([0-9a-f-]{8,})/i) || [])[1] || null;

/** What is this diner actually looking at? Positive evidence for BOTH outcomes, never
 *  "no error appeared" - a blank page would satisfy that and mean nothing. */
async function whereAmI(page) {
  return await page.evaluate(() => {
    const ids = [...document.querySelectorAll("[data-testid]")].map(e => e.getAttribute("data-testid"));
    const txt = (document.body.innerText || "").replace(/\s+/g, " ").trim();
    return {
      tiles: ids.filter(x => /^m2-tile-/.test(x)).length,
      menu: ids.some(x => /^(m2-masonry|m2-dock)$/.test(x)),
      scanqr: /scan .*qr|scan the qr|farewell|thanks for dining|hope to see you|see you (again|soon)|session (has )?(ended|closed|expired)|no longer active|start a new|ask your server|rescan/i.test(txt),
      welcome: /let'?s get started/i.test(txt),
      url: location.href,
      text: txt.slice(0, 240),
      ids: [...new Set(ids)].slice(0, 18),
    };
  });
}

test("mosaic_v2-session-cooldown", async ({ browser }) => {
  const jr = journal("mosaic_v2-session-cooldown");
  jr.target({ app: "patron + waiter", restaurantId: ANANT, table: TABLE,
              substitution: "Anant + daniel@ - no canary waiter credential exists here" });
  jr.note("_The promise: once a waiter closes a table, a diner who re-scans within five minutes is "
        + "sent to ScanQR rather than back into the dead session - and once those five minutes are "
        + "up, a fresh scan is let back in. I close the table by SWIPING it closed as the waiter, "
        + "and I wait the five minutes on the clock. The canary does both by API; I have no API "
        + "credential and a diner has no API either._");
  jr.note("_I place no order, so nothing is left on this table but a guest session, and the close "
        + "clears that._");

  const cP = await roleContext(browser, jr, "diner", PHONE);
  const p = await cP.newPage();
  const cW = await roleContext(browser, jr, "waiter", DESK);
  const w = await cW.newPage();
  let verdict = "INCOMPLETE", why = "";
  const rec = {};

  try {
    await jr.step("WAITER", "Before anything: find a table that is actually free", async id => {
      // Starting on a dirty table is what wasted the previous pass. A table someone else is
      // already sitting at cannot be closed, so the cooldown could never be reached and every
      // later step would be measuring nothing. Establish the starting state FIRST.
      const acc = account("waiter");
      await signInWaiter(w, acc.email, acc.password);
      await w.getByTestId("tab-tables").click({ timeout: 20000 });
      await w.waitForTimeout(DWELL);
      // Hardcoding a table number was wrong: a walk that ends without closing its table poisons
      // that table for the next run, and a seated guest with no order cannot be cleared by anyone
      // (STR-1202). So ASK THE BOARD which table is free, and say which one I took.
      const cards = await w.evaluate(() => [...document.querySelectorAll('[data-testid^="table-card-"]')]
        .map(e => ({ n: e.getAttribute("data-testid").replace("table-card-", ""),
                     t: (e.innerText || "").replace(/\s+/g, " ").trim() })));
      await look(w, jr, "waiter", id, "the whole table board before I choose");
      jr.note(`> **Every table on Daniel's board:** `
            + `${cards.map(c => `${c.n}: "${c.t.slice(0, 40)}"`).join(" · ") || "no table cards at all"}`);
      const free = cards.find(c => /no guests/i.test(c.t));
      if (!free)
        throw new Error(`no table on this board is free - ${cards.map(c => `${c.n} "${c.t.slice(0, 40)}"`).join("; ")}. `
          + `Several carry guest sessions my own earlier walks opened and could not close, which is `
          + `the STR-1202 dead end, not a fault in this journey. I stop rather than make it worse.`);
      TABLE = free.n;
      rec.startState = free.t;
      return `Taking table ${TABLE}, which reads "${free.t.slice(0, 90)}".`;
    });

    await jr.step("PATRON", "A diner scans the table and gets a live session", async id => {
      await arriveAsDiner(p, ANANT, TABLE, "mosaic_v2");
      await p.waitForTimeout(DWELL);
      const at = await whereAmI(p);
      rec.s1 = sessionOf(at.url);
      await look(p, jr, "diner", id, "diner is seated with a live session");
      if (!rec.s1)
        throw new Error(`the URL never gained a session id, so there is no session to close and `
          + `nothing to cool down. I am looking at: ${at.text}`);
      expect(at.tiles, "a seated diner should see dishes").toBeGreaterThan(0);
      return `Session ${rec.s1.slice(0, 8)}… is live and ${at.tiles} dishes are on screen.`;
    });

    await jr.step("WAITER", "Setup: settle the table so the close is even available", async id => {
      // NOT part of the journey's promise - this is SETUP to reach the state the journey needs.
      // A guest who is merely SEATED leaves the table "Settling - 0/1 covered" and the close does
      // not take, because settlement is the diner's job and a diner with nothing in the kitchen
      // cannot ask for the check. That dead end is already known and ticketed (STR-1202) - I am
      // not re-discovering it, I am routing around it by the one path proven to settle a table:
      // place an order, then CANCEL it. Cancelling settles and closes cleanly; marking served
      // does not. No payment control is touched.
      // Only needed if the close is actually locked. A diner who is merely seated with no order
      // leaves "No orders yet" and the close IS offered - proven when table 7 was reclaimed. So
      // check first and skip the ordering entirely when it is not required.
      await w.getByTestId("tab-tables").click({ timeout: 20000 }).catch(() => {});
      await w.waitForTimeout(DWELL);
      await w.getByTestId(`table-card-${TABLE}`).click({ timeout: 20000 }).catch(() => {});
      await w.waitForTimeout(DWELL);
      await w.getByTestId("table-detail-tab-bill").click({ timeout: 20000 }).catch(() => {});
      await w.waitForTimeout(2500);
      const billNow = (await w.locator("body").innerText()).replace(/\s+/g, " ");
      if (/swipe to close/i.test(billNow) && !/swipe after bill is settled/i.test(billNow)) {
        jr.note("> **No settling needed:** the Bill tab already reads \"Swipe to Close Table\" "
              + "(the diner is seated but has ordered nothing), so I place no order at all.");
        return "The close is already available - no order needed, none placed.";
      }
      rec.order = await placeOwnOrder(p, "The diner");
      jr.note(`> **Order placed purely so it can be cancelled:** "${rec.order.label}" · ledger "${rec.order.badge}"`);
      await w.getByTestId("tab-tables").click({ timeout: 20000 }).catch(() => {});
      await w.waitForTimeout(DWELL);
      await w.getByTestId(`table-card-${TABLE}`).click({ timeout: 20000 });
      await w.waitForTimeout(DWELL);
      const cancel = w.locator('[data-testid^="cancel-item-btn-"]').first();
      if (!(await cancel.count()))
        throw new Error(`no cancel control appeared on table ${TABLE} after the diner ordered, so I `
          + `cannot settle the table and cannot reach the close. Setup failed, not the journey.`);
      await cancel.click({ timeout: 20000 });
      await w.waitForTimeout(DWELL);
      const t = await look(w, jr, "waiter", id, "table after the order was cancelled");
      jr.note(`> **After cancelling:** ${t.replace(/\s+/g, " ").slice(0, 200)}`);
      return `Ordered "${rec.order.label}" and cancelled it, which is the path that settles a table.`;
    });

    await jr.step("WAITER", "Daniel closes that table", async id => {
      // The setup step may already have left us on this table's Bill tab. Re-clicking "Tables"
      // then times out, because the open detail sheet covers it - that is a harness stumble, not
      // the app refusing. Only navigate if we are not already looking at the close control.
      // A pending service request also AUTO-OPENS a panel over the whole board and swallows every
      // click, so clear those the way a waiter would before touching the board.
      const onBill = async () => /swipe to close|swipe after bill is settled/i
        .test((await w.locator("body").innerText()).replace(/\s+/g, " "));
      if (!(await onBill())) {
        for (let i = 0; i < 6; i++) {
          const done = w.getByRole("button", { name: /^done$/i }).first();
          if (!(await done.count())) break;
          await done.click({ timeout: 15000 }).catch(() => {});
          await w.waitForTimeout(2500);
        }
        await w.getByTestId("tab-tables").click({ timeout: 20000 }).catch(() => {});
        await w.waitForTimeout(DWELL);
        await w.getByTestId(`table-card-${TABLE}`).click({ timeout: 20000 }).catch(() => {});
        await w.waitForTimeout(DWELL);
        await w.getByTestId("table-detail-tab-bill").click({ timeout: 20000 }).catch(() => {});
        await w.waitForTimeout(2500);
      }
      const t = await look(w, jr, "waiter", id, "the bill tab before he swipes");
      if (/swipe after bill is settled/i.test(t))
        throw new Error(`the close is locked - the control reads "Swipe after bill is settled", so `
          + `something is still on this table. I place no orders, so this is table state left by an `
          + `earlier walk, not this one. Bill tab: ${t.slice(0, 240)}`);
      // A real swipe, using the geometry that is PROVEN to close a table (anant-cleanup, which
      // reclaimed table 7 on 2026-09-05). My earlier attempt swiped the LABEL's own box - the
      // label sits inside a pointer-events:none overlay and is narrower than the track, so the
      // drag never covered the distance and the close silently did not take. Walk up from the
      // label to the first ancestor at least 250px wide: that is the track a thumb actually drags.
      const geo = await w.evaluate(() => {
        const el = [...document.querySelectorAll("*")]
          .find(e => e.children.length === 0 && /swipe to close/i.test(e.textContent || ""));
        if (!el) return null;
        let t = el; while (t && t.getBoundingClientRect().width < 250) t = t.parentElement;
        if (!t) return null;
        const r = t.getBoundingClientRect();
        return { x: r.x, y: r.y, w: r.width, h: r.height };
      });
      if (!geo) throw new Error("no 'Swipe to Close Table' control is offered on the Bill tab at all");
      const y = geo.y + geo.h / 2, x0 = geo.x + Math.min(28, geo.h / 2), x1 = geo.x + geo.w - 10;
      await w.mouse.move(x0, y); await w.mouse.down();
      for (let i = 1; i <= 20; i++) { await w.mouse.move(x0 + ((x1 - x0) * i) / 20, y); await w.waitForTimeout(40); }
      await w.mouse.up();
      await w.waitForTimeout(DWELL);
      await w.reload({ waitUntil: "domcontentloaded" });
      await w.waitForTimeout(DWELL);
      const after = await look(w, jr, "waiter", id, "after the swipe");
      rec.closedAt = Date.now();
      jr.note(`> **Board after the swipe:** ${after.slice(0, 200)}`);
      // PROVE THE CLOSE LANDED. "I performed a swipe" is not "the table closed" - and if the
      // table never closed there is no cooldown to test, so every later step would be measuring
      // nothing. The board names the empty state ("No guests"), so I can read it back.
      await w.getByTestId("tab-tables").click({ timeout: 20000 }).catch(() => {});
      await w.waitForTimeout(DWELL);
      const card = await w.getByTestId(`table-card-${TABLE}`).innerText().catch(() => "");
      const state = card.replace(/\s+/g, " ").trim();
      jr.note(`> **Table ${TABLE} on the board after the close:** "${state.slice(0, 160)}"`);
      if (!/no guests/i.test(state))
        throw new Error(`I swiped, but table ${TABLE} does not read as empty afterwards - it still `
          + `says "${state.slice(0, 160)}". Without a confirmed close there is no cooldown to test, `
          + `so I stop rather than measure nothing.`);
      rec.closedState = state;
      return `He swiped the table closed and the board now shows table ${TABLE} as "${state.slice(0, 80)}".`;
    });

    await jr.step("PATRON", `Inside the ${COOLDOWN_MIN}-minute window, a fresh scan is turned away`, async id => {
      // FRESH scan means no session id in the URL at all - that is what a QR code gives you.
      // Anant shows a WELCOME CARD ("Let's get started") to every fresh arrival. My first run
      // read that card as "not the menu" and stopped - but it is the ordinary front door, not a
      // farewell, and judging from it would have been a finding about my own impatience.
      // arriveAsDiner swipes past the welcome card and the dietary question, exactly as a finger
      // would, and THEN I look at where the diner has actually landed.
      const gates = await arriveAsDiner(p, ANANT, TABLE, "mosaic_v2");
      await p.waitForTimeout(DWELL);
      const at = await whereAmI(p);
      rec.inCooldown = at;
      jr.note(`> **Gates I had to pass through first:** ${gates.join(" · ") || "none"}`);
      await look(p, jr, "diner", id, "re-scanning during the cooldown");
      jr.note(`> **What the re-scanning diner sees:** ${at.text.slice(0, 200)}`);
      // Both directions asserted: the menu must be ABSENT and the farewell PRESENT. Asserting
      // only "no menu" would pass on a blank page or a crash.
      if (at.tiles > 0 || at.menu)
        throw new Error(`the diner was let straight back into the mosaic menu (${at.tiles} dishes on `
          + `screen) during the cooldown, when the promise is a ScanQR/farewell screen. `
          + `URL: ${at.url}`);
      if (!at.scanqr)
        throw new Error(`the menu is gone, but no ScanQR or farewell screen took its place either - `
          + `so I cannot say the diner was turned away rather than shown a broken page. `
          + `Screen reads: ${at.text}`);
      return `Turned away as promised: no menu, and the screen says "${at.text.slice(0, 120)}".`;
    });

    await jr.step("PATRON", "A reload still carrying the dead session id is turned away too", async id => {
      await p.goto(`${PATRON}/menu?id=${ANANT}&table=${TABLE}&layout=mosaic_v2&session=${rec.s1}`,
                   { waitUntil: "domcontentloaded" });
      await p.waitForTimeout(DWELL + 3000);
      // Same discipline: pass the welcome card if it is offered, then judge where I land.
      if (await p.getByText(/Let's get started/i).count()) {
        const vp = p.viewportSize();
        await p.mouse.move(vp.width / 2, vp.height * 0.82); await p.mouse.down();
        for (const f of [0.75, 0.62, 0.5, 0.38, 0.28]) { await p.mouse.move(vp.width / 2, vp.height * f); await p.waitForTimeout(70); }
        await p.mouse.up(); await p.waitForTimeout(DWELL);
      }
      const at = await whereAmI(p);
      rec.staleId = at;
      await look(p, jr, "diner", id, "reloading with the dead session id");
      jr.note(`> **With the dead session id in the URL:** ${at.text.slice(0, 200)}`);
      if (at.tiles > 0 || at.menu)
        throw new Error(`a URL carrying the CLOSED session id (${rec.s1.slice(0, 8)}…) put the diner `
          + `back into a live menu with ${at.tiles} dishes. That is the resurrection this journey `
          + `exists to prevent. URL: ${at.url}`);
      if (!at.scanqr)
        throw new Error(`no menu, but no ScanQR/farewell either - I cannot tell "turned away" from `
          + `"broken". Screen reads: ${at.text}`);
      return `Also turned away: "${at.text.slice(0, 120)}".`;
    });

    await jr.step("PATRON", `After the ${COOLDOWN_MIN} minutes are up, a fresh scan is let back in`, async id => {
      // No backdate API here - I wait it out, which is what the diner does. +40s of margin
      // because the window is measured from the server's close, not from my clock.
      const waited = (COOLDOWN_MIN * 60 + 40) * 1000 - (Date.now() - rec.closedAt);
      jr.note(`> **Waiting ${Math.max(0, Math.round(waited / 1000))}s on the wall clock** for the `
            + `cooldown to expire. The canary skips this with a backdate API call; a diner cannot.`);
      if (waited > 0) await p.waitForTimeout(waited);
      const gates2 = await arriveAsDiner(p, ANANT, TABLE, "mosaic_v2");
      await p.waitForTimeout(DWELL);
      const at = await whereAmI(p);
      rec.after = at;
      jr.note(`> **Gates on the way back in:** ${gates2.join(" · ") || "none"}`);
      rec.s2 = sessionOf(at.url);
      await look(p, jr, "diner", id, "fresh scan after the cooldown");
      jr.note(`> **After the window:** ${at.tiles} dishes · session `
            + `${rec.s2 ? rec.s2.slice(0, 8) + "…" : "NONE"} · ${at.text.slice(0, 160)}`);
      if (!at.tiles)
        throw new Error(`the cooldown expired but a fresh scan STILL cannot get in - ${at.tiles} `
          + `dishes on screen and the page says "${at.text.slice(0, 160)}". This is the ScanQR loop `
          + `the journey's own card warns about.`);
      if (!rec.s2)
        throw new Error(`the menu rendered but no new session id appeared in the URL, so the diner `
          + `is browsing without a table session. URL: ${at.url}`);
      if (rec.s2 === rec.s1)
        throw new Error(`the diner was given back the SAME session id (${rec.s1.slice(0, 8)}…) that `
          + `the waiter closed, instead of a new one.`);
      return `Let back in with a NEW session ${rec.s2.slice(0, 8)}… (the closed one was `
           + `${rec.s1.slice(0, 8)}…) and ${at.tiles} dishes on screen.`;
    });

    verdict = "HONEST_GREEN";
    why = `All three branches held on the deployed dev patron and waiter apps: turned away on a fresh `
        + `scan inside the window, turned away again on the dead session id, and let back in with a `
        + `new session once the ${COOLDOWN_MIN} minutes had passed.`;
  } catch (e) {
    why = e.message; verdict = "HONEST_RED";
  } finally {
    // RECLAIM MY OWN TABLE, whatever happened. Every failed pass of this walk used to leave its
    // diner seated, which made the NEXT pass find no free table - I poisoned my own runway six
    // times before fixing it here. Cleanup belongs in `finally`, not in the happy path.
    if (TABLE && !rec.closedState) {
      try {
        for (let i = 0; i < 6; i++) {
          const done = w.getByRole("button", { name: /^done$/i }).first();
          if (!(await done.count())) break;
          await done.click({ timeout: 12000 }).catch(() => {});
          await w.waitForTimeout(2000);
        }
        await w.getByTestId("tab-tables").click({ timeout: 15000 }).catch(() => {});
        await w.waitForTimeout(DWELL);
        await w.getByTestId(`table-card-${TABLE}`).click({ timeout: 15000 }).catch(() => {});
        await w.waitForTimeout(DWELL);
        for (let i = 0; i < 8; i++) {
          const c = w.locator('[data-testid^="cancel-item-btn-"]').first();
          if (!(await c.count())) break;
          await c.click({ timeout: 15000 }).catch(() => {});
          await w.waitForTimeout(DWELL);
          const ok = w.getByRole("button", { name: /^(remove|cancel item|confirm|yes)/i }).first();
          if (await ok.count()) { await ok.click({ timeout: 12000 }).catch(() => {}); await w.waitForTimeout(DWELL); }
        }
        await w.getByTestId("table-detail-tab-bill").click({ timeout: 15000 }).catch(() => {});
        await w.waitForTimeout(2500);
        const geo = await w.evaluate(() => {
          const el = [...document.querySelectorAll("*")]
            .find(e => e.children.length === 0 && /swipe to close/i.test(e.textContent || ""));
          if (!el) return null;
          let t = el; while (t && t.getBoundingClientRect().width < 250) t = t.parentElement;
          if (!t) return null; const r = t.getBoundingClientRect();
          return { x: r.x, y: r.y, w: r.width, h: r.height };
        });
        if (geo) {
          const y = geo.y + geo.h / 2, x0 = geo.x + Math.min(28, geo.h / 2), x1 = geo.x + geo.w - 10;
          await w.mouse.move(x0, y); await w.mouse.down();
          for (let i = 1; i <= 20; i++) { await w.mouse.move(x0 + ((x1 - x0) * i) / 20, y); await w.waitForTimeout(40); }
          await w.mouse.up(); await w.waitForTimeout(DWELL);
        }
        await w.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
        await w.waitForTimeout(DWELL);
        await w.getByTestId("tab-tables").click({ timeout: 15000 }).catch(() => {});
        await w.waitForTimeout(2500);
        rec.reclaimed = (await w.getByTestId(`table-card-${TABLE}`).innerText().catch(() => "")).replace(/\s+/g, " ").trim();
        jr.note(`> **Table ${TABLE} handed back as:** "${rec.reclaimed.slice(0, 120)}"`);
      } catch (e) {
        jr.note(`> **I could not reclaim table ${TABLE}:** ${String(e.message).slice(0, 160)}`);
      }
    }
    await closeRole(cP, jr, "diner");
    await closeRole(cW, jr, "waiter");
    jr.note(`> **Left behind:** ${rec.closedState ? `table ${TABLE} was closed by the waiter as part of `
          + `the walk` : `table ${TABLE} may still carry the guest session I opened - the close did `
          + `not complete`}. No order was placed at any point, so no bill and no payment is outstanding.`);
    jr.finish(verdict, why);
  }
});
