// Real heterogeneous legacy + new Postgres proof for the alert-hydration TaskRun read.
// The DB is never mocked. A test-only RunStore wraps two real PostgresRunStore
// instances and routes findRun by id residency (run-ops id → NEW, cuid → LEGACY),
// mirroring the sibling routing suite. The ProjectAlertChannel read must stay control-plane.
//
// The alert env-type read (parentEnvironment?.type ?? type) is resolved via the app
// ControlPlaneResolver over a control-plane client DISTINCT from the run-ops store, proving the
// cross-provider inversion. The prior version co-located env + run and masked it.
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
import { PostgresRunStore } from "@internal/run-store";
import type { ReadClient, RunStore } from "@internal/run-store";
import type { Prisma, PrismaClient } from "@trigger.dev/database";
import { generateRunOpsId, ownerEngine } from "@trigger.dev/core/v3/isomorphic";
import { describe, expect } from "vitest";
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
import { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { PerformTaskRunAlertsService } from "~/v3/services/alerts/performTaskRunAlerts.server";

function buildControlPlaneResolver(controlPlane: PrismaClient) {
  return new ControlPlaneResolver({
    controlPlanePrimary: controlPlane,
    controlPlaneReplica: controlPlane,
    cache: new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 }),
    // Split OFF: plain control-plane query every call, byte-identical to the inline join.
    splitEnabled: () => false,
  });
}

vi.setConfig({ testTimeout: 60_000 });

// Test-only routing store: resolve findRun by id length (27 → NEW, else LEGACY),
// dropping any forwarded client so each inner store uses its OWN prisma. NOT a mock —
// real DB I/O against two PostgresRunStore instances.
class RoutingRunStore implements RunStore {
  readonly #newStore: PostgresRunStore;
  readonly #legacyStore: PostgresRunStore;

  constructor(newStore: PostgresRunStore, legacyStore: PostgresRunStore) {
    this.#newStore = newStore;
    this.#legacyStore = legacyStore;
  }

  #resolveById(runId: string): PostgresRunStore {
    return ownerEngine(runId) === "NEW" ? this.#newStore : this.#legacyStore;
  }

  #idFromWhere(where: Prisma.TaskRunWhereInput): string | undefined {
    const id = (where as { id?: unknown }).id;
    return typeof id === "string" ? id : undefined;
  }

  async findRun(
    where: Prisma.TaskRunWhereInput,
    argsOrClient?: { select?: Prisma.TaskRunSelect; include?: Prisma.TaskRunInclude } | ReadClient,
    _client?: ReadClient
  ): Promise<unknown> {
    const id = this.#idFromWhere(where);
    if (id !== undefined) {
      return (this.#resolveById(id).findRun as any)(where, argsOrClient);
    }
    const fromNew = await (this.#newStore.findRun as any)(where, argsOrClient);
    return fromNew ?? (this.#legacyStore.findRun as any)(where, argsOrClient);
  }

  // The remaining RunStore methods are not exercised here; delegate to NEW to satisfy
  // the interface.
  findRunOrThrow(...a: any[]): any {
    return (this.#newStore.findRunOrThrow as any)(...a);
  }
  findRuns(...a: any[]): any {
    return (this.#newStore.findRuns as any)(...a);
  }
  createRun(p: any, tx?: any): any {
    return this.#resolveById(p.data.id).createRun(p, tx);
  }
  createCancelledRun(p: any, tx?: any): any {
    return this.#resolveById(p.data.id).createCancelledRun(p, tx);
  }
  createFailedRun(p: any, tx?: any): any {
    return this.#resolveById(p.data.id).createFailedRun(p, tx);
  }
  updateMetadata(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).updateMetadata as any)(...[runId, ...a]);
  }
  startAttempt(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).startAttempt as any)(runId, ...a);
  }
  completeAttemptSuccess(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).completeAttemptSuccess as any)(runId, ...a);
  }
  recordRetryOutcome(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).recordRetryOutcome as any)(runId, ...a);
  }
  requeueRun(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).requeueRun as any)(runId, ...a);
  }
  recordBulkActionMembership(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).recordBulkActionMembership as any)(runId, ...a);
  }
  cancelRun(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).cancelRun as any)(runId, ...a);
  }
  failRunPermanently(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).failRunPermanently as any)(runId, ...a);
  }
  expireRun(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).expireRun as any)(runId, ...a);
  }
  expireRunsBatch(runIds: string[], ...a: any[]): any {
    return (this.#resolveById(runIds[0] ?? "").expireRunsBatch as any)(runIds, ...a);
  }
  lockRunToWorker(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).lockRunToWorker as any)(runId, ...a);
  }
  parkPendingVersion(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).parkPendingVersion as any)(runId, ...a);
  }
  promotePendingVersionRuns(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).promotePendingVersionRuns as any)(runId, ...a);
  }
  suspendForCheckpoint(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).suspendForCheckpoint as any)(runId, ...a);
  }
  resumeFromCheckpoint(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).resumeFromCheckpoint as any)(runId, ...a);
  }
  rescheduleRun(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).rescheduleRun as any)(runId, ...a);
  }
  enqueueDelayedRun(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).enqueueDelayedRun as any)(runId, ...a);
  }
  rewriteDebouncedRun(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).rewriteDebouncedRun as any)(runId, ...a);
  }
  clearIdempotencyKey(params: any, tx?: any): any {
    const runId = params?.byId?.runId ?? "";
    return this.#resolveById(runId).clearIdempotencyKey(params, tx);
  }
  pushTags(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).pushTags as any)(runId, ...a);
  }
  pushRealtimeStream(runId: string, ...a: any[]): any {
    return (this.#resolveById(runId).pushRealtimeStream as any)(runId, ...a);
  }
}

function buildRoutingStore(prisma17: PrismaClient, prisma14: PrismaClient) {
  const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
  const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
  return new RoutingRunStore(newStore, legacyStore);
}

async function seedProject(prisma: PrismaClient, suffix: string) {
  const organization = await prisma.organization.create({
    data: { title: `test-${suffix}`, slug: `test-${suffix}` },
  });
  const project = await prisma.project.create({
    data: {
      name: `test-${suffix}`,
      slug: `test-${suffix}`,
      organizationId: organization.id,
      externalRef: `test-${suffix}`,
    },
  });
  const runtimeEnvironment = await prisma.runtimeEnvironment.create({
    data: {
      slug: `test-${suffix}`,
      type: "PRODUCTION",
      projectId: project.id,
      organizationId: organization.id,
      apiKey: `test-${suffix}`,
      pkApiKey: `test-${suffix}`,
      shortcode: `test-${suffix}`,
    },
  });
  return { organization, project, runtimeEnvironment };
}

async function seedRun(
  prisma: PrismaClient,
  ids: { id: string; friendlyId: string },
  env: { runtimeEnvironmentId: string; projectId: string; organizationId: string }
) {
  return prisma.taskRun.create({
    data: {
      id: ids.id,
      friendlyId: ids.friendlyId,
      taskIdentifier: "my-task",
      payload: JSON.stringify({ foo: "bar" }),
      payloadType: "application/json",
      traceId: "1234",
      spanId: "1234",
      queue: "test",
      runtimeEnvironmentId: env.runtimeEnvironmentId,
      projectId: env.projectId,
      organizationId: env.organizationId,
      environmentType: "PRODUCTION",
      engine: "V2",
      status: "COMPLETED_SUCCESSFULLY",
    },
  });
}

const TASK_RUN_CROSS_SEAM_FKS = [
  "TaskRun_runtimeEnvironmentId_fkey",
  "TaskRun_projectId_fkey",
  "TaskRun_organizationId_fkey",
] as const;

async function dropTaskRunCrossSeamFks(prisma: PrismaClient) {
  for (const constraint of TASK_RUN_CROSS_SEAM_FKS) {
    await prisma.$executeRawUnsafe(
      `ALTER TABLE "TaskRun" DROP CONSTRAINT IF EXISTS "${constraint}"`
    );
  }
}

describe("PerformTaskRunAlertsService store routing (hetero)", () => {
  heteroPostgresTest(
    "env type resolves via the control-plane resolver (distinct DB) while the run resolves on the run-ops store",
    async ({ prisma17, prisma14 }) => {
      const id = generateRunOpsId();
      const friendlyId = `run_${id}`;

      // Cloud shape: run-ops = the new DB (cross-seam FKs dropped), control-plane = the legacy DB.
      // The control-plane ProjectAlert -> run-ops TaskRun FK is also dropped on the control-plane DB.
      await dropTaskRunCrossSeamFks(prisma17);
      await prisma14.$executeRawUnsafe(
        `ALTER TABLE "ProjectAlert" DROP CONSTRAINT IF EXISTS "ProjectAlert_taskRunId_fkey"`
      );

      // Org/project/env + a PARENT env + the alert channel are control-plane → the control-plane DB.
      const { project, organization } = await seedProject(prisma14, "cp");
      // A branch env whose parent type drives the channel filter (parentEnvironmentType ?? type).
      const parentEnv = await prisma14.runtimeEnvironment.create({
        data: {
          slug: "cp-parent",
          type: "PRODUCTION",
          projectId: project.id,
          organizationId: organization.id,
          apiKey: "cp-parent",
          pkApiKey: "cp-parent",
          shortcode: "cp-parent",
        },
      });
      const childEnv = await prisma14.runtimeEnvironment.create({
        data: {
          slug: "cp-child",
          type: "PREVIEW",
          projectId: project.id,
          organizationId: organization.id,
          apiKey: "cp-child",
          pkApiKey: "cp-child",
          shortcode: "cp-child",
          parentEnvironmentId: parentEnv.id,
        },
      });

      // The run-ops scalar row lives on the run-ops DB, referencing the child (preview) env on the control-plane DB.
      await seedRun(
        prisma17,
        { id, friendlyId },
        {
          runtimeEnvironmentId: childEnv.id,
          projectId: project.id,
          organizationId: organization.id,
        }
      );

      // A channel scoped to the PARENT env's type (PRODUCTION). It matches only if the service
      // computes parentEnvironmentType ?? type — i.e. the parent's PRODUCTION, not the run env's
      // PREVIEW. This proves the resolver's parentEnvironmentType is honoured.
      await prisma14.projectAlertChannel.create({
        data: {
          friendlyId: `alert_${id}`,
          name: "test-channel",
          projectId: project.id,
          alertTypes: ["TASK_RUN"],
          environmentTypes: ["PRODUCTION"],
          type: "EMAIL",
          properties: { type: "EMAIL", email: "test@example.com" },
          enabled: true,
        },
      });

      // prisma (control-plane channel read) = the control-plane DB; the run-ops read is routed to
      // the run-ops DB; the env type is resolved via the resolver over the control-plane client.
      const service = new PerformTaskRunAlertsService({
        prisma: prisma14,
        runStore: buildRoutingStore(prisma17, prisma14),
        controlPlaneResolver: buildControlPlaneResolver(prisma14),
      });

      // The downstream DeliverAlertService.enqueue hits redis (absent here); the projectAlert row
      // is created before that, so tolerate the enqueue rejection.
      await service.call(id).catch(() => {});

      // The channel matched on the PARENT env type → a DeliverAlert row was created on the control-plane DB.
      const delivered = await prisma14.projectAlert.findMany({ where: { projectId: project.id } });
      expect(delivered.length).toBe(1);

      // Inversion: the run-ops DB holds NO env rows; a co-located join would resolve null.
      expect(await prisma17.runtimeEnvironment.count()).toBe(0);
      // The run-ops store has the run; the control-plane DB never received it.
      expect(await prisma14.taskRun.findFirst({ where: { id } })).toBeNull();
    }
  );
});

describe("PerformTaskRunAlertsService passthrough (single-DB)", () => {
  postgresTest(
    "with the default store, run read + alert-channel read both resolve on the single DB",
    async ({ prisma }) => {
      const id = generateRunOpsId();
      const friendlyId = `run_${id}`;

      const { project, organization, runtimeEnvironment } = await seedProject(prisma, "pt");
      await seedRun(
        prisma,
        { id, friendlyId },
        {
          runtimeEnvironmentId: runtimeEnvironment.id,
          projectId: project.id,
          organizationId: organization.id,
        }
      );
      await prisma.projectAlertChannel.create({
        data: {
          friendlyId: `alert_${id}`,
          name: "test-channel",
          projectId: project.id,
          alertTypes: ["TASK_RUN"],
          environmentTypes: ["PRODUCTION"],
          type: "EMAIL",
          properties: { type: "EMAIL", email: "test@example.com" },
          enabled: true,
        },
      });

      const service = new PerformTaskRunAlertsService({
        prisma,
        // The single-DB default store: a passthrough PostgresRunStore over the one
        // container. Injected explicitly so the read resolves on the container the run
        // was seeded into, not the ambient module singleton.
        runStore: new PostgresRunStore({ prisma, readOnlyPrisma: prisma }),
        controlPlaneResolver: buildControlPlaneResolver(prisma),
      });
      await service.call(id).catch(() => {});

      const delivered = await prisma.projectAlert.findMany({ where: { projectId: project.id } });
      expect(delivered.length).toBe(1);
    }
  );
});
