// QA validation for [PAP-9522](/PAP/issues/PAP-9522). Drives the routine-secret
// chain end-to-end against a real embedded Postgres:
//
// 1. Routine env reaches the heartbeat runtime via `resolveExecutionRunAdapterConfig`
//    using `secretsSvc.resolveEnvBindings` with a `consumerType: "routine"` context,
//    even when the executing agent has zero direct bindings for that secret.
// 2. Precedence: agent < project < routine for a shared key.
// 3. `secret_access_events` records routine consumption but NEVER the resolved value.
// 4. Restoring an older revision re-syncs `company_secret_bindings` to the snapshot env.
// 5. Legacy fallback: a routine_run with null `routine_revision_id` still resolves
//    the routine's current env (matches the explicit acceptance criterion).
// 6. Disabled / missing / cross-company secret bindings fail clearly without
//    echoing the value.

import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { eq, and } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
  agents,
  companies,
  companySecretBindings,
  companySecrets,
  companySecretVersions,
  createDb,
  projects,
  routineRuns,
  routines,
  secretAccessEvents,
} from "@paperclipai/db";
import {
  getEmbeddedPostgresTestSupport,
  startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { routineService } from "../services/routines.ts";
import { secretService } from "../services/secrets.ts";
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts";

const support = await getEmbeddedPostgresTestSupport();
const describeEmbedded = support.supported ? describe : describe.skip;
if (!support.supported) {
  console.warn(`Skipping QA e2e on this host: ${support.reason ?? "embedded pg unsupported"}`);
}

describeEmbedded("PAP-9522 QA: routine secrets end-to-end", () => {
  let db!: ReturnType<typeof createDb>;
  let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
  const secretsTmpDir = path.join(os.tmpdir(), `paperclip-qa-routine-secrets-${randomUUID()}`);
  const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;

  beforeAll(async () => {
    mkdirSync(secretsTmpDir, { recursive: true });
    process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
    tempDb = await startEmbeddedPostgresTestDatabase("paperclip-qa-routine-secrets-");
    db = createDb(tempDb.connectionString);
  }, 30_000);

  afterEach(async () => {
    await db.delete(secretAccessEvents);
    await db.delete(companySecretBindings);
    await db.delete(routineRuns);
    await db.delete(routines);
    await db.delete(companySecretVersions);
    await db.delete(companySecrets);
    await db.delete(projects);
    await db.delete(agents);
    await db.delete(companies);
  });

  afterAll(async () => {
    await tempDb?.cleanup();
    if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
    else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
    rmSync(secretsTmpDir, { recursive: true, force: true });
  });

  async function seed() {
    const companyId = randomUUID();
    const executorAgentId = randomUUID();
    const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
    await db.insert(companies).values({
      id: companyId,
      name: "QA Co",
      issuePrefix,
      requireBoardApprovalForNewAgents: false,
    });
    // Note: executor agent has NO secret bindings of its own — this is the
    // whole point of routine env (the secret rides with the routine, not the agent).
    await db.insert(agents).values({
      id: executorAgentId,
      companyId,
      name: "Executor",
      role: "engineer",
      status: "active",
      adapterType: "codex_local",
      adapterConfig: { env: {} },
      runtimeConfig: {},
      permissions: {},
    });
    return { companyId, executorAgentId };
  }

  const ROUTINE_VALUE = "super-sekret-routine-value";
  const PROJECT_VALUE = "project-overlay-value";
  const AGENT_VALUE = "agent-base-value";

  it("resolves routine env for an executing agent that has no direct binding, with routine winning precedence and zero value in access events", async () => {
    const { companyId, executorAgentId } = await seed();
    const secrets = secretService(db);
    const routines = routineService(db, { heartbeat: { wakeup: async () => null } });

    const secret = await secrets.create(companyId, {
      name: `routine-api-${randomUUID()}`,
      provider: "local_encrypted",
      value: ROUTINE_VALUE,
    });

    const routine = await routines.create(
      companyId,
      {
        projectId: null,
        goalId: null,
        parentIssueId: null,
        title: "qa routine",
        description: null,
        assigneeAgentId: executorAgentId,
        priority: "medium",
        status: "active",
        concurrencyPolicy: "coalesce_if_active",
        catchUpPolicy: "skip_missed",
        env: {
          SHARED: { type: "plain", value: "routine-overrides" },
          ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
        },
      },
      {},
    );

    // Verify binding is owned by the routine, not the executing agent.
    const bindings = await db
      .select()
      .from(companySecretBindings)
      .where(eq(companySecretBindings.targetId, routine.id));
    expect(bindings).toMatchObject([
      { targetType: "routine", secretId: secret.id, configPath: "env.ROUTINE_API_KEY" },
    ]);

    // Drive the real heartbeat resolution path with the routine env.
    // issueId/heartbeatRunId left null because secret_access_events has FK
    // constraints on both — populating them would require seeding issue and
    // heartbeat_run rows just for FK validity. The routine consumer fields are
    // what this test cares about.
    const result = await resolveExecutionRunAdapterConfig({
      companyId,
      agentId: executorAgentId,
      issueId: null,
      heartbeatRunId: null,
      projectId: null,
      routineId: routine.id,
      executionRunConfig: { env: { SHARED: AGENT_VALUE, AGENT_ONLY: AGENT_VALUE } },
      projectEnv: { SHARED: { type: "plain", value: PROJECT_VALUE } },
      routineEnv: routine.env,
      secretsSvc: secrets,
    });

    expect(result.resolvedConfig.env).toMatchObject({
      AGENT_ONLY: AGENT_VALUE,
      SHARED: "routine-overrides", // routine beats project beats agent
      ROUTINE_API_KEY: ROUTINE_VALUE,
    });
    expect(result.secretKeys.has("ROUTINE_API_KEY")).toBe(true);
    expect(result.secretManifest.some((m) => m.envKey === "ROUTINE_API_KEY")).toBe(true);
    // Manifest must not echo the resolved value.
    expect(JSON.stringify(result.secretManifest)).not.toContain(ROUTINE_VALUE);

    const events = await db
      .select()
      .from(secretAccessEvents)
      .where(eq(secretAccessEvents.secretId, secret.id));
    expect(events).toHaveLength(1);
    expect(events[0]).toMatchObject({
      consumerType: "routine",
      consumerId: routine.id,
      actorType: "agent",
      actorId: executorAgentId,
      configPath: "env.ROUTINE_API_KEY",
      outcome: "success",
    });
    // No serialized field of the access event row can contain the secret value.
    expect(JSON.stringify(events[0])).not.toContain(ROUTINE_VALUE);
  });

  it("rejects routine env that references a secret from a different company", async () => {
    const { companyId } = await seed();
    const { companyId: otherCompanyId } = await seed();
    const secrets = secretService(db);
    const routines = routineService(db, { heartbeat: { wakeup: async () => null } });

    const foreignSecret = await secrets.create(otherCompanyId, {
      name: `foreign-${randomUUID()}`,
      provider: "local_encrypted",
      value: "cross-company-leak-bait",
    });

    await expect(
      routines.create(
        companyId,
        {
          projectId: null,
          goalId: null,
          parentIssueId: null,
          title: "cross company",
          description: null,
          assigneeAgentId: null,
          priority: "medium",
          status: "paused",
          concurrencyPolicy: "coalesce_if_active",
          catchUpPolicy: "skip_missed",
          env: {
            BAD: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" },
          },
        },
        {},
      ),
    ).rejects.toThrow(/same company/i);
  });

  it("surfaces a clear, value-free error when a routine secret is missing/deleted at resolution time", async () => {
    const { companyId, executorAgentId } = await seed();
    const secrets = secretService(db);
    const routines = routineService(db, { heartbeat: { wakeup: async () => null } });

    const secret = await secrets.create(companyId, {
      name: `to-be-deleted-${randomUUID()}`,
      provider: "local_encrypted",
      value: "doomed-secret-value",
    });

    const routine = await routines.create(
      companyId,
      {
        projectId: null,
        goalId: null,
        parentIssueId: null,
        title: "doomed routine",
        description: null,
        assigneeAgentId: executorAgentId,
        priority: "medium",
        status: "active",
        concurrencyPolicy: "coalesce_if_active",
        catchUpPolicy: "skip_missed",
        env: {
          DOOMED: { type: "secret_ref", secretId: secret.id, version: "latest" },
        },
      },
      {},
    );

    // Hard delete the secret out from under the routine; the routine env now
    // points at a vanished id.
    await secrets.remove(secret.id);

    let caught: unknown = null;
    try {
      await resolveExecutionRunAdapterConfig({
        companyId,
        agentId: executorAgentId,
        issueId: null,
        heartbeatRunId: null,
        projectId: null,
        routineId: routine.id,
        executionRunConfig: { env: {} },
        projectEnv: null,
        routineEnv: routine.env,
        secretsSvc: secrets,
      });
    } catch (error) {
      caught = error;
    }
    expect(caught).toBeTruthy();
    const message = String((caught as Error)?.message ?? caught);
    expect(message).not.toContain("doomed-secret-value");
  });

  it("restoring an older revision re-syncs company_secret_bindings to the snapshot env", async () => {
    const { companyId, executorAgentId } = await seed();
    const secrets = secretService(db);
    const routines = routineService(db, { heartbeat: { wakeup: async () => null } });

    const secretA = await secrets.create(companyId, {
      name: `a-${randomUUID()}`,
      provider: "local_encrypted",
      value: "val-a",
    });
    const secretB = await secrets.create(companyId, {
      name: `b-${randomUUID()}`,
      provider: "local_encrypted",
      value: "val-b",
    });

    const routine = await routines.create(
      companyId,
      {
        projectId: null,
        goalId: null,
        parentIssueId: null,
        title: "restore routine",
        description: null,
        assigneeAgentId: executorAgentId,
        priority: "medium",
        status: "active",
        concurrencyPolicy: "coalesce_if_active",
        catchUpPolicy: "skip_missed",
        env: {
          ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" },
        },
      },
      {},
    );
    const rev1Id = routine.latestRevisionId!;

    await routines.update(
      routine.id,
      {
        env: {
          ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" },
          BETA: { type: "secret_ref", secretId: secretB.id, version: "latest" },
        },
      },
      {},
    );

    let bindings = await db
      .select()
      .from(companySecretBindings)
      .where(eq(companySecretBindings.targetId, routine.id));
    expect(bindings.map((b) => b.configPath).sort()).toEqual(["env.ALPHA", "env.BETA"]);

    await routines.restoreRevision(routine.id, rev1Id, {});

    bindings = await db
      .select()
      .from(companySecretBindings)
      .where(eq(companySecretBindings.targetId, routine.id));
    expect(bindings.map((b) => b.configPath)).toEqual(["env.ALPHA"]);
    expect(bindings[0]?.secretId).toBe(secretA.id);
  });

  it("legacy run with null routine_revision_id falls back to the routine's current env (still resolves)", async () => {
    const { companyId, executorAgentId } = await seed();
    const secrets = secretService(db);
    const routines = routineService(db, { heartbeat: { wakeup: async () => null } });

    const secret = await secrets.create(companyId, {
      name: `legacy-${randomUUID()}`,
      provider: "local_encrypted",
      value: "legacy-value",
    });

    const routine = await routines.create(
      companyId,
      {
        projectId: null,
        goalId: null,
        parentIssueId: null,
        title: "legacy routine",
        description: null,
        assigneeAgentId: executorAgentId,
        priority: "medium",
        status: "active",
        concurrencyPolicy: "coalesce_if_active",
        catchUpPolicy: "skip_missed",
        env: {
          LEGACY: { type: "secret_ref", secretId: secret.id, version: "latest" },
        },
      },
      {},
    );

    // Simulate an old routine_run row (predating the migration) with no
    // routine_revision_id. The fallback path in `getRoutineEnvForExecutionIssue`
    // should still resolve to the routine's current env. Here we exercise the
    // resolution layer directly with routine.env to mirror that behavior.
    await db.insert(routineRuns).values({
      id: randomUUID(),
      companyId,
      routineId: routine.id,
      triggerId: null,
      source: "manual",
      status: "issue_created",
      triggeredAt: new Date(),
      completedAt: new Date(),
      routineRevisionId: null,
    });

    const result = await resolveExecutionRunAdapterConfig({
      companyId,
      agentId: executorAgentId,
      issueId: null,
      heartbeatRunId: null,
      projectId: null,
      routineId: routine.id,
      executionRunConfig: { env: {} },
      projectEnv: null,
      routineEnv: routine.env,
      secretsSvc: secrets,
    });
    expect(result.resolvedConfig.env).toMatchObject({ LEGACY: "legacy-value" });
  });

  it("routines created with null env (no Secrets tab interaction) still resolve normally with empty env", async () => {
    const { companyId, executorAgentId } = await seed();
    const secrets = secretService(db);
    const routines = routineService(db, { heartbeat: { wakeup: async () => null } });

    const routine = await routines.create(
      companyId,
      {
        projectId: null,
        goalId: null,
        parentIssueId: null,
        title: "null env routine",
        description: null,
        assigneeAgentId: executorAgentId,
        priority: "medium",
        status: "active",
        concurrencyPolicy: "coalesce_if_active",
        catchUpPolicy: "skip_missed",
      },
      {},
    );

    expect(routine.env ?? null).toBeNull();

    const bindings = await db
      .select()
      .from(companySecretBindings)
      .where(eq(companySecretBindings.targetId, routine.id));
    expect(bindings).toHaveLength(0);

    const result = await resolveExecutionRunAdapterConfig({
      companyId,
      agentId: executorAgentId,
      issueId: null,
      heartbeatRunId: null,
      projectId: null,
      routineId: routine.id,
      executionRunConfig: { env: { AGENT_ONLY: "agent" } },
      projectEnv: null,
      routineEnv: null,
      secretsSvc: secrets,
    });
    expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent" });
    expect(result.secretKeys.size).toBe(0);
  });
});
