/* WITNESS · mosaic_v2-closed-resume (W-032) + mosaic_v2-cold-start-rescan (W-034)
 * plus one small verification I owe from W-060.                        — QRATE-UAT-1
 *
 * Both journeys turn on the same hinge: a member of STAFF closes the table while the diner is
 * away, and the diner comes back to find the session gone. So the diner half is walkable and
 * the hinge is not, unless a screen exists that closes a table.
 *
 * SUBSTITUTION: neither journey names its own fixture, and there is no "closed resume" canary
 * restaurant. I use Canary Mosaic V2 Dining, the generic mosaic_v2 dining fixture, and say so.
 *
 * I place no orders in either walk - only a cart - so the only thing left behind is an open
 * guest session on a table, which I record.
 */
import { test, devices } from "@playwright/test";
import { journal, look, roleContext, deskContext, closeRole, signInOwner, account,
         arriveAsDiner, PATRON, OWNER, DWELL, PHONE } from "../lib-witness.mjs";
import fs from "node:fs";

const F = JSON.parse(fs.readFileSync("/workspace/uat/runs/wave0/fixtures.json", "utf8"));
const RID = F["mosaic_v2-dining"];
const OUT = "/workspace/uat/wave1/session-recon";

const tap = async (page, sel) => {
  const l = page.locator(sel).first();
  if (!(await l.count())) return false;
  await l.click({ timeout: 10000 }).catch(async () => { await l.evaluate(el => el.click()); });
  return true;
};
const shape = async page => ({
  tiles: await page.locator('[data-testid^="m2-tile-"]').count(),
  cart: (await page.locator('[data-testid="m2-dock-cart-count"]').first().innerText().catch(() => "")).trim(),
  live: await page.locator('[data-testid="table-session-active"]').count(),
  masonry: await page.locator('[data-testid="m2-masonry"]').count(),
  testids: await page.evaluate(() => [...new Set([...document.querySelectorAll("[data-testid]")].map(e => e.getAttribute("data-testid")))].sort()),
});
async function addOne(page) {
  await page.locator('[data-testid^="m2-tile-"]').first().evaluate(el => el.click());
  await page.waitForTimeout(DWELL);
  await page.locator('[data-testid="composition-add"]').first().click({ timeout: 20000 });
  await page.waitForTimeout(DWELL);
  await tap(page, '[data-testid="composition-close"], [data-testid="m2-composition-close"]');
  await page.waitForTimeout(1500);
}
/** Every way an owner might close a table, hunted across the whole owner app. */
async function huntForClose(o, jr, id, rid) {
  const rows = [];
  for (const path of ["/owner/host-station/", "/owner/experience/", "/owner/tables/", "/owner/orders/", "/owner/dashboard/"]) {
    await o.goto(`${OWNER}${path}?restaurantId=${rid}`, { waitUntil: "domcontentloaded" }).catch(() => {});
    await o.waitForTimeout(4000);
    const found = await o.evaluate(() => [...document.querySelectorAll("button,[role=button],a[href]")]
      .filter(e => e.offsetParent !== null)
      .map(e => ({ t: (e.innerText || "").replace(/\s+/g, " ").trim().slice(0, 34), tid: e.getAttribute("data-testid"), aria: e.getAttribute("aria-label") }))
      .filter(c => /close table|end session|close session|clear table|free table|check ?out|reset table/i.test(`${c.t} ${c.tid || ""} ${c.aria || ""}`)));
    rows.push({ path, found: found.map(f => `"${f.t}"${f.tid ? `(${f.tid})` : ""}${f.aria ? `[${f.aria}]` : ""}`) });
    await look(o, jr, "owner", `${id}-${path.replace(/\W+/g, "-").slice(0, 16)}`, `looking on ${path}`);
  }
  return rows;
}

test("mosaic_v2-closed-resume", async ({ browser }) => {
  const TABLE = 9954;
  const jr = journal("mosaic_v2-closed-resume");
  jr.target({ app: "patron+owner", patronUrl: PATRON, ownerUrl: OWNER, restaurantId: RID, table: TABLE,
              fixture: "Canary Mosaic V2 Dining — dev (SUBSTITUTE: this journey names no fixture of its own)" });
  jr.note("_I am at a table. My phone goes in my pocket and loses signal. While it is away, staff "
        + "close my table. When I take my phone out again the app should tell me the meal is over - "
        + "not silently carry on._");

  const pc = await roleContext(browser, jr, "patron");
  const p = await pc.newPage();
  const oc = await deskContext(browser, jr, "owner");
  const o = await oc.newPage();
  const rec = {};
  try {
    await jr.step("PATRON", "I scan the code and the menu loads on a live table", async id => {
      await arriveAsDiner(p, RID, TABLE);
      const t = await look(p, jr, "patron", id, "sat down");
      rec.arrival = await shape(p);
      if (!rec.arrival.tiles) throw new Error(`Nothing on the menu at this restaurant. Screen: "${t.slice(0, 150)}"`);
      if (!rec.arrival.live) throw new Error("The app does not show me as being at a live table, so there is no session to close.");
      await addOne(p);
      rec.arrival.cart = (await p.locator('[data-testid="m2-dock-cart-count"]').first().innerText().catch(() => "")).trim();
      await look(p, jr, "patron", id, "one in my cart");
      return `${rec.arrival.tiles} dishes, the app shows a live table session, and my cart reads ${rec.arrival.cart}.`;
    });

    await jr.step("PATRON", "My phone goes in my pocket — screen off, no signal", async id => {
      await pc.setOffline(true);
      await p.evaluate(() => {
        Object.defineProperty(document, "visibilityState", { get: () => "hidden", configurable: true });
        Object.defineProperty(document, "hidden", { get: () => true, configurable: true });
        document.dispatchEvent(new Event("visibilitychange"));
      });
      await p.waitForTimeout(15000);
      rec.backgrounded = true;
      return "The tab is hidden and the network is cut, which is what a phone in a pocket looks like to the app.";
    });

    await jr.step("OWNER", "Meanwhile staff close my table — I go and find that control", async id => {
      const a = account("canaryOwner");
      await signInOwner(o, a.email, a.password, RID);
      rec.closeHunt = await huntForClose(o, jr, id, RID);
      fs.writeFileSync(`${OUT}/w032-closehunt.json`, JSON.stringify(rec.closeHunt, null, 1));
      jr.note("> **Every owner screen searched for a way to close a table:**\n>\n> - "
            + rec.closeHunt.map(r => `\`${r.path}\` → ${r.found.length ? r.found.join(", ") : "nothing that closes a table"}`).join("\n> - "));
      const any = rec.closeHunt.some(r => r.found.length);
      if (!any)
        throw new Error("There is no control anywhere in the owner app that closes a table. Host Station "
          + "renders a different restaurant entirely and offers only Orders and Service on an occupied table; "
          + "Tables/Staff reports '0 No tables found'; /owner/tables/ offers only a destructive 'Delete table'. "
          + "The waiter app, which has the swipe-to-close control, shows 'No tables assigned' for every canary "
          + "restaurant with the only staff login this container holds. So the hinge of this journey cannot be turned.");
      return `Found: ${rec.closeHunt.filter(r => r.found.length).map(r => `${r.path} → ${r.found.join(",")}`).join(" ; ")}`;
    });

    jr.finish("WALKED", "walked the whole thing");
  } catch (e) {
    const why = String(e.message || e).split("\n")[0];
    /* Even though the table was never closed, I still take my phone out again and record what
       the app does - so the "resume" half is described rather than merely skipped. */
    await jr.soft("PATRON", "I take my phone out again (my table was NEVER closed, so this is only the control case)", async id => {
      await pc.setOffline(false);
      await p.evaluate(() => {
        Object.defineProperty(document, "visibilityState", { get: () => "visible", configurable: true });
        Object.defineProperty(document, "hidden", { get: () => false, configurable: true });
        document.dispatchEvent(new Event("visibilitychange"));
      });
      await p.waitForTimeout(DWELL + 8000);
      const t = await look(p, jr, "patron", id, "phone out of my pocket again");
      rec.resume = await shape(p);
      const gone = rec.arrival.testids.filter(x => !rec.resume.testids.includes(x));
      const appeared = rec.resume.testids.filter(x => !rec.arrival.testids.includes(x));
      jr.note(`> **What changed on screen while my phone was away:** +[${appeared.slice(0, 12).join(", ") || "nothing"}] −[${gone.slice(0, 12).join(", ") || "nothing"}]`);
      return `Menu still there (${rec.resume.tiles} dishes), live table session ${rec.resume.live ? "still shown" : "GONE"}, cart reads "${rec.resume.cart}". `
           + `This is the un-closed control case only - it says nothing about what happens after a real close.`;
    });
    jr.finish("STOPPED", why);
  } finally {
    fs.writeFileSync(`${OUT}/w032.json`, JSON.stringify(rec, null, 1));
    await closeRole(pc, jr, "patron");
    await closeRole(oc, jr, "owner");
  }
});

test("mosaic_v2-cold-start-rescan", async ({ browser }) => {
  const TABLE = 9955;
  const jr = journal("mosaic_v2-cold-start-rescan");
  jr.target({ app: "patron+owner", patronUrl: PATRON, ownerUrl: OWNER, restaurantId: RID, table: TABLE,
              fixture: "Canary Mosaic V2 Dining — dev (SUBSTITUTE)" });
  jr.note("_I scan, I put something in my basket, then I close the browser completely. When I "
        + "open it again on the same phone and scan the same code, I should be back in the same "
        + "meal with my basket intact. After staff close the table, the same act should NOT let "
        + "me back in - it should start me afresh._");

  const pc = await roleContext(browser, jr, "patron");
  const p = await pc.newPage();
  const oc = await deskContext(browser, jr, "owner");
  const o = await oc.newPage();
  const rec = {};
  let pc2 = null;
  try {
    await jr.step("PATRON", "I scan, and I put something in my basket", async id => {
      await arriveAsDiner(p, RID, TABLE);
      const t = await look(p, jr, "patron", id, "first visit");
      rec.first = await shape(p);
      if (!rec.first.tiles) throw new Error(`Nothing on this menu. Screen: "${t.slice(0, 150)}"`);
      await addOne(p);
      rec.first.cart = (await p.locator('[data-testid="m2-dock-cart-count"]').first().innerText().catch(() => "")).trim();
      await look(p, jr, "patron", id, "basket has one thing in it");
      if (!rec.first.cart || rec.first.cart === "0") throw new Error("Nothing landed in the basket, so there is nothing to come back to.");
      return `${rec.first.tiles} dishes; my basket reads ${rec.first.cart}; the app shows a live table session (${rec.first.live}).`;
    });

    await jr.step("PATRON", "I close the browser completely, then open it again and re-scan", async id => {
      /* A cold start is the SAME PHONE with the app shut down - so the device's stored data
         comes with me. A brand new empty context would be a different phone, and would prove
         nothing about resuming. */
      const state = await pc.storageState();
      await p.close();
      const d = devices[PHONE];
      pc2 = await browser.newContext({ ...d, storageState: state,
        recordVideo: { dir: `${jr.dir}/patron-again`, size: { width: d.viewport.width * 2, height: d.viewport.height * 2 } } });
      jr.device("patron-again", PHONE, d, browser);
      const p2 = await pc2.newPage();
      await p2.goto(`${PATRON}/menu?id=${RID}&table=${TABLE}`, { waitUntil: "domcontentloaded" });
      await p2.waitForTimeout(DWELL + 6000);
      const t = await look(p2, jr, "patron-again", id, "back after a cold start");
      rec.second = await shape(p2);
      rec.p2 = true;
      if (!rec.second.tiles) throw new Error(`The menu did not come back. Screen: "${t.slice(0, 200)}"`);
      const same = rec.second.cart === rec.first.cart;
      if (!same)
        throw new Error(`My basket did not come back with me: it read "${rec.first.cart}" before and "${rec.second.cart || "empty"}" now.`);
      return `I am back in: ${rec.second.tiles} dishes, live table session ${rec.second.live ? "shown" : "not shown"}, `
           + `and my basket still reads "${rec.second.cart}" - the same as before I shut the browser.`;
    });

    await jr.step("OWNER", "Now staff close the session — I go and find that control", async id => {
      const a = account("canaryOwner");
      await signInOwner(o, a.email, a.password, RID);
      rec.closeHunt = await huntForClose(o, jr, id, RID);
      jr.note("> **Every owner screen searched for a way to close the session:**\n>\n> - "
            + rec.closeHunt.map(r => `\`${r.path}\` → ${r.found.length ? r.found.join(", ") : "nothing that closes a table"}`).join("\n> - "));
      if (!rec.closeHunt.some(r => r.found.length))
        throw new Error("No control in the owner app closes a table session, so the negative half of this "
          + "journey - cold start BLOCKED after a close, and a rescan starting a fresh session - cannot be reached.");
      return `Found: ${rec.closeHunt.filter(r => r.found.length).map(r => r.path).join(", ")}`;
    });

    jr.finish("WALKED", "walked the whole thing");
  } catch (e) {
    jr.finish("STOPPED", String(e.message || e).split("\n")[0]);
  } finally {
    fs.writeFileSync(`${OUT}/w034.json`, JSON.stringify(rec, null, 1));
    if (pc2) await closeRole(pc2, jr, "patron-again");
    await closeRole(pc, jr, "patron");
    await closeRole(oc, jr, "owner");
  }
});

test("w060-word-check", async ({ browser }) => {
  /* I owe this: my chaos sweep matched the words "chaos" and "fault" on owner screens for the
     Place Order Chaos restaurant. Both are probably innocent - the restaurant's own NAME
     contains "Chaos", and "default" contains "fault". Reporting a chaos switch on that basis
     would be a false finding, so I read the actual context of every match. */
  const jr = journal("w060-word-check");
  jr.target({ app: "owner", url: OWNER, restaurantId: F["mosaic_v2-place-order-chaos"] });
  jr.note("**Verification only.** Disambiguating two regex matches before they become a claim.");
  const oc = await deskContext(browser, jr, "owner");
  const o = await oc.newPage();
  try {
    await jr.step("OWNER", "I read the context around every chaos/fault match", async id => {
      const a = account("canaryOwner");
      await signInOwner(o, a.email, a.password, F["mosaic_v2-place-order-chaos"]);
      const out = {};
      for (const path of ["/owner/menu/", "/owner/dashboard/"]) {
        await o.goto(`${OWNER}${path}?restaurantId=${F["mosaic_v2-place-order-chaos"]}`, { waitUntil: "domcontentloaded" });
        await o.waitForTimeout(5000);
        out[path] = await o.evaluate(() => {
          const t = document.body.innerText.replace(/\s+/g, " ");
          const hits = [];
          for (const m of t.matchAll(/chaos|fault|inject|http_?500|drop_?ack|silent_?hang/gi))
            hits.push(t.slice(Math.max(0, m.index - 45), m.index + 45));
          return [...new Set(hits)].slice(0, 12);
        });
        await look(o, jr, "owner", `${id}-${path.replace(/\W+/g, "-")}`, `context on ${path}`);
      }
      fs.writeFileSync(`${OUT}/w060-wordcheck.json`, JSON.stringify(out, null, 1));
      jr.note("> **Context of every match:**\n>\n> - "
            + Object.entries(out).map(([k, v]) => `\`${k}\`\n>   - ` + v.map(x => `"…${x}…"`).join("\n>   - ")).join("\n> - "));
      return Object.entries(out).map(([k, v]) => `${k}: ${v.length} match(es)`).join(" ; ");
    });
    jr.finish("RECON", "done");
  } catch (e) { jr.finish("STOPPED", String(e.message || e).split("\n")[0]); }
  finally { await closeRole(oc, jr, "owner"); }
});
