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

import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";

// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
// has no type definition under the Convex tsconfig (which scopes `types` to
// node), so declare the minimal shape the test relies on.
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: TestConvex<typeof schema>,
  identity: { readonly tokenIdentifier: string }
) => {
  const org = await t
    .withIdentity(identity)
    .mutation(api.organizations.ensurePersonalOrganization, {});
  return org._id as Id<"organizations">;
};

const problemStatement = {
  title: "Deploy fails on staging",
  summary: "The staging deploy command exits with a DNS resolution error.",
  desiredOutcome: "Staging deploys successfully without DNS errors.",
  constraints: ["Must not change the production pipeline"],
};

const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };

// Begin and admit one user message, returning its messageId.
const seedMessage = async (
  t: TestConvex<typeof schema>,
  identity: { readonly tokenIdentifier: string },
  orgId: Id<"organizations">,
  clientRequestId: string,
  rawText: string
): Promise<string> => {
  const begun = await t
    .withIdentity(identity)
    .mutation(api.conversationMessages.beginUserMessage, {
      clientRequestId,
      organizationId: orgId,
      rawText,
    });
  await t
    .withIdentity(identity)
    .mutation(api.conversationMessages.markAdmitted, {
      clientRequestId,
      organizationId: orgId,
      submissionId: `sub-${clientRequestId}`,
    });
  return begun.messageId;
};

const createProject = async (
  t: TestConvex<typeof schema>,
  ownerId: string
): Promise<Id<"projects">> => {
  // Resolve or create the owner's personal organization
  const ownerIdentity = { tokenIdentifier: ownerId };
  await t
    .withIdentity(ownerIdentity)
    .mutation(api.organizations.ensurePersonalOrganization);
  const outcome = await t
    .withIdentity(ownerIdentity)
    .mutation(internal.projects.persistPublicGitImport, {
      userId: ownerId,
      source: {
        host: "github.com",
        projectName: "test-repo",
        repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`,
        normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
        url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
      },
      remote: {
        defaultBranch: "main",
        documents: [
          {
            kind: "readme" as const,
            path: "README.md",
            content: "# test-repo\n\nTest.\n",
          },
        ],
        warnings: [],
      },
    });
  return outcome.id as unknown as Id<"projects">;
};

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

    await expect(
      t.mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: ["m1"],
        organizationId: orgId,
        problemStatement,
        processedBy,
      })
    ).rejects.toThrow(/Authentication required/u);
  });

  test("creates one Signal from a single admitted user message", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const rawText = "  I cannot deploy to staging  ";
    const messageId = await seedMessage(t, identityA, orgId, "req-1", rawText);

    const result = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [messageId],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });

    const composed = await t
      .withIdentity(identityA)
      .query(api.signals.get, { signalId: result.signalId });

    expect(composed).not.toBeNull();
    expect(composed?.signal.organizationId).toBe(orgId);
    expect(composed?.signal.projectId).toBeNull();
    expect(composed?.signal.conversationId).toBe(orgId);
    expect(composed?.sources).toHaveLength(1);
    expect(composed?.sources[0]?.messageId).toBe(messageId);
    // Exact raw snapshot preserved verbatim (whitespace intact).
    expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
    expect(composed?.sources[0]?.submissionId).toBe("sub-req-1");
    expect(composed?.sources[0]?.ordinal).toBe(0);
  });

  test("composes several ordered user messages into one Signal", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const m1 = await seedMessage(t, identityA, orgId, "req-a", "first message");
    const m2 = await seedMessage(
      t,
      identityA,
      orgId,
      "req-b",
      "second message"
    );
    const m3 = await seedMessage(t, identityA, orgId, "req-c", "third message");

    const result = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [m1, m2, m3],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });

    const composed = await t
      .withIdentity(identityA)
      .query(api.signals.get, { signalId: result.signalId });

    expect(composed?.sources).toHaveLength(3);
    expect(composed?.sources[0]?.messageId).toBe(m1);
    expect(composed?.sources[0]?.ordinal).toBe(0);
    expect(composed?.sources[1]?.messageId).toBe(m2);
    expect(composed?.sources[1]?.ordinal).toBe(1);
    expect(composed?.sources[2]?.messageId).toBe(m3);
    expect(composed?.sources[2]?.ordinal).toBe(2);
    expect(
      composed?.sources.map(
        (s: { rawTextSnapshot: string }) => s.rawTextSnapshot
      )
    ).toEqual(["first message", "second message", "third message"]);
  });

  test("preserves exact raw snapshots and a separate structured problem statement", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const rawText = "\n\t  leading and trailing  \n";
    const messageId = await seedMessage(
      t,
      identityA,
      orgId,
      "req-exact",
      rawText
    );

    const result = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [messageId],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });

    const composed = await t
      .withIdentity(identityA)
      .query(api.signals.get, { signalId: result.signalId });

    // Raw evidence is exact and untouched.
    expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
    // The structured problem statement is stored separately.
    expect(composed?.signal.problemStatement).toEqual(problemStatement);
  });

  test("rejects cross-organization messages", async () => {
    const t = newTest();
    const orgA = await ensureOrg(t, identityA);
    const orgB = await ensureOrg(t, identityB);
    // Message belongs to org A.
    const messageId = await seedMessage(
      t,
      identityA,
      orgA,
      "req-x",
      "secret A"
    );

    // User B tries to build a signal under org B referencing org A's message.
    await expect(
      t.withIdentity(identityB).mutation(api.signals.createFromMessages, {
        conversationId: orgB,
        messageIds: [messageId],
        organizationId: orgB,
        problemStatement,
        processedBy,
      })
    ).rejects.toThrow(/Source message not found/u);
  });

  test("rejects cross-conversation mismatch", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const messageId = await seedMessage(t, identityA, orgId, "req-conv", "hi");

    await expect(
      t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
        conversationId: "different-conversation",
        messageIds: [messageId],
        organizationId: orgId,
        problemStatement,
        processedBy,
      })
    ).rejects.toThrow(/Conversation does not belong to organization/u);
  });

  test("rejects a failed (non-admitted) message", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);

    // Begin then fail a message.
    const begun = await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.beginUserMessage, {
        clientRequestId: "req-failed",
        organizationId: orgId,
        rawText: "doomed",
      });
    await t
      .withIdentity(identityA)
      .mutation(api.conversationMessages.markFailed, {
        clientRequestId: "req-failed",
        organizationId: orgId,
      });

    await expect(
      t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [begun.messageId],
        organizationId: orgId,
        problemStatement,
        processedBy,
      })
    ).rejects.toThrow(/not admitted/u);
  });

  test("rejects a missing message id", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);

    await expect(
      t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: ["nonexistent-id"],
        organizationId: orgId,
        problemStatement,
        processedBy,
      })
    ).rejects.toThrow(/Source message not found/u);
  });

  test("rejects duplicate message ids in the selection", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const messageId = await seedMessage(t, identityA, orgId, "req-dup", "dup");

    await expect(
      t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [messageId, messageId],
        organizationId: orgId,
        problemStatement,
        processedBy,
      })
    ).rejects.toThrow(/must be unique/u);
  });

  test("rejects an empty message selection", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);

    await expect(
      t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [],
        organizationId: orgId,
        problemStatement,
        processedBy,
      })
    ).rejects.toThrow(/At least one source message is required/u);
  });

  test("idempotent retry returns the same signal without duplicate sources", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const m1 = await seedMessage(t, identityA, orgId, "req-idem-1", "a");
    const m2 = await seedMessage(t, identityA, orgId, "req-idem-2", "b");

    const first = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [m1, m2],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });

    // Retry with the same ordered selection.
    const second = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [m1, m2],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });

    expect(second.signalId).toBe(first.signalId);

    // Exactly one signal and two sources exist.
    const list = await t
      .withIdentity(identityA)
      .query(api.signals.list, { organizationId: orgId });
    expect(list).toHaveLength(1);
    expect(list[0]?.sources).toHaveLength(2);
  });

  test("different ordered selections produce different signals", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const m1 = await seedMessage(t, identityA, orgId, "req-ord-1", "x");
    const m2 = await seedMessage(t, identityA, orgId, "req-ord-2", "y");

    const first = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [m1, m2],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });
    const second = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [m2, m1],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });

    expect(second.signalId).not.toBe(first.signalId);

    const list = await t
      .withIdentity(identityA)
      .query(api.signals.list, { organizationId: orgId });
    expect(list).toHaveLength(2);
  });

  test("reversed selection preserves agent ordinals and original timestamps", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const m1 = await seedMessage(t, identityA, orgId, "req-rev-1", "first");
    const m2 = await seedMessage(t, identityA, orgId, "req-rev-2", "second");

    // Chronological selection: m1 then m2.
    const chrono = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [m1, m2],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });

    // Reversed selection: m2 then m1 (non-chronological agent order).
    const reversed = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [m2, m1],
        organizationId: orgId,
        problemStatement,
        processedBy,
      });

    expect(reversed.signalId).not.toBe(chrono.signalId);

    const chronoSignal = await t
      .withIdentity(identityA)
      .query(api.signals.get, { signalId: chrono.signalId });
    const reversedSignal = await t
      .withIdentity(identityA)
      .query(api.signals.get, { signalId: reversed.signalId });

    // Chronological: ordinals follow selection.
    expect(chronoSignal?.sources[0]?.messageId).toBe(m1);
    expect(chronoSignal?.sources[0]?.ordinal).toBe(0);
    expect(chronoSignal?.sources[1]?.messageId).toBe(m2);
    expect(chronoSignal?.sources[1]?.ordinal).toBe(1);

    // Reversed: ordinals follow the agent's reversed selection, and each
    // source retains its original creation timestamp (unchanged by reordering).
    expect(reversedSignal?.sources[0]?.messageId).toBe(m2);
    expect(reversedSignal?.sources[0]?.ordinal).toBe(0);
    expect(reversedSignal?.sources[1]?.messageId).toBe(m1);
    expect(reversedSignal?.sources[1]?.ordinal).toBe(1);

    // Exact raw text is preserved in both orderings.
    expect(
      chronoSignal?.sources.map(
        (s: { rawTextSnapshot: string }) => s.rawTextSnapshot
      )
    ).toEqual(["first", "second"]);
    expect(
      reversedSignal?.sources.map(
        (s: { rawTextSnapshot: string }) => s.rawTextSnapshot
      )
    ).toEqual(["second", "first"]);
  });

  test("creates a project-scoped signal when the project owner is an org member", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    // Project owned by user A, who is a member of orgId.
    const projectId = await createProject(t, ID_A);
    const messageId = await seedMessage(
      t,
      identityA,
      orgId,
      "req-proj",
      "build"
    );

    const result = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [messageId],
        organizationId: orgId,
        problemStatement,
        projectId,
        processedBy,
      });

    const composed = await t
      .withIdentity(identityA)
      .query(api.signals.get, { signalId: result.signalId });

    expect(composed?.signal.projectId).toBe(projectId);
  });

  test("rejects a project whose owner is not an org member (cross-tenant)", async () => {
    const t = newTest();
    const orgA = await ensureOrg(t, identityA);
    const _orgB = await ensureOrg(t, identityB);
    // Project owned by user B.
    const projectId = await createProject(t, ID_B);
    const messageId = await seedMessage(
      t,
      identityA,
      orgA,
      "req-cross-proj",
      "z"
    );

    // User A is a member of orgA but the project owner (B) is not.
    await expect(
      t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
        conversationId: orgA,
        messageIds: [messageId],
        organizationId: orgA,
        problemStatement,
        projectId,
        processedBy,
      })
    ).rejects.toThrow(/does not belong to the target organization/u);
  });

  test("authorized list returns only the organization's signals with composed sources", async () => {
    const t = newTest();
    const orgA = await ensureOrg(t, identityA);
    const orgB = await ensureOrg(t, identityB);

    const mA1 = await seedMessage(t, identityA, orgA, "req-list-1", "a1");
    const mA2 = await seedMessage(t, identityA, orgA, "req-list-2", "a2");
    await seedMessage(t, identityB, orgB, "req-list-b", "b1");

    await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
      conversationId: orgA,
      messageIds: [mA1],
      organizationId: orgA,
      problemStatement,
      processedBy,
    });
    await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
      conversationId: orgA,
      messageIds: [mA1, mA2],
      organizationId: orgA,
      problemStatement,
      processedBy,
    });

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

    expect(forA).toHaveLength(2);
    expect(forA[0]?.sources.length).toBeGreaterThan(0);
    expect(forB).toHaveLength(0);

    // Cross-org list is denied.
    await expect(
      t
        .withIdentity(identityB)
        .query(api.signals.list, { organizationId: orgA })
    ).rejects.toThrow(/Organization membership required/u);
  });

  test("authorized get denies cross-organization signal access", async () => {
    const t = newTest();
    const orgA = await ensureOrg(t, identityA);
    const _orgB = await ensureOrg(t, identityB);

    const messageId = await seedMessage(
      t,
      identityA,
      orgA,
      "req-get",
      "secret"
    );
    const result = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgA,
        messageIds: [messageId],
        organizationId: orgA,
        problemStatement,
        processedBy,
      });

    // User B cannot read org A's signal.
    await expect(
      t
        .withIdentity(identityB)
        .query(api.signals.get, { signalId: result.signalId })
    ).rejects.toThrow(/Organization membership required/u);

    // get returns null for an unknown but well-formed id when authorized.
    const other = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgA,
        messageIds: [await seedMessage(t, identityA, orgA, "req-ghost", "g")],
        organizationId: orgA,
        problemStatement,
        processedBy,
      });
    expect(other.signalId).not.toBe(result.signalId);
  });
});
