import { convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";

import { requireOrganizationMember } from "./authz";
import schema from "./schema";

declare global {
  interface ImportMeta {
    readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
  }
}

const modules = import.meta.glob("./**/*.ts");
const api = anyApi;

const ID_A = "https://convex.test|user-a";
const ID_B = "https://convex.test|user-b";

const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };

const newTest = () => convexTest({ schema, modules });

const ensureOrg = async (
  t: ReturnType<typeof newTest>,
  identity: { readonly tokenIdentifier: string }
) => {
  const org = await t
    .withIdentity(identity)
    .mutation(api.organizations.ensurePersonalOrganization, {});
  return org._id as string;
};

describe("conversationMessages", () => {
  test("unauthenticated access is rejected", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);

    await expect(
      t.mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-1",
        organizationId: orgId,
        rawText: "hello",
      })
    ).rejects.toThrow(/Authentication required/u);

    await expect(
      t.mutation(api.conversationMessages.markAdmitted, {
        clientRequestId: "req-1",
        organizationId: orgId,
        submissionId: "sub-1",
      })
    ).rejects.toThrow(/Authentication required/u);

    await expect(
      t.query(api.conversationMessages.getByRequest, {
        clientRequestId: "req-1",
        organizationId: orgId,
      })
    ).rejects.toThrow(/Authentication required/u);
  });

  test("cross-organization access is denied for begin and observation", async () => {
    const t = newTest();
    const orgA = await ensureOrg(t, identityA);
    const _orgB = await ensureOrg(t, identityB);

    // User A captures a message in its own organization.
    const created = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-cross",
        organizationId: orgA,
        rawText: "private to org A",
      });

    // User B cannot begin a message under organization A.
    await expect(
      t
        .withIdentity(identityB)
        .mutation(api.conversationMessages.beginUserMessage, {
          clientRequestId: "req-cross",
          organizationId: orgA,
          rawText: "intruder",
        })
    ).rejects.toThrow(/Organization membership required/u);

    // User B cannot observe organization A's message by request id.
    await expect(
      t.withIdentity(identityB).query(api.conversationMessages.getByRequest, {
        clientRequestId: "req-cross",
        organizationId: orgA,
      })
    ).rejects.toThrow(/Organization membership required/u);

    // User B cannot list organization A's conversation.
    await expect(
      t
        .withIdentity(identityB)
        .query(api.conversationMessages.listForCurrentOrganization, {
          organizationId: orgA,
        })
    ).rejects.toThrow(/Organization membership required/u);

    // The membership boundary independently denies cross-org access.
    await expect(
      t
        .withIdentity(identityB)
        .query((ctx) => requireOrganizationMember(ctx, orgA as never))
    ).rejects.toThrow(/Organization membership required/u);

    expect(created.status).toBe("admitting");
  });

  test("duplicate request id is idempotent and preserves exact whitespace and casing", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const rawText = "   Hello   WORLD  \n\t with  spaces  ";

    const first = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-dup",
        organizationId: orgId,
        rawText,
      });

    // A second begin with the same request id returns the same row — no
    // duplicate is created.
    const second = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-dup",
        organizationId: orgId,
        rawText: "DIFFERENT TEXT MUST BE IGNORED",
      });

    expect(second._id).toBe(first._id);
    expect(second.messageId).toBe(first.messageId);
    expect(second.rawText).toBe(rawText);
    expect(second.clientRequestId).toBe("req-dup");
    expect(second.role).toBe("user");
    expect(second.status).toBe("admitting");

    // The conversation id equals the organization id (global agent scope).
    expect(first.conversationId).toBe(orgId);
  });

  test("admitted transition records the submission id", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);

    const begun = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-admit",
        organizationId: orgId,
        rawText: "please help",
      });
    expect(begun.status).toBe("admitting");
    expect(begun.submissionId).toBeNull();

    const admitted = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.markAdmitted, {
        clientRequestId: "req-admit",
        organizationId: orgId,
        submissionId: "sub-receipt-123",
      });

    expect(admitted.status).toBe("admitted");
    expect(admitted.submissionId).toBe("sub-receipt-123");

    // Marking admitted again is idempotent and keeps the same submission id.
    const again = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.markAdmitted, {
        clientRequestId: "req-admit",
        organizationId: orgId,
        submissionId: "sub-receipt-123",
      });
    expect(again.status).toBe("admitted");
    expect(again.submissionId).toBe("sub-receipt-123");
  });

  test("failed transition records the failed status and preserves evidence", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);

    await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-fail",
        organizationId: orgId,
        rawText: "doomed message",
      });

    const failed = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.markFailed, {
        clientRequestId: "req-fail",
        organizationId: orgId,
      });

    expect(failed.status).toBe("failed");
    expect(failed.submissionId).toBeNull();
    expect(failed.rawText).toBe("doomed message");

    // A failed message is still observable as evidence.
    const observed = await t
      .withIdentity(identityA)
      .query(api.conversationMessages.getByRequest, {
        clientRequestId: "req-fail",
        organizationId: orgId,
      });
    expect(observed?.status).toBe("failed");
    expect(observed?.rawText).toBe("doomed message");
  });

  test("admission wins over a concurrent failure and cannot be overwritten", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);

    const begun = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-race",
        organizationId: orgId,
        rawText: "race",
      });

    // Admission completes first.
    await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.markAdmitted, {
        clientRequestId: "req-race",
        organizationId: orgId,
        submissionId: "sub-won",
      });

    // A late failure is a no-op: an admitted message keeps its submission id.
    const afterFail = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.markFailed, {
        clientRequestId: "req-race",
        organizationId: orgId,
      });
    expect(afterFail.status).toBe("admitted");
    expect(afterFail.submissionId).toBe("sub-won");

    expect(begun.status).toBe("admitting");
  });

  test("list returns only the authenticated organization's messages", async () => {
    const t = newTest();
    const orgA = await ensureOrg(t, identityA);
    const orgB = await ensureOrg(t, identityB);

    await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-a1",
        organizationId: orgA,
        rawText: "a-one",
      });
    await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-a2",
        organizationId: orgA,
        rawText: "a-two",
      });
    await t
      .withIdentity(identityB)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-b1",
        organizationId: orgB,
        rawText: "b-one",
      });

    const forA = await t
      .withIdentity(identityA)
      .query(api.conversationMessages.listForCurrentOrganization, {
        organizationId: orgA,
      });
    const forB = await t
      .withIdentity(identityB)
      .query(api.conversationMessages.listForCurrentOrganization, {
        organizationId: orgB,
      });

    expect(forA).toHaveLength(2);
    expect(
      forA.map((m: { clientRequestId: string }) => m.clientRequestId)
    ).toEqual(expect.arrayContaining(["req-a1", "req-a2"]));
    expect(forB).toHaveLength(1);
    expect(forB[0]?.clientRequestId).toBe("req-b1");
  });
});
