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";

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" };

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,
  suffix: string
): Promise<Id<"projects">> => {
  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-${suffix}`,
        repositoryPath: `owner-${suffix}/test-repo`,
        normalizedUrl: `https://github.com/owner-${suffix}/test-repo`,
        url: `https://github.com/owner-${suffix}/test-repo`,
      },
      remote: {
        defaultBranch: "main",
        documents: [
          {
            kind: "readme" as const,
            path: "README.md",
            content: "# test\n",
          },
        ],
        warnings: [],
      },
    });
  return outcome.id as unknown as Id<"projects">;
};

// ---------------------------------------------------------------------------
// Helpers for creating signals and attachments via the DB directly (the
// query under test is read-only; we seed through mutations/DB).
// ---------------------------------------------------------------------------

const createSignalInProject = async (
  t: TestConvex<typeof schema>,
  identity: { readonly tokenIdentifier: string },
  orgId: Id<"organizations">,
  projectId: Id<"projects">,
  msgSuffix: string,
  ps: typeof problemStatement
): Promise<Id<"signals">> => {
  const messageId = await seedMessage(
    t,
    identity,
    orgId,
    `req-${msgSuffix}`,
    `evidence ${msgSuffix}`
  );
  const result = await t
    .withIdentity(identity)
    .mutation(api.signals.createFromMessages, {
      conversationId: orgId,
      messageIds: [messageId],
      organizationId: orgId,
      problemStatement: ps,
      projectId,
      processedBy,
    });
  return result.signalId;
};

/** Insert a signalIssueAttachment directly via the test DB. */
const linkSignalToIssue = async (
  t: TestConvex<typeof schema>,
  signalId: Id<"signals">,
  issueId: Id<"projectIssues">,
  organizationId: Id<"organizations">,
  projectId: Id<"projects">
): Promise<void> => {
  await t.mutation((ctx) =>
    ctx.db.insert("signalIssueAttachments", {
      createdAt: Date.now(),
      issueId,
      organizationId,
      projectId,
      signalId,
    })
  );
};

const createIssue = async (
  t: TestConvex<typeof schema>,
  identity: { readonly tokenIdentifier: string },
  projectId: Id<"projects">,
  title: string
): Promise<Id<"projectIssues">> =>
  t.withIdentity(identity).mutation(api.projectIssues.create, {
    body: "A representative issue body long enough to pass validation",
    projectId,
    title,
  });

describe("listLinkedSignalsForProject", () => {
  test("groups linked signals by issue with correct source counts", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const projectId = await createProject(t, ID_A, "group");

    const issue1 = await createIssue(t, identityA, projectId, "Issue one");
    const issue2 = await createIssue(t, identityA, projectId, "Issue two");

    // Signal 1: one source, linked to issue1
    const sig1 = await createSignalInProject(
      t,
      identityA,
      orgId,
      projectId,
      "s1",
      problemStatement
    );
    await linkSignalToIssue(t, sig1, issue1, orgId, projectId);

    // Signal 2: one source, linked to issue2
    const sig2 = await createSignalInProject(
      t,
      identityA,
      orgId,
      projectId,
      "s2",
      {
        ...problemStatement,
        title: "Different signal",
      }
    );
    await linkSignalToIssue(t, sig2, issue2, orgId, projectId);

    const result = await t
      .withIdentity(identityA)
      .query(api.signals.listLinkedSignalsForProject, { projectId });

    expect(Object.keys(result)).toHaveLength(2);
    expect(result[String(issue1)]).toHaveLength(1);
    expect(result[String(issue2)]).toHaveLength(1);
    expect(result[String(issue1)]![0]!.title).toBe("Deploy fails on staging");
    expect(result[String(issue2)]![0]!.title).toBe("Different signal");
    expect(result[String(issue1)]![0]!.sourceCount).toBe(1);
  });

  test("multiple signals linked to one issue are returned newest first", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const projectId = await createProject(t, ID_A, "order");
    const issue = await createIssue(t, identityA, projectId, "Single issue");

    const ps1 = { ...problemStatement, title: "First signal" };
    const ps2 = { ...problemStatement, title: "Second signal" };

    const sig1 = await createSignalInProject(
      t,
      identityA,
      orgId,
      projectId,
      "order1",
      ps1
    );
    // Small delay so createdAt differs.
    await t.mutation(async (ctx) => {
      // Patch createdAt to be earlier.
      const s = await ctx.db.get(sig1);
      if (s) {
        await ctx.db.patch(sig1, { createdAt: s.createdAt - 1000 });
      }
    });
    const sig2 = await createSignalInProject(
      t,
      identityA,
      orgId,
      projectId,
      "order2",
      ps2
    );

    await linkSignalToIssue(t, sig1, issue, orgId, projectId);
    await linkSignalToIssue(t, sig2, issue, orgId, projectId);

    const result = await t
      .withIdentity(identityA)
      .query(api.signals.listLinkedSignalsForProject, { projectId });

    const linked = result[String(issue)]!;
    expect(linked).toHaveLength(2);
    // Newest first: sig2 was created later.
    expect(linked[0]!.title).toBe("Second signal");
    expect(linked[1]!.title).toBe("First signal");
  });

  test("requires project membership — denies non-member access", async () => {
    const t = newTest();
    const _orgA = await ensureOrg(t, identityA);
    const _orgB = await ensureOrg(t, identityB);
    const projectId = await createProject(t, ID_A, "auth");

    // User B is NOT a member of org A / project.
    await expect(
      t
        .withIdentity(identityB)
        .query(api.signals.listLinkedSignalsForProject, { projectId })
    ).rejects.toThrow(/Project not found|Organization membership required/u);
  });

  test("does not return signals from a different project", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);

    // Two projects in the same org.
    const projectId1 = await createProject(t, ID_A, "p1");
    const projectId2 = await createProject(t, ID_A, "p2");

    const issue1 = await createIssue(t, identityA, projectId1, "Issue P1");
    const issue2 = await createIssue(t, identityA, projectId2, "Issue P2");

    // Signal linked to issue in project2.
    const sig = await createSignalInProject(
      t,
      identityA,
      orgId,
      projectId2,
      "xproj",
      problemStatement
    );
    await linkSignalToIssue(t, sig, issue2, orgId, projectId2);

    // Querying project1 should NOT see project2's signal.
    const result1 = await t
      .withIdentity(identityA)
      .query(api.signals.listLinkedSignalsForProject, {
        projectId: projectId1,
      });
    expect(result1[String(issue1)] ?? []).toHaveLength(0);

    // Querying project2 SHOULD see it.
    const result2 = await t
      .withIdentity(identityA)
      .query(api.signals.listLinkedSignalsForProject, {
        projectId: projectId2,
      });
    expect(result2[String(issue2)] ?? []).toHaveLength(1);
  });

  test("returns correct source count for multi-source signals", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const projectId = await createProject(t, ID_A, "sources");
    const issue = await createIssue(t, identityA, projectId, "Multi-source");

    // Create a signal with two sources.
    const m1 = await seedMessage(t, identityA, orgId, "ms-1", "evidence a");
    const m2 = await seedMessage(t, identityA, orgId, "ms-2", "evidence b");
    const result = await t
      .withIdentity(identityA)
      .mutation(api.signals.createFromMessages, {
        conversationId: orgId,
        messageIds: [m1, m2],
        organizationId: orgId,
        problemStatement,
        projectId,
        processedBy,
      });
    await linkSignalToIssue(t, result.signalId, issue, orgId, projectId);

    const linked = await t
      .withIdentity(identityA)
      .query(api.signals.listLinkedSignalsForProject, { projectId });

    const signals = linked[String(issue)]!;
    expect(signals).toHaveLength(1);
    expect(signals[0]!.sourceCount).toBe(2);
  });

  test("returns empty record for a project with no attachments", async () => {
    const t = newTest();
    const _orgId = await ensureOrg(t, identityA);
    const projectId = await createProject(t, ID_A, "empty");
    await createIssue(t, identityA, projectId, "Orphan issue");

    const result = await t
      .withIdentity(identityA)
      .query(api.signals.listLinkedSignalsForProject, { projectId });

    expect(Object.keys(result)).toHaveLength(0);
  });

  test("issue with no linked signals is omitted from the result", async () => {
    const t = newTest();
    const orgId = await ensureOrg(t, identityA);
    const projectId = await createProject(t, ID_A, "mixed");
    const issueWith = await createIssue(t, identityA, projectId, "Has signal");
    const issueWithout = await createIssue(
      t,
      identityA,
      projectId,
      "No signal"
    );

    const sig = await createSignalInProject(
      t,
      identityA,
      orgId,
      projectId,
      "mixed1",
      problemStatement
    );
    await linkSignalToIssue(t, sig, issueWith, orgId, projectId);

    const result = await t
      .withIdentity(identityA)
      .query(api.signals.listLinkedSignalsForProject, { projectId });

    expect(Object.keys(result)).toHaveLength(1);
    expect(result[String(issueWith)]).toHaveLength(1);
    expect(result[String(issueWithout)]).toBeUndefined();
  });
});
