// web/src/items/useItemsPresence.ts — the gate that decides whether Builds,
// Stock adjustments and the two inventory report tabs exist at all
// (Inventory Task 5).
//
// This is tested because the FIRST attempt at it was wrong in a way that read
// as right. The Sidebar never unmounts, so a per-hook fetch froze the answer
// for the whole session; keying it on the current route looked like a fix and
// was not, because creating an item on #items does not change the route. The
// bug the comment claimed to have fixed was still there. What follows pins the
// behaviour rather than the implementation: after the item list changes, a
// subscriber that never re-mounted sees the new answer.
//
// No DOM and no React renderer in this repo's test setup, so the store is
// driven through its module surface (`itemsChanged`, `__resetItemsPresence`)
// with `fetch` stubbed — which is exactly the seam a Sidebar or a Reports tab
// consumes.

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
  itemsChanged,
  itemsPresenceFor,
  useItemsPresence,
  showsInventoryValuation,
  showsItemProfitability,
  __resetItemsPresence,
} from "../../web/src/items/useItemsPresence.js";

const COMPANY = "11111111-1111-1111-1111-111111111111";

type Item = { id: string; kind: string };

let served: Item[] = [];
/** What the server says about "has this company ever held stock" — the report
 *  gates' input, independent of the active item list above. */
let servedEverHeldStock = false;
let calls = 0;
let originalFetch: typeof globalThis.fetch;

/** Minimal stand-in for lib/api.ts's fetch: it only ever GETs /api/c/items. */
function stubFetch(): void {
  originalFetch = globalThis.fetch;
  globalThis.fetch = (async (path: unknown) => {
    calls += 1;
    expect(String(path)).toBe("/api/c/items");
    return {
      ok: true,
      status: 200,
      text: async () => JSON.stringify({ items: served, everHeldStock: servedEverHeldStock }),
    } as unknown as Response;
  }) as typeof globalThis.fetch;
}

/** Lets the store's promise chain (fetch -> then -> finally) settle. */
async function settle(): Promise<void> {
  for (let i = 0; i < 5; i += 1) await Promise.resolve();
  await new Promise((r) => setTimeout(r, 0));
}

/**
 * Stands in for a mounted consumer — the Sidebar, or a Reports tab. The hook
 * itself is `itemsPresenceFor` plus a subscription; a renderer supplies the
 * subscription, and what is worth pinning is what the store says.
 */
function mountConsumer(companyId: string) {
  itemsChanged(companyId);
  return { read: () => readPresence(companyId) };
}

const readPresence = itemsPresenceFor;

beforeEach(() => {
  __resetItemsPresence();
  served = [];
  servedEverHeldStock = false;
  calls = 0;
  stubFetch();
});

afterEach(() => {
  globalThis.fetch = originalFetch;
  __resetItemsPresence();
});

describe("useItemsPresence — what the gate answers", () => {
  it("says no stock for a company with no items", async () => {
    served = [];
    itemsChanged(COMPANY);
    await settle();
    expect(readPresence(COMPANY)).toEqual({
      hasItems: false, hasStockedItems: false, everHeldStock: false, loading: false,
    });
  });

  it("separates 'has items' from 'has STOCKED items'", async () => {
    // A consultancy that bills through service items: profitability is worth
    // showing, an inventory valuation is not.
    served = [{ id: "1", kind: "service" }, { id: "2", kind: "non_inventory" }];
    itemsChanged(COMPANY);
    await settle();
    expect(readPresence(COMPANY)).toEqual({
      hasItems: true, hasStockedItems: false, everHeldStock: false, loading: false,
    });

    served = [...served, { id: "3", kind: "inventory" }];
    itemsChanged(COMPANY);
    await settle();
    expect(readPresence(COMPANY)).toMatchObject({ hasItems: true, hasStockedItems: true });
  });

  it("answers a company it has never heard of with loading, not with 'no'", async () => {
    // The distinction matters: a gate that renders "no" while it is still
    // asking would flash two nav entries into existence a moment later.
    expect(readPresence("22222222-2222-2222-2222-222222222222").loading).toBe(true);
  });
});

describe("useItemsPresence — refreshing on the mutation, not on navigation", () => {
  it("picks up the FIRST stocked item without anything re-mounting", async () => {
    served = [];
    itemsChanged(COMPANY);
    await settle();
    expect(readPresence(COMPANY).hasStockedItems).toBe(false);

    // The user adds their first stocked item on the Items screen. No route
    // change, no unmount — this is the exact scenario the route-keyed version
    // silently failed.
    served = [{ id: "1", kind: "inventory" }];
    itemsChanged(COMPANY);
    await settle();

    expect(readPresence(COMPANY).hasStockedItems).toBe(true);
  });

  it("takes the entries away again when the last stocked item is turned off", async () => {
    served = [{ id: "1", kind: "inventory" }];
    itemsChanged(COMPANY);
    await settle();
    expect(readPresence(COMPANY).hasStockedItems).toBe(true);

    // Deactivated items are excluded by the API's own default, so the list
    // simply comes back without it.
    served = [];
    itemsChanged(COMPANY);
    await settle();
    expect(readPresence(COMPANY).hasStockedItems).toBe(false);
  });

  it("notifies every subscriber, not just the one that asked", async () => {
    const seen: boolean[] = [];
    const notify = () => seen.push(readPresence(COMPANY).hasStockedItems);
    // Two consumers, as in the app: the Sidebar and a Reports tab.
    const consumerA = mountConsumer(COMPANY);
    const consumerB = mountConsumer(COMPANY);
    await settle();

    served = [{ id: "1", kind: "inventory" }];
    itemsChanged(COMPANY);
    await settle();
    notify();

    expect(consumerA.read().hasStockedItems).toBe(true);
    expect(consumerB.read().hasStockedItems).toBe(true);
    expect(seen).toEqual([true]);
  });

  it("shares one request when several consumers ask at once", async () => {
    served = [{ id: "1", kind: "inventory" }];
    // Three mounts in the same tick must not be three round trips.
    itemsChanged(COMPANY);
    itemsChanged(COMPANY);
    itemsChanged(COMPANY);
    await settle();
    expect(calls).toBe(1);
  });

  it("keeps companies apart", async () => {
    const OTHER = "33333333-3333-3333-3333-333333333333";
    served = [{ id: "1", kind: "inventory" }];
    itemsChanged(COMPANY);
    await settle();
    served = [];
    itemsChanged(OTHER);
    await settle();

    expect(readPresence(COMPANY).hasStockedItems).toBe(true);
    expect(readPresence(OTHER).hasStockedItems).toBe(false);
  });

  it("degrades to 'no extra screens' rather than throwing when the fetch fails", async () => {
    globalThis.fetch = (async () => {
      throw new Error("network down");
    }) as typeof globalThis.fetch;
    itemsChanged(COMPANY);
    await settle();
    expect(readPresence(COMPANY)).toEqual({
      hasItems: false, hasStockedItems: false, everHeldStock: false, loading: false,
    });
  });
});

describe("itemsChanged", () => {
  it("with no company, refreshes every company already known", async () => {
    const OTHER = "44444444-4444-4444-4444-444444444444";
    served = [];
    itemsChanged(COMPANY);
    itemsChanged(OTHER);
    await settle();
    expect(readPresence(COMPANY).hasStockedItems).toBe(false);

    served = [{ id: "1", kind: "inventory" }];
    itemsChanged();
    await settle();
    expect(readPresence(COMPANY).hasStockedItems).toBe(true);
    expect(readPresence(OTHER).hasStockedItems).toBe(true);
  });

  it("does not invent a company it has never seen", async () => {
    served = [{ id: "1", kind: "inventory" }];
    itemsChanged();
    await settle();
    expect(calls).toBe(0);
  });
});

// Guards against the hook quietly regaining a second parameter: the route-keyed
// signature ("refetch when the URL changes, and hope the user navigates after
// adding an item") is the mistake this module exists to have stopped making.
describe("useItemsPresence — signature", () => {
  it("takes a company and nothing else", () => {
    expect(useItemsPresence.length).toBe(1);
  });
});

// ===========================================================================
// The report gates — the bug this section exists for.
//
// An item that has ever moved can NEVER be deleted, only deactivated. Retire a
// product with 40 units and $100.00 of value on the shelf and the company is
// still carrying that $100.00 on its balance sheet — while an active-only gate
// removes the Inventory valuation tab AND (Reports.tsx's redirect effect)
// pushes anyone standing on it back to Profit & loss. The one report that
// explains the balance disappears exactly when it is the only thing that would.
//
// So nav and pickers keep the narrow predicate and the REPORTS ask a wider
// question: has this company ever held stock at all (server/src/items.ts's
// everHeldStock — any inventory movement, or an inventory account carrying a
// balance). These are the pure predicates Reports.tsx calls.
// ===========================================================================

describe("report gates — a deactivated item does not hide live value", () => {
  it("keeps the valuation report after the last stocked item is turned off", async () => {
    served = [{ id: "1", kind: "inventory" }];
    servedEverHeldStock = true;
    itemsChanged(COMPANY);
    await settle();
    expect(showsInventoryValuation(readPresence(COMPANY))).toBe(true);

    // The product is retired. The API's active-only default stops returning
    // it — but the stock history, and the balance-sheet value it explains,
    // are still there.
    served = [];
    itemsChanged(COMPANY);
    await settle();

    const presence = readPresence(COMPANY);
    expect(presence.hasStockedItems).toBe(false);
    expect(presence.everHeldStock).toBe(true);
    expect(showsInventoryValuation(presence)).toBe(true);
    expect(showsItemProfitability(presence)).toBe(true);
  });

  it("still shows NOTHING to a company that has never held stock", async () => {
    // The gate's whole point survives: a service business is not handed two
    // dead report tabs just because the predicate got wider.
    served = [];
    servedEverHeldStock = false;
    itemsChanged(COMPANY);
    await settle();

    const presence = readPresence(COMPANY);
    expect(showsInventoryValuation(presence)).toBe(false);
    expect(showsItemProfitability(presence)).toBe(false);
  });

  it("shows profitability but not valuation for a service business with items", async () => {
    served = [{ id: "1", kind: "service" }];
    servedEverHeldStock = false;
    itemsChanged(COMPANY);
    await settle();

    const presence = readPresence(COMPANY);
    expect(showsInventoryValuation(presence)).toBe(false);
    expect(showsItemProfitability(presence)).toBe(true);
  });

  it("shows nothing for a company still being asked about", () => {
    // loading: true, everything false — the gates must not flash a tab into
    // existence and then take it away.
    const presence = readPresence("55555555-5555-5555-5555-555555555555");
    expect(presence.loading).toBe(true);
    expect(showsInventoryValuation(presence)).toBe(false);
    expect(showsItemProfitability(presence)).toBe(false);
  });

  it("treats a failed fetch as 'no extra tabs', never as 'ever held stock'", async () => {
    globalThis.fetch = (async () => {
      throw new Error("network down");
    }) as typeof globalThis.fetch;
    itemsChanged(COMPANY);
    await settle();

    const presence = readPresence(COMPANY);
    expect(presence.everHeldStock).toBe(false);
    expect(showsInventoryValuation(presence)).toBe(false);
  });
});
