import "./env.js";
import "./net.js";
import { createClient } from "@clickhouse/client";
import { serve } from "@hono/node-server";
import { SpanStatusCode, trace } from "@opentelemetry/api";
import {
  DEFAULT_AGENT_RUN_PROVIDER,
  type Issue,
  confirmResolutionProposal,
  createIncidentLifecycle,
  db,
  dismissResolutionProposal,
  isAgentRunProvider,
  listAccessibleGithubInstallsForProject,
  mintApiKey,
  resolveIncident,
  runMigrations,
  schema,
  sendLoopsWelcomeFlow,
  upsertLoopsContact,
} from "@superlog/db";
import { fingerprint, fingerprintLog } from "@superlog/fingerprint";
import { Autumn, AutumnError } from "autumn-js";
import { and, asc, count, desc, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm";
import { Hono } from "hono";
import type { Context } from "hono";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { nanoid } from "nanoid";
import { mountAlerts } from "./alerts.js";
import { auth } from "./auth.js";
import { shouldRunMigrationsOnBoot } from "./boot-migrations.js";
import { mountCloudConnectionsAuthed } from "./cloud-connections.js";
import { mountDashboards } from "./dashboards.js";
import { mountFeedbackAuthed, mountFeedbackPublic } from "./feedback.js";
import { type GatewayVars, mountGateway } from "./gateway.js";
import { prBaseBranchExists } from "./github-branches.js";
import {
  closeAgentPullRequestOnGithub,
  listProjectRepoBranches,
  mergeGithubPullRequest,
  mountGithubAuthed,
  mountGithubAuthorOAuth,
  mountGithubPublic,
} from "./github.js";
import { createApiHttpObservabilityMiddleware } from "./http-observability.js";
import { mountImpersonation } from "./impersonation.js";
import { buildIncidentListItem, shouldInlineIncidentListStats } from "./incidents/list.js";
import { getPrDeliveryRetryEligibility } from "./incidents/pr-retry.js";
import { buildIncidentPullRequestViews } from "./incidents/pr-view.js";
import {
  runResolvedIncidentSideEffectsForIncident,
  shouldRunResolvedIncidentSideEffects,
} from "./incidents/resolution-side-effects.js";
import {
  buildIncidentStatsFromActivityRows,
  buildIncidentStatsFromIssues,
  buildIncidentStatsPairs,
  buildIncidentStatsWithFallback,
  spanSampleKey,
} from "./incidents/stats.js";
import { mountLinearAuthed, mountLinearPublic } from "./linear.js";
import { logger } from "./logger.js";
import { mountManagementApi, mountOrgKeyManagementAuthed } from "./management.js";
import {
  METRIC_AGGREGATIONS,
  type MetricAggregation,
  type ResourceAttrFilter,
  type SeriesSource,
  countSeries,
  getTraceDetail,
  listAttributeKeys,
  listAttributeValues,
  listIssueFilterAttributeKeys,
  listIssueFilterAttributeValues,
  listMetricNames,
  listServices,
  metricSeries,
  pickStep,
  previewIssueFilterMatches,
  queryLogs,
  queryMetrics,
  queryTraces,
  queryTracesAggregated,
} from "./mcp/clickhouse.js";
import { mountMcpAuthed, mountMcpPublic } from "./mcp/index.js";
import { resolveActiveOrgContext, resolveMaybeActiveOrgContext } from "./org-context.js";
import { mountSettingsAuthed } from "./settings.js";
import { normalizeSignupIntentKeyHash, normalizeSignupIntentKeyPrefix } from "./signup-intents.js";
import { mountSlackAuthed, mountSlackPublic } from "./slack.js";
import { sourceMapObjectStoreFromEnv } from "./sourcemaps.js";
import { userIsStaff } from "./staff.js";
import { symbolicateIssueSample, symbolicateTelemetrySample } from "./symbolication.js";
import { buildSystemCapabilities } from "./system-capabilities.js";
import { mountWebhooks } from "./webhooks.js";

const PORT = Number(process.env.PORT ?? 4100);
const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173";
const WEB_CORS_ORIGINS = Array.from(
  new Set([WEB_ORIGIN, "http://localhost:5173", "http://127.0.0.1:5173"]),
);

const ch = createClient({
  url: process.env.CLICKHOUSE_URL ?? "http://localhost:8123",
  database: process.env.CLICKHOUSE_DB ?? "superlog",
  username: process.env.CLICKHOUSE_USER ?? "default",
  password: process.env.CLICKHOUSE_PASSWORD ?? "",
  // idle_socket_ttl must stay below CH server's keep_alive_timeout (3s default)
  // so we recycle before the server closes; request_timeout short-circuits
  // stale sockets that slipped through instead of hanging 30s.
  request_timeout: 10_000,
  keep_alive: { enabled: true, idle_socket_ttl: 2_500 },
});

const tracer = trace.getTracer("@superlog/api");
const SIGNUP_INTENT_TTL_MS = 30 * 60 * 1000;

type Vars = {
  userId: string;
  orgId: string | null;
  impersonating?: boolean;
} & Partial<GatewayVars>;
const app = new Hono<{ Variables: Vars }>();
const incidentLifecycle = createIncidentLifecycle(db);
const sourceMapObjectStore = sourceMapObjectStoreFromEnv(process.env);

app.use(
  "/api/*",
  cors({
    origin: WEB_CORS_ORIGINS,
    credentials: true,
    allowHeaders: [
      "authorization",
      "content-type",
      "traceparent",
      "tracestate",
      "x-superlog-signup-source",
    ],
    allowMethods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
  }),
);

app.use("/api/*", createApiHttpObservabilityMiddleware());

app.use(
  "/mcp",
  cors({
    origin: "*",
    allowHeaders: ["authorization", "content-type", "mcp-session-id", "mcp-protocol-version"],
    allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
    exposeHeaders: ["mcp-session-id", "www-authenticate"],
  }),
);

app.use(
  "/oauth/*",
  cors({
    origin: "*",
    allowHeaders: ["content-type"],
    allowMethods: ["GET", "POST", "OPTIONS"],
  }),
);

app.use(
  "/.well-known/*",
  cors({
    origin: "*",
    allowMethods: ["GET", "OPTIONS"],
  }),
);

app.use(
  "/activate/*",
  cors({
    origin: WEB_CORS_ORIGINS,
    credentials: true,
    allowHeaders: ["authorization", "content-type", "traceparent", "tracestate"],
    allowMethods: ["POST", "OPTIONS"],
  }),
);

// /feedback/pr/* is the anonymous PR-link surface — the web app posts to
// it cross-origin from /feedback/pr/:owner/:repo/:num. Credentials are
// included so a signed-in user is still attributed to their account.
app.use(
  "/feedback/*",
  cors({
    origin: WEB_CORS_ORIGINS,
    credentials: true,
    allowHeaders: ["content-type", "traceparent", "tracestate"],
    allowMethods: ["POST", "OPTIONS"],
  }),
);

app.get("/health", (c) => c.json({ ok: true }));

// Tells the web client which social providers have credentials configured
// in this environment so it can hide the buttons that would 503 on click.
app.get("/api/auth-providers", (c) =>
  c.json({
    google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
    github: !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET),
  }),
);

app.get("/api/system/capabilities", (c) => c.json(buildSystemCapabilities()));

app.post("/api/signup-intents", async (c) => {
  const body = (await c.req.json().catch(() => ({}))) as {
    keyHash?: unknown;
    keyPrefix?: unknown;
    returnTo?: unknown;
  };
  const keyHash = normalizeSignupIntentKeyHash(body.keyHash);
  const keyPrefix = normalizeSignupIntentKeyPrefix(body.keyPrefix);
  if (!keyHash || !keyPrefix) {
    throw new HTTPException(400, { message: "valid keyHash and keyPrefix required" });
  }
  const returnTo =
    typeof body.returnTo === "string" && body.returnTo.length <= 1024 ? body.returnTo : null;

  const intentId = `sui_${nanoid(24)}`;
  const expiresAt = new Date(Date.now() + SIGNUP_INTENT_TTL_MS);
  await db.insert(schema.signupIntents).values({
    id: intentId,
    keyHash,
    keyPrefix,
    returnTo,
    expiresAt,
  });

  const signupUrl = new URL(`${WEB_ORIGIN}/signup`);
  signupUrl.searchParams.set("from", "skill");
  signupUrl.searchParams.set("intent", intentId);

  return c.json({
    id: intentId,
    signupUrl: signupUrl.toString(),
    expiresAt: expiresAt.toISOString(),
  });
});

// Better Auth handles its own routes under /api/auth/*. Mount before the
// session middleware so sign-in/sign-up/oauth-callback endpoints don't trip
// the unauthenticated guard.
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));

mountGateway(app, ch);
mountGithubPublic(app);
mountGithubAuthorOAuth(app);
mountLinearPublic(app);
mountMcpPublic(app, ch);
mountSlackPublic(app);
mountFeedbackPublic(app);
// Management API (org-key auth, /api/v1/*) registers its own middleware
// before the session middleware below — the session middleware skips
// /api/v1/* and /api/auth/*.
mountManagementApi(app, { ch });

app.use("/api/*", async (c, next) => {
  if (c.req.path.startsWith("/api/v1/")) return next();
  if (c.req.path.startsWith("/api/auth/")) return next();
  if (c.req.path === "/api/auth-providers") return next();
  // Zero-paste AWS-connect callback: no session — authenticated by the
  // connection's external ID inside the body (see cloud-connections.ts).
  if (c.req.path === "/api/cloud-connections/callback") return next();
  const session = await auth.api.getSession({ headers: c.req.raw.headers });
  if (!session) return c.json({ error: "unauthenticated" }, 401);
  c.set("userId", session.user.id);
  c.set("orgId", session.session.activeOrganizationId ?? null);
  // Better Auth's admin plugin sets impersonatedBy on the session row when an
  // admin is acting as another user; surface it on `c.var` so /api/me can
  // expose a boolean without the web client having to inspect raw session.
  c.set("impersonating", typeof session.session.impersonatedBy === "string");
  await next();
});

mountMcpAuthed(app);
mountGithubAuthed(app);
mountLinearAuthed(app);
mountSlackAuthed(app);
mountSettingsAuthed(app);
mountOrgKeyManagementAuthed(app);
mountDashboards(app);
mountCloudConnectionsAuthed(app);
mountAlerts(app, { ch });
mountWebhooks(app);
mountFeedbackAuthed(app);
mountImpersonation(app);

const ALLOWED_SIGNUP_SOURCES = new Set(["skill", "web", "mcp", "github", "cli"]);

app.get("/api/me", async (c) => {
  const ctx = await resolveMaybeActiveOrgContext({
    userId: c.var.userId,
    preferredOrgId: c.var.orgId,
  });
  const { user } = ctx;
  // Whether billing hard-blocks (ingest 402 / investigation cap) are enforced.
  // Metering runs regardless; this only gates blocking, so the web can avoid
  // showing an "Ingest paused" bar when nothing is actually being blocked.
  const billingEnforcement = process.env.BILLING_ENFORCEMENT_ENABLED === "true";

  // Pre-org users (just signed up, haven't created their first org yet) get a
  // null org/project so the web client can route them to the create-org step
  // in the onboarding wizard.
  if (!ctx.org) {
    return c.json({
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        isStaff: userIsStaff(user.role),
        impersonating: c.var.impersonating === true,
      },
      org: null,
      project: null,
      billingEnforcement,
    });
  }

  const { org, project } = ctx;

  if (!org.signupSource) {
    const headerSource = c.req.header("x-superlog-signup-source")?.trim().toLowerCase();
    if (headerSource && ALLOWED_SIGNUP_SOURCES.has(headerSource)) {
      const updated = await db
        .update(schema.orgs)
        .set({ signupSource: headerSource })
        .where(and(eq(schema.orgs.id, org.id), isNull(schema.orgs.signupSource)))
        .returning({ signupSource: schema.orgs.signupSource });
      if (updated[0]?.signupSource) org.signupSource = updated[0].signupSource;
    }
  }

  void upsertLoopsContact({
    user,
    org,
    project,
    signupSource: org.signupSource,
    appUrl: WEB_ORIGIN,
  }).catch((err) => {
    logger.warn({ err, userId: user.id, orgId: org.id }, "loops contact sync failed");
  });

  const accessibleInstalls = await listAccessibleGithubInstallsForProject(project.id);
  const githubSetupNeeded = accessibleInstalls.length === 0 && !org.githubSetupSkippedAt;

  // `hasIngested` decides whether OnboardingGate shows the install wizard. Reading
  // it from api_keys.last_used_at (set by proxy/src/index.ts on every successful
  // auth) avoids 6 ClickHouse count() queries per page load — the previous gate
  // derived this from /stats and was firing on every navigation forever.
  const ingestedKey = await db.query.apiKeys.findFirst({
    columns: { id: true },
    where: and(eq(schema.apiKeys.projectId, project.id), isNotNull(schema.apiKeys.lastUsedAt)),
  });
  const hasIngested = ingestedKey !== undefined;

  return c.json({
    user: {
      id: user.id,
      email: user.email,
      name: user.name,
      isStaff: userIsStaff(user.role),
      impersonating: c.var.impersonating === true,
    },
    org: { id: org.id, name: org.name, slug: org.slug, githubSetupNeeded },
    project: { id: project.id, name: project.name, slug: project.slug, hasIngested },
    billingEnforcement,
  });
});

app.put("/api/me/active-project", async (c) => {
  const { user, org } = await resolveActiveOrgContext({
    userId: c.var.userId,
    preferredOrgId: c.var.orgId,
  });
  const body = (await c.req.json().catch(() => ({}))) as { projectId?: unknown };
  const projectId = typeof body.projectId === "string" ? body.projectId : null;
  if (!projectId) throw new HTTPException(400, { message: "projectId required" });

  const project = await db.query.projects.findFirst({
    where: and(eq(schema.projects.id, projectId), eq(schema.projects.orgId, org.id)),
  });
  if (!project) throw new HTTPException(404, { message: "project not found in active org" });

  await db
    .update(schema.users)
    .set({ activeProjectId: project.id })
    .where(eq(schema.users.id, user.id));

  return c.json({ project: { id: project.id, name: project.name, slug: project.slug } });
});

const ORG_NAME_MAX = 80;

function slugifyOrgName(value: string): string {
  return value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 40);
}

async function uniqueOrgSlug(client: Pick<typeof db, "query">, base: string): Promise<string> {
  const seed = base || "org";
  let candidate = seed;
  for (let attempt = 0; attempt < 20; attempt += 1) {
    const existing = await client.query.orgs.findFirst({ where: eq(schema.orgs.slug, candidate) });
    if (!existing) return candidate;
    candidate = `${seed.slice(0, 32)}-${nanoid(6).toLowerCase()}`;
  }
  return `${seed.slice(0, 20)}-${nanoid(12).toLowerCase()}`;
}

// First-org creation. Called by the onboarding wizard's create-org step for
// users that signed up but don't have a membership yet. Idempotent on retry:
// if the user already has an org, returns it instead of creating a duplicate.
app.post("/api/me/orgs", async (c) => {
  const body = (await c.req.json().catch(() => ({}))) as { name?: unknown };
  const rawName = typeof body.name === "string" ? body.name.trim() : "";
  if (!rawName) throw new HTTPException(400, { message: "name required" });
  if (rawName.length > ORG_NAME_MAX) {
    throw new HTTPException(400, { message: `name must be ${ORG_NAME_MAX} chars or fewer` });
  }

  const userId = c.var.userId;
  type WelcomePayload = {
    user: { id: string; email: string };
    org: typeof schema.orgs.$inferSelect;
    project: typeof schema.projects.$inferSelect;
  };

  const result = await db.transaction(
    async (
      tx,
    ): Promise<{
      payload: {
        org: { id: string; name: string; slug: string };
        project: { id: string; name: string; slug: string };
      };
      welcome: WelcomePayload | null;
    }> => {
      // Serializes first-org creation for a user. Without this lock, two
      // concurrent create-org requests can both see no membership and create two
      // organizations before either membership row exists.
      const [user] = await tx
        .select({ id: schema.users.id, email: schema.users.email })
        .from(schema.users)
        .where(eq(schema.users.id, userId))
        .for("update");
      if (!user) throw new HTTPException(404, { message: "user not found" });

      const existingMembership = await tx.query.orgMembers.findFirst({
        where: eq(schema.orgMembers.userId, userId),
      });
      if (existingMembership) {
        const org = await tx.query.orgs.findFirst({
          where: eq(schema.orgs.id, existingMembership.orgId),
        });
        if (!org)
          throw new HTTPException(500, { message: "org membership references missing org" });
        let project = await tx.query.projects.findFirst({
          where: eq(schema.projects.orgId, org.id),
        });
        if (!project) {
          const [createdProject] = await tx
            .insert(schema.projects)
            .values({ orgId: org.id, name: "Default", slug: "default" })
            .returning();
          if (!createdProject) {
            throw new HTTPException(500, { message: "failed to create default project" });
          }
          project = createdProject;
          await tx
            .insert(schema.projectAutomationSettings)
            .values({ projectId: project.id })
            .onConflictDoNothing({ target: schema.projectAutomationSettings.projectId });
        }
        return {
          payload: {
            org: { id: org.id, name: org.name, slug: org.slug },
            project: { id: project.id, name: project.name, slug: project.slug },
          },
          welcome: null,
        };
      }

      const slug = await uniqueOrgSlug(tx, slugifyOrgName(rawName));
      const [org] = await tx.insert(schema.orgs).values({ name: rawName, slug }).returning();
      if (!org) throw new HTTPException(500, { message: "failed to create org" });

      await tx
        .insert(schema.orgMembers)
        .values({ orgId: org.id, userId, role: "owner" })
        .onConflictDoNothing({ target: [schema.orgMembers.orgId, schema.orgMembers.userId] });

      const [project] = await tx
        .insert(schema.projects)
        .values({ orgId: org.id, name: "Default", slug: "default" })
        .returning();
      if (!project) throw new HTTPException(500, { message: "failed to create default project" });

      await tx
        .insert(schema.projectAutomationSettings)
        .values({ projectId: project.id })
        .onConflictDoNothing({ target: schema.projectAutomationSettings.projectId });

      // Promote the new org to active on every session this user has open, so the
      // next /api/me call returns it without requiring a sign-out/sign-in round trip.
      await tx
        .update(schema.sessions)
        .set({ activeOrganizationId: org.id })
        .where(
          and(eq(schema.sessions.userId, userId), isNull(schema.sessions.activeOrganizationId)),
        );

      return {
        payload: {
          org: { id: org.id, name: org.name, slug: org.slug },
          project: { id: project.id, name: project.name, slug: project.slug },
        },
        welcome: { user, org, project },
      };
    },
  );

  if (result.welcome) {
    void sendLoopsWelcomeFlow({
      user: result.welcome.user,
      org: result.welcome.org,
      project: result.welcome.project,
      signupSource: result.welcome.org.signupSource,
      appUrl: WEB_ORIGIN,
    }).catch((err) => {
      logger.warn({ err, userId, orgId: result.welcome?.org.id }, "loops welcome flow failed");
    });
  }

  return c.json(result.payload);
});

app.post("/api/signup-intents/:intentId/claim", async (c) => {
  const intentId = c.req.param("intentId");
  const body = (await c.req.json().catch(() => ({}))) as { projectId?: unknown };
  const projectId = typeof body.projectId === "string" ? body.projectId : null;
  if (!projectId) throw new HTTPException(400, { message: "projectId required" });

  const { user, org } = await resolveActiveOrgContext({
    userId: c.var.userId,
    preferredOrgId: c.var.orgId,
  });
  const project = await db.query.projects.findFirst({
    where: and(eq(schema.projects.id, projectId), eq(schema.projects.orgId, org.id)),
  });
  if (!project) throw new HTTPException(404, { message: "project not found in active org" });

  const claimed = await db.transaction(async (tx) => {
    const intent = await tx.query.signupIntents.findFirst({
      where: eq(schema.signupIntents.id, intentId),
    });
    if (!intent) throw new HTTPException(404, { message: "signup intent not found" });
    if (intent.expiresAt < new Date()) {
      logger.warn(
        {
          intent_id: intentId,
          project_id: projectId,
          user_id: c.var.userId,
          expires_at: intent.expiresAt.toISOString(),
          age_ms: Date.now() - intent.expiresAt.getTime(),
        },
        "signup intent expired",
      );
      throw new HTTPException(410, { message: "signup intent expired" });
    }
    if (intent.consumedAt) {
      if (intent.claimedProjectId === project.id) {
        return {
          id: null,
          keyPrefix: intent.keyPrefix,
          returnTo: intent.returnTo,
          alreadyClaimed: true,
        };
      }
      throw new HTTPException(409, { message: "signup intent already claimed" });
    }

    const existingKey = await tx.query.apiKeys.findFirst({
      where: eq(schema.apiKeys.keyHash, intent.keyHash),
    });
    if (existingKey && existingKey.projectId !== project.id) {
      throw new HTTPException(409, {
        message: "signup intent key already belongs to another project",
      });
    }

    const [key] = existingKey
      ? [existingKey]
      : await tx
          .insert(schema.apiKeys)
          .values({
            projectId: project.id,
            name: "Skill onboarding",
            keyHash: intent.keyHash,
            keyPrefix: intent.keyPrefix,
          })
          .returning();
    if (!key) throw new Error("failed to claim signup intent key");

    await tx
      .update(schema.signupIntents)
      .set({
        consumedAt: new Date(),
        claimedProjectId: project.id,
        claimedByUserId: user.id,
      })
      .where(eq(schema.signupIntents.id, intent.id));

    return {
      id: key.id,
      keyPrefix: key.keyPrefix,
      returnTo: intent.returnTo,
      alreadyClaimed: false,
    };
  });

  return c.json(claimed);
});

app.post("/api/projects/:projectId/keys", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  return tracer.startActiveSpan("apikey.create", async (span) => {
    span.setAttribute("tenant.project_id", projectId);
    try {
      const body = (await c.req.json().catch(() => ({}))) as { name?: string };
      const name = body.name?.trim() || "new key";
      span.setAttribute("apikey.name", name);
      const minted = await mintApiKey({ projectId, name });
      span.setAttribute("apikey.prefix", minted.keyPrefix);
      span.setAttribute("apikey.id", minted.id);
      return c.json({
        id: minted.id,
        name: minted.name,
        keyPrefix: minted.keyPrefix,
        createdAt: minted.createdAt,
        plaintext: minted.plaintext,
      });
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
      throw err;
    } finally {
      span.end();
    }
  });
});

app.get("/api/projects/:projectId/keys", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const rows = await db.query.apiKeys.findMany({
    where: eq(schema.apiKeys.projectId, projectId),
  });
  return c.json(
    rows.map((k) => ({
      id: k.id,
      name: k.name,
      keyPrefix: k.keyPrefix,
      lastUsedAt: k.lastUsedAt,
      revokedAt: k.revokedAt,
      createdAt: k.createdAt,
    })),
  );
});

app.delete("/api/projects/:projectId/keys/:keyId", async (c) => {
  const projectId = c.req.param("projectId");
  const keyId = c.req.param("keyId");
  await requireProjectAccess(c, projectId);
  await db
    .update(schema.apiKeys)
    .set({ revokedAt: new Date() })
    .where(and(eq(schema.apiKeys.id, keyId), eq(schema.apiKeys.projectId, projectId)));
  return c.json({ ok: true });
});

// Reports whether the user has connected at least one MCP client to this
// project. Used by the dashboard's setup-todos to hide the "Install the MCP
// server" card once a token exists. Counts non-revoked tokens whose refresh
// is still valid (or never expires) — clients with only stale access tokens
// will still re-auth, so we don't gate on `accessExpiresAt`.
app.get("/api/projects/:projectId/mcp-status", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const rows = await db
    .select({ id: schema.mcpOauthTokens.id })
    .from(schema.mcpOauthTokens)
    .where(
      and(eq(schema.mcpOauthTokens.projectId, projectId), isNull(schema.mcpOauthTokens.revokedAt)),
    )
    .limit(1);
  return c.json({ connected: rows.length > 0 });
});

app.get("/api/projects/:projectId/stats", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const [traces, logs, metrics, issueRows] = await Promise.all([
    chCount("otel_traces", "Timestamp", projectId),
    chCount("otel_logs", "Timestamp", projectId),
    chMetricsCount(projectId),
    db.select({ c: count() }).from(schema.issues).where(eq(schema.issues.projectId, projectId)),
  ]);
  return c.json({
    window: "1h",
    traces,
    logs,
    metrics,
    issues: Number(issueRows[0]?.c ?? 0),
  });
});

// The Autumn SDK throws a typed AutumnError carrying the HTTP `statusCode` and
// raw `body` — use it directly (no casts) and match the specific 409
// "plan_already_attached" conflict code in the body.
function isPlanAlreadyAttached(err: unknown): boolean {
  return (
    err instanceof AutumnError &&
    err.statusCode === 409 &&
    err.body.includes("plan_already_attached")
  );
}

// Immediate "switch to Free" — per Autumn's guidance, attach the Free plan now
// (planSchedule "immediate") and carry usage over, rather than cancelling. Free
// is the auto_enable default, so attaching it when already on Free returns a
// "plan_already_attached" conflict, which we treat as a no-op success. Per-org
// (org = Autumn customer); the carry-over closes the toggle-to-reset loophole.
app.post("/api/me/billing/cancel", async (c) => {
  const ctx = await resolveMaybeActiveOrgContext({
    userId: c.var.userId,
    preferredOrgId: c.var.orgId,
  });
  if (!ctx.org) throw new HTTPException(400, { message: "no active org" });
  const key = process.env.AUTUMN_SECRET_KEY;
  if (!key) throw new HTTPException(400, { message: "billing is not configured" });
  const orgId = ctx.org.id;
  // "Switch to Free" — per Autumn's guidance, attach the Free plan immediately
  // and carry usage over, rather than cancelling the paid plan. When a paid
  // plan is active this is a real downgrade: planSchedule:"immediate" applies it
  // now and carryOverUsages preserves the org's metered usage (spans / logs /
  // metric points / investigation credits) so a maxed cap can't be reset by
  // toggling paid↔Free. (Cancelling instead spins up a fresh Free entitlement
  // with usage 0 — the reset we were fighting; and billing.update has no
  // carry-over option.) If the org is ALREADY on Free (Free is the auto_enable
  // default), the attach 409s "plan_already_attached" — that's a no-op success.
  const autumn = new Autumn({ secretKey: key });
  try {
    await autumn.billing.attach({
      customerId: orgId,
      planId: "free",
      planSchedule: "immediate",
      carryOverUsages: { enabled: true },
    });
  } catch (err) {
    // Only the "already on Free" conflict is a no-op success; surface any other
    // error so a genuine failure isn't reported as a successful switch.
    if (isPlanAlreadyAttached(err)) {
      return c.json({ ok: true });
    }
    throw err;
  }
  return c.json({ ok: true });
});

app.get("/api/projects/:projectId/explore/attribute-keys", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const { since, until } = parseRangeQuery(c);
  const rows = await listAttributeKeys(
    ch,
    projectId,
    { since, until },
    parseExploreAttributeSource(c),
  );
  return c.json(rows);
});

app.get("/api/projects/:projectId/explore/attribute-values", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const key = c.req.query("key");
  if (!key) throw new HTTPException(400, { message: "key is required" });
  const { since, until } = parseRangeQuery(c);
  const rows = await listAttributeValues(
    ch,
    projectId,
    key,
    { since, until },
    200,
    parseExploreAttributeSource(c),
  );
  return c.json(rows);
});

app.get("/api/projects/:projectId/explore/services", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const { since, until } = parseRangeQuery(c);
  const services = await listServices(ch, projectId, { since, until });
  return c.json(services);
});

app.post("/api/projects/:projectId/explore/logs", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  return tracer.startActiveSpan("explore.query_logs", async (span) => {
    span.setAttribute("tenant.project_id", projectId);
    try {
      const body = (await c.req.json().catch(() => ({}))) as ExploreListBody;
      const limit = clampLimit(body.limit, 100, 500);
      span.setAttribute("explore.limit", limit);
      if (body.service) span.setAttribute("explore.service", body.service);
      if (body.severity) span.setAttribute("explore.severity", body.severity);
      if (body.search) span.setAttribute("explore.has_search", true);
      const rows = await queryLogs(ch, projectId, {
        range: body.range,
        service: body.service,
        severity: body.severity,
        search: body.search,
        resourceAttrs: normalizeAttrs(body.resourceAttrs),
        limit,
      });
      span.setAttribute("explore.row_count", rows.length);
      return c.json(rows);
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
      throw err;
    } finally {
      span.end();
    }
  });
});

app.post("/api/projects/:projectId/explore/traces", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as ExploreListBody;
  const limit = clampLimit(body.limit, 100, 500);
  const rows = await queryTraces(ch, projectId, {
    range: body.range,
    service: body.service,
    spanName: body.spanName,
    statusCode: body.statusCode,
    minDurationMs: body.minDurationMs,
    resourceAttrs: normalizeAttrs(body.resourceAttrs),
    limit,
  });
  return c.json(rows);
});

app.post("/api/projects/:projectId/explore/traces-aggregated", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as ExploreListBody;
  const limit = clampLimit(body.limit, 100, 500);
  const rows = await queryTracesAggregated(ch, projectId, {
    range: body.range,
    service: body.service,
    spanName: body.spanName,
    statusCode: body.statusCode,
    minDurationMs: body.minDurationMs,
    resourceAttrs: normalizeAttrs(body.resourceAttrs),
    limit,
  });
  return c.json(rows);
});

app.get("/api/projects/:projectId/explore/traces/:traceId", async (c) => {
  const projectId = c.req.param("projectId");
  const traceId = c.req.param("traceId");
  await requireProjectAccess(c, projectId);
  if (!/^[a-fA-F0-9]{1,64}$/.test(traceId)) {
    throw new HTTPException(400, { message: "invalid trace id" });
  }
  const detail = await getTraceDetail(ch, projectId, traceId);
  return c.json(detail);
});

app.post("/api/projects/:projectId/explore/series", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as ExploreSeriesBody;
  const source: SeriesSource = body.source === "traces" ? "traces" : "logs";
  const filter = body.filter ?? {};
  const rangeSeconds = estimateRangeSeconds(filter.range);
  const step = pickStep(rangeSeconds);
  const rows = await countSeries(
    ch,
    projectId,
    source,
    {
      range: filter.range,
      service: filter.service,
      resourceAttrs: normalizeAttrs(filter.resourceAttrs),
      search: filter.search,
      severity: filter.severity,
      spanName: filter.spanName,
      statusCode: filter.statusCode,
      minDurationMs: filter.minDurationMs,
    },
    body.groupBy || undefined,
    step,
  );
  return c.json({ step: `${step.n} ${step.unit}`, rows });
});

app.get("/api/projects/:projectId/explore/metric-names", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const { since, until } = parseRangeQuery(c);
  const names = await listMetricNames(ch, projectId, { since, until });
  return c.json(names);
});

app.post("/api/projects/:projectId/explore/metric-series", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as MetricSeriesBody;
  const metricName = typeof body.metricName === "string" ? body.metricName : "";
  const filter = body.filter ?? {};
  const rangeSeconds = estimateRangeSeconds(filter.range);
  const step = pickStep(rangeSeconds);
  const aggregation =
    typeof body.aggregation === "string" &&
    (METRIC_AGGREGATIONS as readonly string[]).includes(body.aggregation)
      ? (body.aggregation as MetricAggregation)
      : undefined;
  const rows = await metricSeries(
    ch,
    projectId,
    metricName,
    {
      range: filter.range,
      service: filter.service,
      resourceAttrs: normalizeAttrs(filter.resourceAttrs),
    },
    body.groupBy || undefined,
    step,
    aggregation,
  );
  return c.json({ step: `${step.n} ${step.unit}`, rows });
});

app.post("/api/projects/:projectId/explore/metrics", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as ExploreListBody & { metricName?: string };
  const limit = clampLimit(body.limit, 100, 500);
  const rows = await queryMetrics(ch, projectId, {
    metricName: typeof body.metricName === "string" ? body.metricName : undefined,
    range: body.range,
    service: body.service,
    resourceAttrs: normalizeAttrs(body.resourceAttrs),
    limit,
  });
  return c.json(rows);
});

type MetricSeriesBody = {
  metricName?: string;
  groupBy?: string;
  aggregation?: string;
  filter?: Omit<ExploreListBody, "limit">;
};

type ExploreListBody = {
  range?: { since?: string; until?: string };
  service?: string;
  resourceAttrs?: ResourceAttrFilter[];
  search?: string;
  severity?: string;
  spanName?: string;
  statusCode?: string;
  minDurationMs?: number;
  limit?: number;
};

type ExploreSeriesBody = {
  source?: SeriesSource;
  groupBy?: string;
  filter?: Omit<ExploreListBody, "limit">;
};

function parseRangeQuery(c: Context<{ Variables: Vars }>): { since?: string; until?: string } {
  const since = c.req.query("since");
  const until = c.req.query("until");
  return { since, until };
}

function parseExploreAttributeSource(
  c: Context<{ Variables: Vars }>,
): SeriesSource | "metrics" | undefined {
  const source = c.req.query("source");
  if (source === "logs" || source === "traces" || source === "metrics") return source;
  return undefined;
}

function clampLimit(n: number | undefined, fallback: number, max: number): number {
  const v = Math.floor(Number(n ?? fallback));
  if (!Number.isFinite(v) || v <= 0) return fallback;
  return Math.min(v, max);
}

function isIncidentStatus(value: string): value is schema.IncidentStatus {
  return (
    value === "open" || value === "resolved" || value === "autoresolved_noise" || value === "merged"
  );
}

function normalizeAttrs(attrs: ResourceAttrFilter[] | undefined): ResourceAttrFilter[] | undefined {
  if (!Array.isArray(attrs)) return undefined;
  const out = attrs.filter(
    (a): a is ResourceAttrFilter =>
      !!a &&
      typeof a.key === "string" &&
      a.key.length > 0 &&
      typeof a.value === "string" &&
      (a.op === undefined || a.op === "eq" || a.op === "neq" || a.op === "not_contains"),
  );
  return out.length > 0 ? out : undefined;
}

function estimateRangeSeconds(range: { since?: string; until?: string } | undefined): number {
  if (!range?.since || !range?.until) return 3600;
  const s = Date.parse(range.since);
  const u = Date.parse(range.until);
  if (!Number.isFinite(s) || !Number.isFinite(u) || u <= s) return 3600;
  return (u - s) / 1000;
}

async function chCount(table: string, tsColumn: string, projectId: string): Promise<number> {
  const r = await ch.query({
    query: `
      SELECT count() AS c
      FROM ${table}
      WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
        AND ${tsColumn} > now() - INTERVAL 1 HOUR
    `,
    query_params: { projectId },
    format: "JSONEachRow",
  });
  const rows = (await r.json()) as { c: string | number }[];
  return Number(rows[0]?.c ?? 0);
}

async function chMetricsCount(projectId: string): Promise<number> {
  return tracer.startActiveSpan("metrics.aggregate_counts", async (span) => {
    span.setAttribute("tenant.project_id", projectId);
    try {
      const tables = [
        "otel_metrics_gauge",
        "otel_metrics_sum",
        "otel_metrics_histogram",
        "otel_metrics_summary",
        "otel_metrics_exp_histogram",
      ];
      span.setAttribute("metrics.table_count", tables.length);
      const counts = await Promise.all(tables.map((t) => chCount(t, "TimeUnix", projectId)));
      const total = counts.reduce((a, b) => a + b, 0);
      span.setAttribute("metrics.total", total);
      return total;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
      throw err;
    } finally {
      span.end();
    }
  });
}

async function requireProjectAccess(
  c: Context<{ Variables: Vars }>,
  projectId: string,
): Promise<void> {
  return tracer.startActiveSpan("project.authorize", async (span) => {
    span.setAttribute("tenant.project_id", projectId);
    span.setAttribute("superlog.user_id", c.var.userId);
    if (c.var.orgId) span.setAttribute("superlog.org_id_hint", c.var.orgId);
    try {
      const project = await db.query.projects.findFirst({
        where: eq(schema.projects.id, projectId),
      });
      if (!project) throw new HTTPException(404, { message: "project not found" });
      const ctx = await resolveActiveOrgContext({
        userId: c.var.userId,
        preferredOrgId: c.var.orgId,
      });
      if (project.orgId !== ctx.org.id) throw new HTTPException(403, { message: "forbidden" });
      span.setAttribute("superlog.org_id", ctx.org.id);
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
      throw err;
    } finally {
      span.end();
    }
  });
}

async function getProjectAutomation(projectId: string): Promise<{
  autoInvestigateIssuesEnabled: boolean;
  agentRunProvider: string;
  maxRuntimeMinutes: number;
  maxHumanResumeCount: number;
  customInstructions: string;
  agentRunEnabled: boolean;
  linearTicketPolicy: schema.LinearTicketPolicy;
  linearTicketInstructions: schema.LinearTicketInstruction[];
  prPolicy: schema.PrPolicy;
  prBaseBranch: string | null;
  autoMergeFixPrs: schema.AutoMergePolicy;
  autoMergeMethod: schema.AutoMergeMethod;
  issueFilterConfig: schema.IssueFilterConfig;
}> {
  const row = await db.query.projectAutomationSettings.findFirst({
    where: eq(schema.projectAutomationSettings.projectId, projectId),
  });
  return {
    autoInvestigateIssuesEnabled: row?.autoInvestigateIssuesEnabled ?? true,
    agentRunProvider: row?.agentRunProvider ?? DEFAULT_AGENT_RUN_PROVIDER,
    maxRuntimeMinutes: row?.maxRuntimeMinutes ?? 90,
    maxHumanResumeCount: row?.maxHumanResumeCount ?? 3,
    customInstructions: row?.customInstructions ?? "",
    agentRunEnabled: row?.agentRunEnabled ?? true,
    linearTicketPolicy: row?.linearTicketPolicy ?? "on_ready_to_pr",
    linearTicketInstructions: row?.linearTicketInstructions ?? [],
    prPolicy: row?.prPolicy ?? "on_ready_to_pr",
    prBaseBranch: schema.normalizePrBaseBranch(row?.prBaseBranch),
    autoMergeFixPrs: row?.autoMergeFixPrs ?? "never",
    autoMergeMethod: row?.autoMergeMethod ?? "squash",
    issueFilterConfig: row?.issueFilterConfig ?? schema.EMPTY_ISSUE_FILTER_CONFIG,
  };
}

function sanitizeClauseList(input: unknown): schema.IssueFilterClause[] {
  if (!Array.isArray(input)) return [];
  const out: schema.IssueFilterClause[] = [];
  const seen = new Set<string>();
  for (const item of input) {
    if (!item || typeof item !== "object") continue;
    const key =
      typeof (item as { key?: unknown }).key === "string"
        ? (item as { key: string }).key.trim()
        : "";
    const value =
      typeof (item as { value?: unknown }).value === "string"
        ? (item as { value: string }).value.trim()
        : "";
    if (!key || !value) continue;
    const dedupe = `${key.toLowerCase()}=${value}`;
    if (seen.has(dedupe)) continue;
    seen.add(dedupe);
    out.push({ key: key.slice(0, 200), value: value.slice(0, 400) });
    if (out.length >= 20) break;
  }
  return out;
}

function sanitizeIssueFilterConfig(
  input: unknown,
  fallback: schema.IssueFilterConfig,
): schema.IssueFilterConfig {
  if (!input || typeof input !== "object") return fallback;
  const o = input as Partial<Record<keyof schema.IssueFilterConfig, unknown>>;
  return {
    includeLogs: sanitizeClauseList(o.includeLogs),
    includeSpans: sanitizeClauseList(o.includeSpans),
    excludeLogs: sanitizeClauseList(o.excludeLogs),
    excludeSpans: sanitizeClauseList(o.excludeSpans),
  };
}

async function getProjectIncident(projectId: string, incidentId: string) {
  return db.query.incidents.findFirst({
    where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)),
  });
}

async function getLatestAgentRun(incidentId: string) {
  return db.query.agentRuns.findFirst({
    where: eq(schema.agentRuns.incidentId, incidentId),
    orderBy: [desc(schema.agentRuns.createdAt)],
  });
}

app.get("/api/projects/:projectId/issues", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const silencedParam = c.req.query("silenced") ?? "active";
  const limit = clampLimit(Number(c.req.query("limit") ?? 50), 50, 200);
  const projectFilter = eq(schema.issues.projectId, projectId);
  const where =
    silencedParam === "all"
      ? projectFilter
      : silencedParam === "silenced"
        ? and(projectFilter, isNotNull(schema.issues.silencedAt))
        : and(projectFilter, isNull(schema.issues.silencedAt));
  const rows = await db.query.issues.findMany({
    where,
    orderBy: [desc(schema.issues.lastSeen)],
    limit,
  });
  return c.json(rows);
});

app.get("/api/projects/:projectId/issues/:issueId", async (c) => {
  const projectId = c.req.param("projectId");
  const issueId = c.req.param("issueId");
  await requireProjectAccess(c, projectId);
  const issue = await db.query.issues.findFirst({
    where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)),
  });
  if (!issue) throw new HTTPException(404, { message: "issue not found" });
  const symbolication = await symbolicateIssueSample({
    database: db,
    objectReader: sourceMapObjectStore,
    projectId,
    sample: issue.lastSample,
  }).catch((err) => {
    logger.warn({ err, projectId, issueId }, "failed to symbolicate issue sample");
    return null;
  });
  return c.json({ ...issue, symbolication });
});

app.post("/api/projects/:projectId/issues/lookup", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as {
    kind?: "log" | "span";
    service?: string | null;
    severity?: string | null;
    body?: string | null;
    exceptionType?: string | null;
    exceptionMessage?: string | null;
    stacktrace?: string | null;
  };
  const kind = body.kind === "span" ? "span" : "log";
  const fp =
    kind === "span"
      ? fingerprint({
          type: body.exceptionType || body.severity || "Error",
          stacktrace: body.stacktrace ?? null,
          message: body.exceptionMessage ?? body.body ?? null,
        })
      : fingerprintLog({
          service: body.service ?? "",
          severity: body.severity ?? "",
          body: body.body ?? "",
          exceptionType: body.exceptionType ?? null,
          stacktrace: body.stacktrace ?? null,
        });
  const issue = await db.query.issues.findFirst({
    where: and(eq(schema.issues.projectId, projectId), eq(schema.issues.fingerprint, fp.hash)),
  });
  return c.json({ issue: issue ?? null });
});

app.post("/api/projects/:projectId/symbolication/log", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as {
    stacktrace?: string | null;
    logAttrs?: Record<string, string> | null;
    resourceAttrs?: Record<string, string> | null;
  };
  const symbolication = await symbolicateTelemetrySample({
    database: db,
    objectReader: sourceMapObjectStore,
    projectId,
    sample: {
      stacktrace: body.stacktrace ?? null,
      logAttrs: body.logAttrs ?? null,
      resourceAttrs: body.resourceAttrs ?? null,
    },
  }).catch((err) => {
    logger.warn({ err, projectId }, "failed to symbolicate log sample");
    return null;
  });
  return c.json({ symbolication });
});

app.get("/api/projects/:projectId/issues/:issueId/agent-run", async (c) => {
  const projectId = c.req.param("projectId");
  const issueId = c.req.param("issueId");
  await requireProjectAccess(c, projectId);

  const issue = await db.query.issues.findFirst({
    where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)),
  });
  if (!issue) throw new HTTPException(404, { message: "issue not found" });

  const link = await db.query.incidentIssues.findFirst({
    where: eq(schema.incidentIssues.issueId, issueId),
  });
  if (!link) return c.json({ incident: null, agentRun: null, events: [] });

  const incident = await getProjectIncident(projectId, link.incidentId);
  if (!incident) return c.json({ incident: null, agentRun: null, events: [] });

  const agentRun = await getLatestAgentRun(incident.id);
  if (!agentRun) return c.json({ incident, agentRun: null, events: [] });

  const events = await loadIncidentTimeline(incident.id, agentRun.id);

  return c.json({ incident, agentRun, events });
});

app.patch("/api/projects/:projectId/incidents/:incidentId", async (c) => {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as { status?: string };
  const status = body.status;
  if (status !== "open" && status !== "resolved") {
    throw new HTTPException(400, { message: "status must be 'open' or 'resolved'" });
  }
  const existing = await db.query.incidents.findFirst({
    where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)),
  });
  if (!existing) throw new HTTPException(404, { message: "incident not found" });

  if (status === "resolved") {
    // Route the dashboard's mark-resolved through the shared helper so the
    // resolved_* columns are populated exactly like every other resolve path.
    await resolveIncident({
      incidentId,
      kind: "dashboard_manual",
      reasonCode: "dashboard_manual",
      reasonText: `Resolved from the dashboard by user ${c.var.userId}.`,
      resolvedByUserId: c.var.userId,
    });
    if (shouldRunResolvedIncidentSideEffects({ requestedStatus: status, incidentExists: true })) {
      await runResolvedIncidentSideEffectsForIncident({
        incidentId,
        closePullRequest: (pr) =>
          closeAgentPullRequestOnGithub({
            installationId: pr.githubInstallationId,
            fallbackInstallationIds: pr.fallbackGithubInstallationIds,
            repoFullName: pr.repoFullName,
            prNumber: pr.prNumber,
            prNodeId: pr.prNodeId,
          }),
      });
    }
  } else {
    await incidentLifecycle.reopenManually({
      incident: existing,
      actor: { userId: c.var.userId },
      summary: "Incident reopened from the dashboard.",
      detail: { reason: "dashboard_manual" },
    });
  }

  const updated = await db.query.incidents.findFirst({
    where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)),
  });
  return c.json(updated);
});

// Confirm/dismiss a pending resolution proposal from the dashboard. The
// proposalId in the path is the source of truth — projectId/incidentId
// are validated for access control and to make the URL self-documenting.
// Both routes route through the same shared helper the Slack handler
// uses, so confirmed/dismissed proposals from either surface look
// identical in the audit log.
async function decideResolutionProposal(
  c: Context<{ Variables: Vars }>,
  decision: "confirm" | "dismiss",
): Promise<Response> {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  const proposalId = c.req.param("proposalId");
  if (!projectId || !incidentId || !proposalId) {
    throw new HTTPException(400, { message: "missing path parameter" });
  }
  await requireProjectAccess(c, projectId);

  const proposal = await db.query.incidentResolutionProposals.findFirst({
    where: eq(schema.incidentResolutionProposals.id, proposalId),
  });
  if (!proposal || proposal.incidentId !== incidentId) {
    throw new HTTPException(404, { message: "proposal not found" });
  }
  // Make sure the proposal belongs to this project — defence in depth on
  // the project-access check above (the incident's projectId is the
  // authoritative one).
  const incident = await db.query.incidents.findFirst({
    where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)),
  });
  if (!incident) throw new HTTPException(404, { message: "incident not found" });

  // Look up the user's name for the attribution phrase. Skip if the user
  // row is gone (e.g. just-deleted account); the helper falls back to the
  // user id.
  const user = await db.query.users.findFirst({
    where: eq(schema.users.id, c.var.userId),
    columns: { id: true, name: true, email: true },
  });
  const displayName = user?.name?.trim() || user?.email || null;

  const actor = { userId: c.var.userId, displayName };
  const result =
    decision === "confirm"
      ? await confirmResolutionProposal({ proposalId, actor })
      : await dismissResolutionProposal({ proposalId, actor });
  if (!result.ok) {
    // already_confirmed / already_dismissed land as 409 so the dashboard
    // can refresh and show the up-to-date state without treating it as a
    // hard error.
    if (result.reason?.startsWith("already_")) {
      throw new HTTPException(409, { message: result.reason });
    }
    throw new HTTPException(400, { message: result.reason ?? "decision failed" });
  }
  if (decision === "confirm" && result.incidentId) {
    await runResolvedIncidentSideEffectsForIncident({
      incidentId: result.incidentId,
      closePullRequest: (pr) =>
        closeAgentPullRequestOnGithub({
          installationId: pr.githubInstallationId,
          fallbackInstallationIds: pr.fallbackGithubInstallationIds,
          repoFullName: pr.repoFullName,
          prNumber: pr.prNumber,
          prNodeId: pr.prNodeId,
        }),
    });
  }
  return c.json({ ok: true, incidentId, proposalId, decision });
}

app.post(
  "/api/projects/:projectId/incidents/:incidentId/resolution-proposals/:proposalId/confirm",
  (c) => decideResolutionProposal(c, "confirm"),
);
app.post(
  "/api/projects/:projectId/incidents/:incidentId/resolution-proposals/:proposalId/dismiss",
  (c) => decideResolutionProposal(c, "dismiss"),
);

app.post("/api/projects/:projectId/issues/:issueId/silence", async (c) => {
  const projectId = c.req.param("projectId");
  const issueId = c.req.param("issueId");
  await requireProjectAccess(c, projectId);
  return tracer.startActiveSpan("issue.silence", async (span) => {
    span.setAttribute("tenant.project_id", projectId);
    span.setAttribute("issue.id", issueId);
    try {
      const updated = await db
        .update(schema.issues)
        .set({ silencedAt: new Date() })
        .where(
          and(
            eq(schema.issues.id, issueId),
            eq(schema.issues.projectId, projectId),
            isNull(schema.issues.silencedAt),
          ),
        )
        .returning();
      if (!updated[0]) {
        span.setAttribute("issue.silence.applied", false);
        const existing = await db.query.issues.findFirst({
          where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)),
        });
        if (!existing) throw new HTTPException(404, { message: "issue not found" });
        return c.json(existing);
      }
      span.setAttribute("issue.silence.applied", true);
      return c.json(updated[0]);
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
      throw err;
    } finally {
      span.end();
    }
  });
});

app.post("/api/projects/:projectId/issues/:issueId/unsilence", async (c) => {
  const projectId = c.req.param("projectId");
  const issueId = c.req.param("issueId");
  await requireProjectAccess(c, projectId);
  const existing = await db.query.issues.findFirst({
    where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)),
  });
  if (!existing) throw new HTTPException(404, { message: "issue not found" });
  if (!existing.silencedAt) return c.json(existing);
  const conflict = await db.query.issues.findFirst({
    where: and(
      eq(schema.issues.projectId, projectId),
      eq(schema.issues.fingerprint, existing.fingerprint),
      isNull(schema.issues.silencedAt),
    ),
  });
  if (conflict) {
    throw new HTTPException(409, {
      message: "another active issue already exists for this fingerprint",
    });
  }
  const updated = await db
    .update(schema.issues)
    .set({ silencedAt: null })
    .where(and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)))
    .returning();
  return c.json(updated[0] ?? existing);
});

app.get("/api/projects/:projectId/automation", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const current = await getProjectAutomation(projectId);
  return c.json({ projectId, ...current });
});

// Picker support for the Issue filter editor — keys/values/preview are drawn
// from ERROR events only (logs SeverityNumber>=17, traces with exception
// event) so the UI matches the population the worker filter applies to.
const ISSUE_FILTER_RANGE = "now() - INTERVAL 24 HOUR";

app.get("/api/projects/:projectId/issue-filter/attribute-keys", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const rows = await listIssueFilterAttributeKeys(ch, projectId, {
    since: ISSUE_FILTER_RANGE,
  });
  return c.json(rows);
});

app.get("/api/projects/:projectId/issue-filter/attribute-values", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const key = c.req.query("key");
  if (!key) throw new HTTPException(400, { message: "key is required" });
  const rows = await listIssueFilterAttributeValues(ch, projectId, key, {
    since: ISSUE_FILTER_RANGE,
  });
  return c.json(rows);
});

// POST because the filter is now a structured object with 4 lists, which
// doesn't fit cleanly in a query string.
app.post("/api/projects/:projectId/issue-filter/preview", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const body = (await c.req.json().catch(() => ({}))) as {
    config?: unknown;
  };
  const config = sanitizeIssueFilterConfig(body.config, schema.EMPTY_ISSUE_FILTER_CONFIG);
  const events = await previewIssueFilterMatches(
    ch,
    projectId,
    config,
    { since: ISSUE_FILTER_RANGE },
    10,
  );
  return c.json({ events });
});

const VALID_AGENT_POLICIES: ReadonlySet<string> = new Set(["never", "on_ready_to_pr", "always"]);
const VALID_AUTO_MERGE_POLICIES: ReadonlySet<string> = new Set([
  "never",
  "when_checks_pass",
  "immediately",
]);
const VALID_AUTO_MERGE_METHODS: ReadonlySet<string> = new Set(["squash", "merge", "rebase"]);
const MAX_INSTRUCTIONS_LEN = 8000;

function parsePrBaseBranch(input: unknown, current: string | null): string | null {
  if (input === undefined) return current;
  if (input === null) return null;
  if (typeof input !== "string") return current;
  const branch = schema.normalizePrBaseBranch(input);
  if (branch && !schema.isValidPrBaseBranch(branch)) {
    throw new HTTPException(400, {
      message:
        "prBaseBranch must be a valid Git branch name, or blank to use the repository default",
    });
  }
  return branch;
}

// Branches the agent could target for PRs: the union across the project's
// enabled GitHub repos. Powers the strict PR-target-branch picker in Settings.
app.get("/api/projects/:projectId/github/branches", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);

  const result = await listProjectRepoBranches(projectId);
  if (result.errored) {
    return c.json({ error: "failed to load branches from GitHub" }, 502);
  }
  return c.json({ branches: result.branches });
});

app.patch("/api/projects/:projectId/automation", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);

  const current = await getProjectAutomation(projectId);
  const body = (await c.req.json().catch(() => ({}))) as {
    autoInvestigateIssuesEnabled?: unknown;
    agentRunProvider?: unknown;
    maxRuntimeMinutes?: unknown;
    maxHumanResumeCount?: unknown;
    customInstructions?: unknown;
    agentRunEnabled?: unknown;
    linearTicketPolicy?: unknown;
    linearTicketInstructions?: unknown;
    prPolicy?: unknown;
    prBaseBranch?: unknown;
    autoMergeFixPrs?: unknown;
    autoMergeMethod?: unknown;
    issueFilterConfig?: unknown;
  };

  const autoInvestigateIssuesEnabled =
    typeof body.autoInvestigateIssuesEnabled === "boolean"
      ? body.autoInvestigateIssuesEnabled
      : current.autoInvestigateIssuesEnabled;
  const agentRunProvider =
    typeof body.agentRunProvider === "string" ? body.agentRunProvider : current.agentRunProvider;
  const maxRuntimeMinutes =
    typeof body.maxRuntimeMinutes === "number"
      ? Math.floor(body.maxRuntimeMinutes)
      : current.maxRuntimeMinutes;
  const maxHumanResumeCount =
    typeof body.maxHumanResumeCount === "number"
      ? Math.floor(body.maxHumanResumeCount)
      : current.maxHumanResumeCount;
  const customInstructions =
    typeof body.customInstructions === "string"
      ? body.customInstructions.slice(0, MAX_INSTRUCTIONS_LEN)
      : current.customInstructions;
  const agentRunEnabled =
    typeof body.agentRunEnabled === "boolean" ? body.agentRunEnabled : current.agentRunEnabled;
  const linearTicketPolicy: schema.LinearTicketPolicy =
    typeof body.linearTicketPolicy === "string" && VALID_AGENT_POLICIES.has(body.linearTicketPolicy)
      ? (body.linearTicketPolicy as schema.LinearTicketPolicy)
      : current.linearTicketPolicy;
  const linearTicketInstructions: schema.LinearTicketInstruction[] = (() => {
    if (!Array.isArray(body.linearTicketInstructions)) return current.linearTicketInstructions;
    const validated = body.linearTicketInstructions
      .filter(
        (item): item is { id: string; title: string; text: string } =>
          item !== null &&
          typeof item === "object" &&
          typeof item.id === "string" &&
          typeof item.title === "string" &&
          typeof item.text === "string",
      )
      .slice(0, 20)
      .map((item) => ({
        id: item.id,
        title: item.title.slice(0, 200),
        text: item.text.slice(0, 2000),
      }));
    return validated;
  })();
  const prPolicy: schema.PrPolicy =
    typeof body.prPolicy === "string" && VALID_AGENT_POLICIES.has(body.prPolicy)
      ? (body.prPolicy as schema.PrPolicy)
      : current.prPolicy;
  const prBaseBranch = parsePrBaseBranch(body.prBaseBranch, current.prBaseBranch);
  const autoMergeFixPrs: schema.AutoMergePolicy =
    typeof body.autoMergeFixPrs === "string" && VALID_AUTO_MERGE_POLICIES.has(body.autoMergeFixPrs)
      ? (body.autoMergeFixPrs as schema.AutoMergePolicy)
      : current.autoMergeFixPrs;
  const autoMergeMethod: schema.AutoMergeMethod =
    typeof body.autoMergeMethod === "string" && VALID_AUTO_MERGE_METHODS.has(body.autoMergeMethod)
      ? (body.autoMergeMethod as schema.AutoMergeMethod)
      : current.autoMergeMethod;
  const issueFilterConfig =
    body.issueFilterConfig !== undefined
      ? sanitizeIssueFilterConfig(body.issueFilterConfig, current.issueFilterConfig)
      : current.issueFilterConfig;

  if (!isAgentRunProvider(agentRunProvider)) {
    throw new HTTPException(400, {
      message: "agentRunProvider must be one of: community, anthropic, disabled",
    });
  }
  if (!Number.isFinite(maxRuntimeMinutes) || maxRuntimeMinutes < 1 || maxRuntimeMinutes > 720) {
    throw new HTTPException(400, { message: "maxRuntimeMinutes must be between 1 and 720" });
  }
  if (
    !Number.isFinite(maxHumanResumeCount) ||
    maxHumanResumeCount < 0 ||
    maxHumanResumeCount > 10
  ) {
    throw new HTTPException(400, { message: "maxHumanResumeCount must be between 0 and 10" });
  }

  // When the target branch changed to a non-blank value, confirm it actually
  // exists in one of the project's repos. Skipped when GitHub can't be reached
  // (errored) so a transient API failure can't lock the user out of saving
  // unrelated settings — the worker still falls back to the repo default at PR
  // time if the branch later disappears.
  if (prBaseBranch && prBaseBranch !== current.prBaseBranch) {
    const { branches, errored } = await listProjectRepoBranches(projectId);
    if (!errored && !prBaseBranchExists(prBaseBranch, branches)) {
      throw new HTTPException(400, {
        message: `Branch "${prBaseBranch}" was not found in this project's connected repositories.`,
      });
    }
  }

  const values = {
    projectId,
    autoInvestigateIssuesEnabled,
    agentRunProvider,
    maxRuntimeMinutes,
    maxHumanResumeCount,
    customInstructions,
    agentRunEnabled,
    linearTicketPolicy,
    linearTicketInstructions,
    prPolicy,
    prBaseBranch,
    autoMergeFixPrs,
    autoMergeMethod,
    issueFilterConfig,
    updatedAt: new Date(),
  };

  const updated = await db
    .insert(schema.projectAutomationSettings)
    .values(values)
    .onConflictDoUpdate({
      target: schema.projectAutomationSettings.projectId,
      set: {
        autoInvestigateIssuesEnabled,
        agentRunProvider,
        maxRuntimeMinutes,
        maxHumanResumeCount,
        customInstructions,
        agentRunEnabled,
        linearTicketPolicy,
        linearTicketInstructions,
        prPolicy,
        prBaseBranch,
        autoMergeFixPrs,
        autoMergeMethod,
        issueFilterConfig,
        updatedAt: new Date(),
      },
    })
    .returning();

  return c.json(updated[0] ?? values);
});

app.get("/api/projects/:projectId/incidents", async (c) => {
  const projectId = c.req.param("projectId");
  await requireProjectAccess(c, projectId);
  const limit = clampLimit(Number(c.req.query("limit") ?? 50), 50, 200);
  const status = c.req.query("status");
  const incidentStatus = status && status !== "all" && isIncidentStatus(status) ? status : null;
  if (status && status !== "all" && !incidentStatus) {
    throw new HTTPException(400, { message: "invalid incident status" });
  }

  // Default view hides agent-classified noise. Pass ?status=all to include it,
  // or ?status=autoresolved_noise to inspect just the noise pile.
  const baseFilter = eq(schema.incidents.projectId, projectId);
  let where = !status
    ? and(baseFilter, ne(schema.incidents.status, "autoresolved_noise"))
    : baseFilter;
  if (incidentStatus) {
    where = and(baseFilter, eq(schema.incidents.status, incidentStatus));
  }
  const incidents = await db.query.incidents.findMany({
    where,
    orderBy: [desc(schema.incidents.lastSeen)],
    limit,
  });
  const [agentRuns, pendingProposals] = await Promise.all([
    Promise.all(incidents.map((incident) => getLatestAgentRun(incident.id))),
    loadPendingResolutionProposals(incidents.map((i) => i.id)),
  ]);

  // Graph stats hit ClickHouse and can be slow on large projects. Keep the list
  // itself Postgres-only by default; callers that still need the legacy inline
  // shape can opt in with ?includeStats=1 while the web UI lazy-loads rows.
  const inlineStats = shouldInlineIncidentListStats(c.req.query("includeStats"));
  const statsByIncidentId = inlineStats
    ? await loadIncidentsBucketStats(
        projectId,
        incidents.map((i) => i.id),
      )
    : new Map<string, IncidentBucketStats>();
  const emptyStats: IncidentBucketStats = {
    buckets: buildBucketsFromMap(new Map(), INCIDENT_STATS_WINDOW_DAYS),
    impactedUsers: 0,
    impactedUsersAvailable: false,
    impactedUsersCapped: false,
  };

  return c.json(
    incidents.map((incident, index) =>
      buildIncidentListItem({
        incident,
        agentRun: agentRuns[index] ?? null,
        pendingResolutionProposal: pendingProposals.get(incident.id) ?? null,
        stats: inlineStats
          ? {
              windowDays: INCIDENT_STATS_WINDOW_DAYS,
              ...(statsByIncidentId.get(incident.id) ?? emptyStats),
            }
          : undefined,
      }),
    ),
  );
});

type IncidentBucketStats = {
  buckets: { day: string; count: number }[];
  impactedUsers: number;
  impactedUsersAvailable: boolean;
  impactedUsersCapped: boolean;
};

// Bulk version of the per-incident stats query, used by the incidents list.
// Two ClickHouse round-trips: one returns (svc, et, day, count) tuples for
// the sparkline, the other returns (svc, et) → distinct user.id arrays.
// We then map each tuple back to whichever incident's issues had that
// (svc, et) pair, summing counts and Set-unioning users per-incident.
// Same approximation as the single-incident endpoint — see
// `fetchIncidentTimeseriesPairs`.
async function loadIncidentsBucketStats(
  projectId: string,
  incidentIds: string[],
): Promise<Map<string, IncidentBucketStats>> {
  if (incidentIds.length === 0) return new Map();

  const links = await db.query.incidentIssues.findMany({
    where: inArray(schema.incidentIssues.incidentId, incidentIds),
  });
  if (links.length === 0) return new Map();

  const issues = await db.query.issues.findMany({
    where: inArray(
      schema.issues.id,
      links.map((l) => l.issueId),
    ),
  });
  if (issues.length === 0) return new Map();

  const issueById = new Map(issues.map((i) => [i.id, i] as const));
  // incidentId -> list of (kind, svc, et) it cares about
  const incidentPairs = new Map<string, { kind: "span" | "log"; svc: string; et: string }[]>();
  for (const link of links) {
    const issue = issueById.get(link.issueId);
    if (!issue) continue;
    const arr = incidentPairs.get(link.incidentId) ?? [];
    arr.push({
      kind: issue.kind === "log" ? "log" : "span",
      svc: issue.service ?? "",
      et: issue.exceptionType ?? "",
    });
    incidentPairs.set(link.incidentId, arr);
  }

  // Union all pairs into the CH query (one round-trip).
  const spanServices: string[] = [];
  const spanExcTypes: string[] = [];
  const logServices: string[] = [];
  const logExcTypes: string[] = [];
  const seenSpan = new Set<string>();
  const seenLog = new Set<string>();
  for (const pairs of incidentPairs.values()) {
    for (const p of pairs) {
      const key = pairKey2(p.svc, p.et);
      if (p.kind === "log") {
        if (!seenLog.has(key)) {
          seenLog.add(key);
          logServices.push(p.svc);
          logExcTypes.push(p.et);
        }
      } else {
        if (!seenSpan.has(key)) {
          seenSpan.add(key);
          spanServices.push(p.svc);
          spanExcTypes.push(p.et);
        }
      }
    }
  }

  // Delimiter unlikely to appear in service / exception_type — keeps the
  // composite map key unambiguous so ("ab","c") and ("a","bc") don't collide.
  const tupleKey = (kind: string, svc: string, et: string) => `${kind}${svc}${et}`;

  const [rows, userRows] = await Promise.all([
    fetchIncidentTimeseriesPairs({
      projectId,
      spanServices,
      spanExcTypes,
      logServices,
      logExcTypes,
      windowDays: INCIDENT_STATS_WINDOW_DAYS,
    }),
    fetchIncidentUserIdsByPair({
      projectId,
      spanServices,
      spanExcTypes,
      logServices,
      logExcTypes,
      windowDays: INCIDENT_STATS_WINDOW_DAYS,
    }),
  ]);

  const tupleCounts = new Map<string, Map<string, number>>();
  for (const r of rows) {
    const key = tupleKey(r.kind, r.svc, r.et);
    let byDay = tupleCounts.get(key);
    if (!byDay) {
      byDay = new Map();
      tupleCounts.set(key, byDay);
    }
    byDay.set(r.day, (byDay.get(r.day) ?? 0) + r.count);
  }
  const tupleUsers = new Map<string, { users: string[]; capped: boolean }>();
  for (const r of userRows) {
    tupleUsers.set(tupleKey(r.kind, r.svc, r.et), {
      users: r.users,
      // groupUniqArray returns up to N rows; if we got exactly N we likely
      // truncated. A false positive here just shows "200+" once — better
      // than under-reporting a genuinely viral incident.
      capped: r.users.length >= INCIDENT_USERS_CAP,
    });
  }

  const out = new Map<string, IncidentBucketStats>();
  for (const [incidentId, pairs] of incidentPairs) {
    const merged = new Map<string, number>();
    const userUnion = new Set<string>();
    let capped = false;
    // A single incident may have two issues whose (kind, svc, et) tuples are
    // equal; dedupe so we don't double-count counts or users.
    const tupleKeys = new Set(pairs.map((p) => tupleKey(p.kind, p.svc, p.et)));
    for (const key of tupleKeys) {
      const byDay = tupleCounts.get(key);
      if (byDay) for (const [day, c] of byDay) merged.set(day, (merged.get(day) ?? 0) + c);
      const tu = tupleUsers.get(key);
      if (tu) {
        for (const uid of tu.users) userUnion.add(uid);
        if (tu.capped) capped = true;
      }
    }
    out.set(incidentId, {
      buckets: buildBucketsFromMap(merged, INCIDENT_STATS_WINDOW_DAYS),
      impactedUsers: userUnion.size,
      impactedUsersAvailable: userUnion.size > 0,
      impactedUsersCapped: capped,
    });
  }
  return out;
}

app.get("/api/projects/:projectId/incidents/:incidentId", async (c) => {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  await requireProjectAccess(c, projectId);

  const incident = await getProjectIncident(projectId, incidentId);
  if (!incident) throw new HTTPException(404, { message: "incident not found" });

  const [links, agentRuns] = await Promise.all([
    db.query.incidentIssues.findMany({
      where: eq(schema.incidentIssues.incidentId, incidentId),
      orderBy: [desc(schema.incidentIssues.createdAt)],
    }),
    db.query.agentRuns.findMany({
      where: eq(schema.agentRuns.incidentId, incident.id),
      orderBy: [desc(schema.agentRuns.createdAt)],
    }),
  ]);
  const issues =
    links.length > 0
      ? await db.query.issues.findMany({
          where: inArray(
            schema.issues.id,
            links.map((link) => link.issueId),
          ),
          orderBy: [desc(schema.issues.lastSeen)],
        })
      : [];

  // Timeline is the incident's own — events from incident_events scoped to
  // this incident (and any of its agent runs), plus PR/Linear ticket events.
  // Findings (root cause, severity, etc.) on `incident` are the primary
  // source of truth; the timeline + agent-run history ride along on the
  // same GET so the detail panel renders in one round trip.
  const latestAgentRun = agentRuns[0] ?? null;
  const [timeline, pendingProposalMap] = await Promise.all([
    latestAgentRun ? loadIncidentTimeline(incident.id, latestAgentRun.id) : Promise.resolve([]),
    loadPendingResolutionProposals([incident.id]),
  ]);

  return c.json({
    incident,
    issues,
    agentRun: latestAgentRun,
    agentRuns,
    timeline,
    pendingResolutionProposal: pendingProposalMap.get(incident.id) ?? null,
  });
});

app.get("/api/projects/:projectId/incidents/:incidentId/pull-requests", async (c) => {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  await requireProjectAccess(c, projectId);

  const incident = await getProjectIncident(projectId, incidentId);
  if (!incident) throw new HTTPException(404, { message: "incident not found" });

  const [prs, agentRuns] = await Promise.all([
    db.query.agentPullRequests.findMany({
      where: eq(schema.agentPullRequests.incidentId, incidentId),
      orderBy: [desc(schema.agentPullRequests.createdAt)],
    }),
    db.query.agentRuns.findMany({
      where: eq(schema.agentRuns.incidentId, incidentId),
    }),
  ]);

  return c.json(buildIncidentPullRequestViews(prs, agentRuns));
});

const VALID_MANUAL_MERGE_METHODS = new Set(["squash", "merge", "rebase"]);

app.post("/api/projects/:projectId/incidents/:incidentId/pull-requests/:prId/merge", async (c) => {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  const prId = c.req.param("prId");
  await requireProjectAccess(c, projectId);

  const incident = await getProjectIncident(projectId, incidentId);
  if (!incident) throw new HTTPException(404, { message: "incident not found" });

  const pr = await db.query.agentPullRequests.findFirst({
    where: and(
      eq(schema.agentPullRequests.id, prId),
      eq(schema.agentPullRequests.incidentId, incidentId),
    ),
  });
  if (!pr) throw new HTTPException(404, { message: "pull request not found" });
  if (pr.state !== "open") {
    throw new HTTPException(409, { message: `pull request is already ${pr.state}` });
  }

  const installation = await db.query.githubInstallations.findFirst({
    where: eq(schema.githubInstallations.id, pr.installationId),
  });
  if (!installation || installation.revokedAt) {
    throw new HTTPException(409, { message: "github installation is unavailable" });
  }

  const body = (await c.req.json().catch(() => ({}))) as { method?: unknown };
  const method =
    typeof body.method === "string" && VALID_MANUAL_MERGE_METHODS.has(body.method)
      ? (body.method as "squash" | "merge" | "rebase")
      : "squash";

  const merged = await mergeGithubPullRequest({
    installationId: installation.installationId,
    repoFullName: pr.repoFullName,
    prNumber: pr.prNumber,
    method,
  });

  const now = new Date();
  const [updatedPr] = await db
    .update(schema.agentPullRequests)
    .set({
      state: "merged",
      mergedAt: now,
      closedAt: now,
      headSha: merged.sha ?? pr.headSha,
      lastSyncedAt: now,
      updatedAt: now,
    })
    .where(eq(schema.agentPullRequests.id, pr.id))
    .returning();

  await db
    .insert(schema.agentPrEvents)
    .values({
      agentPrId: pr.id,
      kind: "pr_merged",
      summary: `PR #${pr.prNumber} merged from dashboard`,
      payload: { method, sha: merged.sha, prUrl: pr.url, repoFullName: pr.repoFullName },
      providerEventId: `dashboard_merge:${pr.id}`,
      occurredAt: now,
    })
    .onConflictDoNothing();

  await resolveIncident({
    incidentId: pr.incidentId,
    kind: "agent_pr_merged",
    reasonCode: "agent_pr_merged",
    reasonText: `Resolved because agent PR #${pr.prNumber} (${pr.repoFullName}) was merged.`,
    agentRunId: pr.agentRunId,
    eventSummary: `Incident resolved because PR #${pr.prNumber} was merged.`,
    eventDetail: {
      agentPrId: pr.id,
      repoFullName: pr.repoFullName,
      prNumber: pr.prNumber,
      prUrl: pr.url,
      mergedByLogin: null,
    },
    eventDedupeKey: `incident_resolved:agent_pr:${pr.id}`,
    resolvedAt: now,
  });

  const agentRun = await db.query.agentRuns.findFirst({
    where: eq(schema.agentRuns.id, pr.agentRunId),
  });

  return c.json({
    ok: true,
    sha: merged.sha,
    pullRequest: updatedPr
      ? (buildIncidentPullRequestViews([updatedPr], agentRun ? [agentRun] : [])[0] ?? null)
      : null,
  });
});

const INCIDENT_STATS_WINDOW_DAYS = 14;

// Composite-key delimiter for (kind, service, exception_type) tuples. ASCII SOH
// is never a legal char in any of those fields, so it guarantees ("ab","c") and
// ("a","bc") get distinct keys.
const PAIR_KEY_SEP = "";
const pairKey2 = (svc: string, et: string) => `${svc}${PAIR_KEY_SEP}${et}`;

// Backfills the last N days as zero so the bar chart always shows a continuous timeline.
function buildBucketsFromMap(
  countsByDay: Map<string, number>,
  windowDays: number,
): { day: string; count: number }[] {
  const buckets: { day: string; count: number }[] = [];
  const today = new Date();
  today.setUTCHours(0, 0, 0, 0);
  for (let i = windowDays - 1; i >= 0; i--) {
    const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
    const key = d.toISOString().slice(0, 10);
    buckets.push({ day: key, count: countsByDay.get(key) ?? 0 });
  }
  return buckets;
}

// Per-incident user-id sample cap. groupUniqArray(N) limits the response payload
// so a viral incident doesn't blow up the list endpoint; the per-incident count
// displayed in the UI is exact up to this cap, and "200+" thereafter.
const INCIDENT_USERS_CAP = 200;

// Bulk fetch distinct user IDs per (kind, service, exception_type) across a project.
// Caller maps the returned tuples back to incidents and Set-unions per-incident.
async function fetchIncidentUserIdsByPair(args: {
  projectId: string;
  spanServices: string[];
  spanExcTypes: string[];
  logServices: string[];
  logExcTypes: string[];
  windowDays: number;
}): Promise<{ kind: "span" | "log"; svc: string; et: string; users: string[] }[]> {
  const { projectId, spanServices, spanExcTypes, logServices, logExcTypes, windowDays } = args;
  if (spanServices.length === 0 && logServices.length === 0) return [];

  const spanWhere =
    spanServices.length === 0
      ? "0"
      : `(coalesce(ServiceName, ''), coalesce(event_attrs['exception.type'], '')) IN arrayZip({spanServices:Array(String)}, {spanExcTypes:Array(String)})`;
  const logWhere =
    logServices.length === 0
      ? "0"
      : `(coalesce(ServiceName, ''), coalesce(LogAttributes['exception.type'], '')) IN arrayZip({logServices:Array(String)}, {logExcTypes:Array(String)})`;

  const query = `
    SELECT 'span' AS kind,
      coalesce(ServiceName, '') AS svc,
      coalesce(event_attrs['exception.type'], '') AS et,
      groupUniqArray(${INCIDENT_USERS_CAP})(nullIf(coalesce(SpanAttributes['user.id'], ResourceAttributes['user.id']), '')) AS users
    FROM (
      SELECT Timestamp, ServiceName, SpanAttributes, ResourceAttributes, Events.Name, Events.Attributes
      FROM otel_traces
      WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY
        AND ResourceAttributes['superlog.project_id'] = {projectId:String}
        AND has({spanServices:Array(String)}, coalesce(ServiceName, ''))
        AND has(Events.Name, 'exception')
    )
    ARRAY JOIN Events.Name AS event_name, Events.Attributes AS event_attrs
    WHERE event_name = 'exception'
      AND ${spanWhere}
    GROUP BY svc, et
    UNION ALL
    SELECT 'log' AS kind,
      coalesce(ServiceName, '') AS svc,
      coalesce(LogAttributes['exception.type'], '') AS et,
      groupUniqArray(${INCIDENT_USERS_CAP})(nullIf(coalesce(LogAttributes['user.id'], ResourceAttributes['user.id']), '')) AS users
    FROM otel_logs
    WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY
      AND ResourceAttributes['superlog.project_id'] = {projectId:String}
      AND SeverityNumber >= 17
      AND has({logServices:Array(String)}, coalesce(ServiceName, ''))
      AND ${logWhere}
    GROUP BY svc, et
  `;

  const res = await ch.query({
    query,
    query_params: {
      projectId,
      days: windowDays,
      spanServices,
      spanExcTypes,
      logServices,
      logExcTypes,
    },
    format: "JSONEachRow",
  });
  const rows = (await res.json()) as {
    kind: "span" | "log";
    svc: string;
    et: string;
    users: string[];
  }[];
  return rows;
}

// Bulk fetch per-(service, exception_type, day) counts for spans + logs across a project.
// Caller maps the returned tuples back to incidents. The incident → telemetry link is
// approximate: fingerprints live in pg only, so we filter by the fingerprint *inputs*
// (exception.type, optionally service). This over-counts slightly when two distinct
// stacktraces share an exception type in the same service — fine for a sparkline.
async function fetchIncidentTimeseriesPairs(args: {
  projectId: string;
  spanServices: string[];
  spanExcTypes: string[];
  logServices: string[];
  logExcTypes: string[];
  windowDays: number;
}): Promise<{ svc: string; et: string; day: string; count: number; kind: "span" | "log" }[]> {
  const { projectId, spanServices, spanExcTypes, logServices, logExcTypes, windowDays } = args;
  if (spanServices.length === 0 && logServices.length === 0) return [];

  const spanWhere =
    spanServices.length === 0
      ? "0"
      : `(coalesce(ServiceName, ''), coalesce(event_attrs['exception.type'], '')) IN arrayZip({spanServices:Array(String)}, {spanExcTypes:Array(String)})`;
  const logWhere =
    logServices.length === 0
      ? "0"
      : `(coalesce(ServiceName, ''), coalesce(LogAttributes['exception.type'], '')) IN arrayZip({logServices:Array(String)}, {logExcTypes:Array(String)})`;

  const query = `
    SELECT 'span' AS kind,
      coalesce(ServiceName, '') AS svc,
      coalesce(event_attrs['exception.type'], '') AS et,
      toString(toDate(Timestamp)) AS day,
      count() AS c
    FROM (
      SELECT Timestamp, ServiceName, Events.Name, Events.Attributes
      FROM otel_traces
      WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY
        AND ResourceAttributes['superlog.project_id'] = {projectId:String}
        AND has({spanServices:Array(String)}, coalesce(ServiceName, ''))
        AND has(Events.Name, 'exception')
    )
    ARRAY JOIN Events.Name AS event_name, Events.Attributes AS event_attrs
    WHERE event_name = 'exception'
      AND ${spanWhere}
    GROUP BY svc, et, day
    UNION ALL
    SELECT 'log' AS kind,
      coalesce(ServiceName, '') AS svc,
      coalesce(LogAttributes['exception.type'], '') AS et,
      toString(toDate(Timestamp)) AS day,
      count() AS c
    FROM otel_logs
    WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY
      AND ResourceAttributes['superlog.project_id'] = {projectId:String}
      AND SeverityNumber >= 17
      AND has({logServices:Array(String)}, coalesce(ServiceName, ''))
      AND ${logWhere}
    GROUP BY svc, et, day
  `;

  const res = await ch.query({
    query,
    query_params: {
      projectId,
      days: windowDays,
      spanServices,
      spanExcTypes,
      logServices,
      logExcTypes,
    },
    format: "JSONEachRow",
  });
  const rows = (await res.json()) as {
    kind: "span" | "log";
    svc: string;
    et: string;
    day: string;
    c: string | number;
  }[];
  return rows.map((r) => ({ ...r, count: Number(r.c) }));
}

// Daily event counts + impacted-user count for an incident's underlying telemetry.
app.get("/api/projects/:projectId/incidents/:incidentId/stats", async (c) => {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  await requireProjectAccess(c, projectId);

  const incident = await getProjectIncident(projectId, incidentId);
  if (!incident) throw new HTTPException(404, { message: "incident not found" });

  const windowDays = INCIDENT_STATS_WINDOW_DAYS;
  const empty = {
    windowDays,
    buckets: [] as { day: string; count: number }[],
    totalEvents: 0,
    impactedUsers: 0,
    impactedUsersAvailable: false,
  };

  const links = await db.query.incidentIssues.findMany({
    where: eq(schema.incidentIssues.incidentId, incidentId),
  });
  if (links.length === 0) return c.json(empty);

  const issues = await db.query.issues.findMany({
    where: inArray(
      schema.issues.id,
      links.map((l) => l.issueId),
    ),
  });
  if (issues.length === 0) return c.json(empty);

  const fingerprints = [...new Set(issues.map((issue) => issue.fingerprint).filter(Boolean))];
  const aggregateParams: Record<string, unknown> = {
    projectId,
    days: windowDays,
    fingerprints,
  };
  const aggregateQuery = `
    SELECT toString(day) AS bucket_day, sum(event_count) AS count
    FROM issue_activity_daily
    WHERE project_id = {projectId:String}
      AND fingerprint IN {fingerprints:Array(String)}
      AND day >= today() - ({days:UInt32} - 1)
    GROUP BY day
    ORDER BY day ASC
  `;

  const spanNamesBySample = await loadSpanNamesForIssueSamples(projectId, issues).catch((err) => {
    logger.warn(
      { err, projectId, incidentId },
      "incident span-name lookup unavailable; using unscoped span stats",
    );
    return new Map<string, string>();
  });
  const {
    namedSpanServices,
    namedSpanNames,
    namedSpanExcTypes,
    unnamedSpanServices,
    unnamedSpanExcTypes,
    logServices,
    logExcTypes,
  } = buildIncidentStatsPairs(issues, spanNamesBySample);

  // Match spans against the incident's fingerprint inputs. When we can resolve
  // SpanName from the issue sample, include it so ClickHouse can use the
  // (ServiceName, SpanName, Timestamp) primary key instead of scanning every
  // exception span for a busy service.
  const namedSpanWhere =
    namedSpanServices.length === 0
      ? "0"
      : `(coalesce(ServiceName, ''), coalesce(SpanName, ''), coalesce(event_attrs['exception.type'], '')) IN arrayZip({namedSpanServices:Array(String)}, {namedSpanNames:Array(String)}, {namedSpanExcTypes:Array(String)})`;
  const unnamedSpanWhere =
    unnamedSpanServices.length === 0
      ? "0"
      : `(coalesce(ServiceName, ''), coalesce(event_attrs['exception.type'], '')) IN arrayZip({unnamedSpanServices:Array(String)}, {unnamedSpanExcTypes:Array(String)})`;
  const spanWhere = `(${namedSpanWhere} OR ${unnamedSpanWhere})`;
  const logWhere =
    logServices.length === 0
      ? "0"
      : `(coalesce(ServiceName, ''), coalesce(LogAttributes['exception.type'], '')) IN arrayZip({logServices:Array(String)}, {logExcTypes:Array(String)})`;

  const params: Record<string, unknown> = {
    projectId,
    days: windowDays,
    namedSpanServices,
    namedSpanNames,
    namedSpanExcTypes,
    unnamedSpanServices,
    unnamedSpanExcTypes,
    logServices,
    logExcTypes,
  };
  const fallbackStats = buildIncidentStatsFromIssues(issues, { windowDays });

  const bucketsQuery = `
    SELECT day, sum(c) AS c
    FROM (
      SELECT toString(toDate(Timestamp)) AS day, count() AS c
      FROM (
        SELECT Timestamp, ServiceName, SpanName, Events.Name, Events.Attributes
        FROM otel_traces
        WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY
          AND ResourceAttributes['superlog.project_id'] = {projectId:String}
          AND (
            has({namedSpanServices:Array(String)}, coalesce(ServiceName, ''))
            OR has({unnamedSpanServices:Array(String)}, coalesce(ServiceName, ''))
          )
          AND (
            has({unnamedSpanServices:Array(String)}, coalesce(ServiceName, ''))
            OR has({namedSpanNames:Array(String)}, coalesce(SpanName, ''))
          )
          AND has(Events.Name, 'exception')
      )
      ARRAY JOIN Events.Name AS event_name, Events.Attributes AS event_attrs
      WHERE event_name = 'exception'
        AND ${spanWhere}
      GROUP BY day
      UNION ALL
      SELECT toString(toDate(Timestamp)) AS day, count() AS c
      FROM otel_logs
      WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY
        AND ResourceAttributes['superlog.project_id'] = {projectId:String}
        AND SeverityNumber >= 17
        AND has({logServices:Array(String)}, coalesce(ServiceName, ''))
        AND ${logWhere}
      GROUP BY day
    )
    GROUP BY day
    ORDER BY day ASC
  `;

  const usersQuery = `
    SELECT
      uniqExact(uid) AS users,
      countIf(uid IS NOT NULL) AS tagged_events
    FROM (
      SELECT nullIf(coalesce(SpanAttributes['user.id'], ResourceAttributes['user.id']), '') AS uid
      FROM (
        SELECT Timestamp, ServiceName, SpanName, SpanAttributes, ResourceAttributes, Events.Name, Events.Attributes
        FROM otel_traces
        WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY
          AND ResourceAttributes['superlog.project_id'] = {projectId:String}
          AND (
            has({namedSpanServices:Array(String)}, coalesce(ServiceName, ''))
            OR has({unnamedSpanServices:Array(String)}, coalesce(ServiceName, ''))
          )
          AND (
            has({unnamedSpanServices:Array(String)}, coalesce(ServiceName, ''))
            OR has({namedSpanNames:Array(String)}, coalesce(SpanName, ''))
          )
          AND has(Events.Name, 'exception')
      )
      ARRAY JOIN Events.Name AS event_name, Events.Attributes AS event_attrs
      WHERE event_name = 'exception'
        AND ${spanWhere}
      UNION ALL
      SELECT nullIf(coalesce(LogAttributes['user.id'], ResourceAttributes['user.id']), '') AS uid
      FROM otel_logs
      WHERE Timestamp > now() - INTERVAL {days:UInt32} DAY
        AND ResourceAttributes['superlog.project_id'] = {projectId:String}
        AND SeverityNumber >= 17
        AND has({logServices:Array(String)}, coalesce(ServiceName, ''))
        AND ${logWhere}
    )
  `;

  const stats = await buildIncidentStatsWithFallback({
    fallback: fallbackStats,
    // Stay below the ClickHouse client's 10s request timeout so this endpoint
    // returns issue-derived activity instead of surfacing a 500 to the drawer.
    timeoutMs: 8_500,
    onTelemetryUnavailable: (reason, err) => {
      logger.warn(
        { err, reason, projectId, incidentId },
        "incident telemetry stats unavailable; returning issue fallback",
      );
    },
    loadTelemetry: async (signal) => {
      try {
        const activityRes = await ch.query({
          query: aggregateQuery,
          query_params: aggregateParams,
          format: "JSONEachRow",
          abort_signal: signal,
        });
        const activityRows = (await activityRes.json()) as {
          bucket_day: string;
          count: string | number;
        }[];
        return buildIncidentStatsFromActivityRows(
          activityRows.map((row) => ({ day: row.bucket_day, count: row.count })),
          { windowDays },
        );
      } catch (err) {
        logger.warn(
          { err, projectId, incidentId },
          "incident fingerprint activity aggregate unavailable; falling back to raw telemetry stats",
        );
      }

      const [bucketsRes, usersRes] = await Promise.all([
        ch.query({
          query: bucketsQuery,
          query_params: params,
          format: "JSONEachRow",
          abort_signal: signal,
        }),
        ch.query({
          query: usersQuery,
          query_params: params,
          format: "JSONEachRow",
          abort_signal: signal,
        }),
      ]);

      const bucketRows = (await bucketsRes.json()) as { day: string; c: string | number }[];
      const userRows = (await usersRes.json()) as {
        users: string | number;
        tagged_events: string | number;
      }[];

      const countsByDay = new Map(bucketRows.map((r) => [r.day, Number(r.c)]));
      const buckets = buildBucketsFromMap(countsByDay, windowDays);
      const totalEvents = buckets.reduce((a, b) => a + b.count, 0);
      const taggedEvents = Number(userRows[0]?.tagged_events ?? 0);

      return {
        windowDays,
        buckets,
        totalEvents,
        impactedUsers: Number(userRows[0]?.users ?? 0),
        // false ⇒ no event in the window had a user.id attribute, so the UI can show a
        // "not instrumented" hint and link to the OTel onboarding skill.
        impactedUsersAvailable: taggedEvents > 0,
      };
    },
  });

  return c.json(stats);
});

async function loadSpanNamesForIssueSamples(
  projectId: string,
  issues: Pick<Issue, "kind" | "lastSample">[],
): Promise<Map<string, string>> {
  const samples: { traceId: string; spanId: string }[] = [];
  const seen = new Set<string>();
  for (const issue of issues) {
    if (issue.kind === "log" || issue.lastSample?.spanName) continue;
    const traceId = issue.lastSample?.traceId;
    const spanId = issue.lastSample?.spanId;
    if (!traceId || !spanId) continue;
    const key = spanSampleKey(traceId, spanId);
    if (seen.has(key)) continue;
    seen.add(key);
    samples.push({ traceId, spanId });
  }
  if (samples.length === 0) return new Map();

  const res = await ch.query({
    query: `
      SELECT
        TraceId AS trace_id,
        SpanId AS span_id,
        any(SpanName) AS span_name
      FROM otel_traces
      WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
        AND TraceId IN {traceIds:Array(String)}
        AND SpanId IN {spanIds:Array(String)}
      GROUP BY trace_id, span_id
    `,
    query_params: {
      projectId,
      traceIds: samples.map((s) => s.traceId),
      spanIds: samples.map((s) => s.spanId),
    },
    format: "JSONEachRow",
  });
  const rows = (await res.json()) as { trace_id: string; span_id: string; span_name: string }[];
  return new Map(
    rows
      .filter((row) => row.span_name)
      .map((row) => [spanSampleKey(row.trace_id, row.span_id), row.span_name]),
  );
}

// Pull the latest open (decision IS NULL) resolution proposal for each
// incident id. Returns a Map keyed by incident id so the caller can
// stitch it into the response without an N+1 query. Empty input → empty
// map (no DB roundtrip).
async function loadPendingResolutionProposals(
  incidentIds: string[],
): Promise<Map<string, PendingResolutionProposal>> {
  if (incidentIds.length === 0) return new Map();
  const rows = await db.query.incidentResolutionProposals.findMany({
    where: and(
      inArray(schema.incidentResolutionProposals.incidentId, incidentIds),
      isNull(schema.incidentResolutionProposals.decision),
    ),
    orderBy: [desc(schema.incidentResolutionProposals.proposedAt)],
  });
  // One open proposal per incident is the invariant — the gating in the
  // sweep selector enforces it — but defensively pick the newest if for
  // some reason multiple exist (e.g. mid-migration data).
  const map = new Map<string, PendingResolutionProposal>();
  for (const row of rows) {
    if (map.has(row.incidentId)) continue;
    map.set(row.incidentId, {
      id: row.id,
      sourceKind: row.sourceKind,
      confidence: row.confidence,
      proposedReasonCode: row.proposedReasonCode,
      proposedReasonText: row.proposedReasonText,
      proposedAt: row.proposedAt.toISOString(),
    });
  }
  return map;
}

type PendingResolutionProposal = {
  id: string;
  sourceKind: string;
  confidence: schema.IncidentResolutionProposalConfidence;
  proposedReasonCode: string;
  proposedReasonText: string;
  proposedAt: string;
};

// Back-compat: returns the same {agentRun, events} shape the web client
// used pre-merge. The main `/incidents/:id` GET now folds this in.
app.get("/api/projects/:projectId/incidents/:incidentId/agent-run", async (c) => {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  await requireProjectAccess(c, projectId);

  const incident = await getProjectIncident(projectId, incidentId);
  if (!incident) throw new HTTPException(404, { message: "incident not found" });

  const agentRun = await getLatestAgentRun(incident.id);
  // An incident may have lifecycle events (manual resolve from the
  // dashboard or Slack, sweep proposal confirmed) without ever having
  // had an agent run — pass a never-matches uuid so loadIncidentTimeline
  // returns only the incident-scoped lifecycle events.
  const events = await loadIncidentTimeline(
    incident.id,
    agentRun?.id ?? "00000000-0000-0000-0000-000000000000",
  );

  return c.json({ agentRun: agentRun ?? null, events });
});

app.post("/api/projects/:projectId/incidents/:incidentId/agent-run/restart", async (c) => {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  await requireProjectAccess(c, projectId);

  const incident = await getProjectIncident(projectId, incidentId);
  if (!incident) throw new HTTPException(404, { message: "incident not found" });

  const latest = await getLatestAgentRun(incident.id);
  if (!latest) throw new HTTPException(404, { message: "agent run not found" });

  const automation = await getProjectAutomation(projectId);
  if (!automation.agentRunEnabled) {
    throw new HTTPException(400, { message: "incident agent_runs are disabled" });
  }

  const activeStates = [
    "queued",
    "repo_discovery",
    "running",
    "awaiting_human",
    "pr_retry_queued",
    "blocked_no_github",
  ];
  const now = new Date();
  const agentRun = await db.transaction(async (tx) => {
    const superseded = await tx
      .update(schema.agentRuns)
      .set({ state: "superseded", completedAt: now, updatedAt: now })
      .where(
        and(
          eq(schema.agentRuns.incidentId, incident.id),
          inArray(schema.agentRuns.state, activeStates),
        ),
      )
      .returning({ id: schema.agentRuns.id });

    if (superseded.length > 0) {
      await tx.insert(schema.incidentEvents).values(
        superseded.map((row) => ({
          agentRunId: row.id,
          kind: "agent_run_superseded",
          summary: "Investigation superseded by a restart.",
          dedupeKey: `superseded:${row.id}:${now.getTime()}`,
          processedAt: now,
        })),
      );
    }

    const [created] = await tx
      .insert(schema.agentRuns)
      .values({
        incidentId: incident.id,
        runtime: automation.agentRunProvider,
        state: "queued",
      })
      .returning();
    if (!created) throw new Error("failed to restart agent run");

    await tx.insert(schema.incidentEvents).values({
      agentRunId: created.id,
      kind: "agent_run_restarted",
      summary: "Investigation restarted.",
      detail: {
        restartedFromAgentRunId: latest.id,
        restartedFromState: latest.state,
      },
      dedupeKey: `restart:${created.id}`,
      processedAt: now,
    });

    return created;
  });

  return c.json(agentRun);
});

app.post("/api/projects/:projectId/incidents/:incidentId/agent-run/retry-pr", async (c) => {
  const projectId = c.req.param("projectId");
  const incidentId = c.req.param("incidentId");
  await requireProjectAccess(c, projectId);

  const incident = await getProjectIncident(projectId, incidentId);
  if (!incident) throw new HTTPException(404, { message: "incident not found" });

  const latest = await getLatestAgentRun(incident.id);
  if (!latest) throw new HTTPException(404, { message: "agent run not found" });
  const eligibility = getPrDeliveryRetryEligibility(latest ?? null);
  if (!eligibility.canRetry) {
    throw new HTTPException(400, { message: eligibility.reason });
  }

  const now = new Date();
  const agentRun = await db.transaction(async (tx) => {
    const [updated] = await tx
      .update(schema.agentRuns)
      .set({
        state: "pr_retry_queued",
        failureReason: null,
        completedAt: null,
        updatedAt: now,
      })
      .where(
        and(
          eq(schema.agentRuns.id, latest.id),
          eq(schema.agentRuns.state, "failed"),
          eq(schema.agentRuns.failureReason, "pr_open_failed"),
        ),
      )
      .returning();
    if (!updated) throw new HTTPException(409, { message: "agent run is no longer retryable" });

    await tx.insert(schema.incidentEvents).values({
      agentRunId: updated.id,
      kind: "agent_run_pr_retry_queued",
      summary: "PR delivery retry queued.",
      detail: {
        retriedFromState: latest.state,
        selectedRepoFullName: latest.result?.pr?.selectedRepoFullName ?? null,
        branchName: latest.result?.pr?.branchName ?? null,
      },
      dedupeKey: `pr-retry:${updated.id}:${now.getTime()}`,
      processedAt: now,
    });

    return updated;
  });

  return c.json(agentRun);
});

type TimelineEvent = {
  id: string;
  agentRunId: string;
  kind: string;
  summary: string | null;
  detail: Record<string, unknown> | null;
  // The provider's own event id (Anthropic `sevt_…`). Tool_result events carry
  // the matching `toolUseId` in their detail; pairing logic in the UI keys on
  // this, not on `id` (which is a freshly-allocated row UUID).
  providerEventId: string | null;
  createdAt: string;
  source: "agent_run" | "agent_pr" | "agent_linear";
  actor?: {
    name: string | null;
    avatarUrl: string | null;
    profileUrl: string | null;
  } | null;
};

async function loadIncidentTimeline(
  incidentId: string,
  agentRunId: string,
): Promise<TimelineEvent[]> {
  const [agentRunEvents, prRows, linearTickets] = await Promise.all([
    // Match events on either join key: events tied to this incident's
    // current agent run OR events keyed directly on the incident (manual
    // resolves, sweep proposal confirmations, etc., don't have an
    // agent_run_id to anchor to).
    db.query.incidentEvents.findMany({
      where: or(
        eq(schema.incidentEvents.agentRunId, agentRunId),
        eq(schema.incidentEvents.incidentId, incidentId),
      ),
      orderBy: [asc(schema.incidentEvents.createdAt)],
    }),
    db.query.agentPullRequests.findMany({
      where: eq(schema.agentPullRequests.incidentId, incidentId),
    }),
    db.query.agentLinearTickets.findMany({
      where: eq(schema.agentLinearTickets.incidentId, incidentId),
    }),
  ]);

  const [prEventRows, linearEventRows] = await Promise.all([
    prRows.length === 0
      ? ([] as schema.AgentPrEvent[])
      : db.query.agentPrEvents.findMany({
          where: inArray(
            schema.agentPrEvents.agentPrId,
            prRows.map((r) => r.id),
          ),
          orderBy: [asc(schema.agentPrEvents.occurredAt)],
        }),
    linearTickets.length === 0
      ? ([] as schema.AgentLinearTicketEvent[])
      : db.query.agentLinearTicketEvents.findMany({
          where: inArray(
            schema.agentLinearTicketEvents.agentLinearTicketId,
            linearTickets.map((r) => r.id),
          ),
          orderBy: [asc(schema.agentLinearTicketEvents.occurredAt)],
        }),
  ]);

  const prById = new Map(prRows.map((r) => [r.id, r] as const));
  const linearById = new Map(linearTickets.map((r) => [r.id, r] as const));

  const items: TimelineEvent[] = [];

  for (const ev of agentRunEvents) {
    items.push({
      id: ev.id,
      // The row's own agent_run_id if it has one; fall back to the
      // active agent run id we were called with so consumers that key
      // off `agentRunId` still group events correctly. Lifecycle events
      // without any run context land with the caller-supplied value.
      agentRunId: ev.agentRunId ?? agentRunId,
      kind: ev.kind,
      summary: ev.summary,
      detail: ev.detail,
      providerEventId: ev.providerEventId,
      createdAt: ev.createdAt.toISOString(),
      source: "agent_run",
      actor: null,
    });
  }
  for (const ev of prEventRows) {
    const pr = prById.get(ev.agentPrId);
    items.push({
      id: ev.id,
      agentRunId,
      kind: ev.kind,
      summary: ev.summary,
      detail: {
        ...(ev.payload ?? {}),
        prUrl: pr?.url ?? null,
        prNumber: pr?.prNumber ?? null,
        repoFullName: pr?.repoFullName ?? null,
      },
      providerEventId: ev.providerEventId,
      createdAt: ev.occurredAt.toISOString(),
      source: "agent_pr",
      actor: ev.actorLogin
        ? {
            name: ev.actorLogin,
            avatarUrl: ev.actorAvatarUrl,
            profileUrl: `https://github.com/${ev.actorLogin}`,
          }
        : null,
    });
  }
  for (const ev of linearEventRows) {
    const ticket = linearById.get(ev.agentLinearTicketId);
    items.push({
      id: ev.id,
      agentRunId,
      kind: ev.kind,
      summary: ev.summary,
      detail: {
        ...(ev.payload ?? {}),
        ticketUrl: ticket?.url ?? null,
        ticketIdentifier: ticket?.ticketIdentifier ?? null,
      },
      providerEventId: ev.providerEventId,
      createdAt: ev.occurredAt.toISOString(),
      source: "agent_linear",
      actor: ev.actorName
        ? { name: ev.actorName, avatarUrl: ev.actorAvatarUrl, profileUrl: null }
        : null,
    });
  }

  items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
  return items;
}

if (shouldRunMigrationsOnBoot(process.env)) {
  await runMigrations();
  logger.info({ scope: "db" }, "migrations applied");
} else {
  // Managed deploy: migrations are applied by a dedicated, gated step as a
  // schema-owner role before this service rolls out. The app role can't (and
  // shouldn't) run DDL on boot.
  logger.info({ scope: "db" }, "skipping boot migrations (RUN_MIGRATIONS_ON_BOOT=false)");
}

const server = serve({ fetch: app.fetch, port: PORT });
// Same load-balancer keep-alive concern as the proxy: Node's default 5s
// keepAliveTimeout lets it close idle sockets an upstream LB (typically ~60s
// idle timeout) still pools, so reused connections RST and surface as a 502
// even though the app is healthy. Keep the keep-alive comfortably above the LB
// idle timeout — a thin margin still leaks 502s under bursty connection reuse —
// with headersTimeout above keepAliveTimeout per Node's required order.
if ("keepAliveTimeout" in server) {
  server.keepAliveTimeout = 75_000;
  server.headersTimeout = 76_000;
}
logger.info({ port: PORT }, "superlog api listening");
