/**
 * GET /api/projects/[projectId]/anatomy
 *
 * Single-fetch shape consumed by the Product / Hosting / Infrastructure
 * tabs. Keeping it one endpoint keeps page transitions cheap and avoids
 * fan-out.
 *
 * Conceptual model (locked Apr 28 2026):
 *   Product   = "what makes up the thing you're shipping"
 *               → codebases (Gitea repos) + images (Coolify services
 *                 backed by an upstream Docker image, e.g. Twenty CRM,
 *                 n8n). Both are first-class product surfaces.
 *               → vibn-dev-* containers are NOT shown — the dev
 *                 container is the AI's workshop, not the product.
 *
 *   Hosting   = "where does it live and how does it get there"
 *               → unified `live` list of running endpoints (each item
 *                 carries source = "repo" | "image", attached domains,
 *                 and last build/deploy status inline) + `previews`
 *                 (dev container preview URLs).
 *               → no separate Build, Domains, or Services categories.
 *
 *   Infrastructure = TODO (placeholder).
 */

import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
import {
  listApplications,
  listApplicationDeployments,
  listApplicationEnvs,
  listServicesInProject,
  listServiceEnvs,
  listDatabasesInProject,
  listProjects as listCoolifyProjects,
  createProject as createCoolifyProject,
  type CoolifyApplication,
  type CoolifyService,
  type CoolifyDatabase,
} from "@/lib/coolify";
import { getWorkspaceGcsState } from "@/lib/workspace-gcs";
import { VIBN_GCS_LOCATION } from "@/lib/gcp/storage";
import { sortDevPreviewsFrontendFirst } from "@/lib/dev-preview-priority";

const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com";
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? "";

// ──────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────

interface Codebase {
  id: string;
  label: string;
  path: string;
  hint?: string;
}

interface ProductImage {
  uuid: string;
  name: string;
  /** "twentycrm/twenty" */
  image: string;
  /** "v1.15" — empty string when not pinned */
  version: string;
  serviceType?: string;
  /** Coolify service status, surfaced so the Product tile can show a dot */
  status?: string;
}

interface BuildSummary {
  status: string;
  createdAt?: string;
  finishedAt?: string;
  commit?: string;
}

interface LiveEndpoint {
  uuid: string;
  name: string;
  /** repo = built from Gitea, image = pulled docker image (Coolify service) */
  source: "repo" | "image";
  /** "apps/web" or "twentycrm/twenty:v1.15" */
  sourceLabel: string;
  status: string;
  /** primary host (no scheme) when one exists */
  fqdn?: string;
  /** all attached hosts */
  domains: string[];
  branch?: string;
  buildPack?: string;
  /** Last finished deployment, only for source = "repo" */
  lastBuild?: BuildSummary;
  /** In-flight deployment (queued / in_progress / building). Present
   *  iff Coolify has an active deploy not yet marked finished. The
   *  status pill flips to "Deploying" the moment this is non-null. */
  inFlightBuild?: BuildSummary;
}

interface Preview {
  id: string;
  name: string;
  /** Last start command — helps debug preview readiness failures */
  command?: string;
  port: number;
  url: string;
  state: string;
  startedAt: string;
}

/** A Coolify database resource attached to this project. */
interface InfraDatabase {
  uuid: string;
  name: string;
  type: string; // postgresql / redis / mongodb / mysql / keydb / clickhouse
  status: string;
  isPublic: boolean;
  publicPort?: number;
  /** "host:port" for the in-cluster reachable DB (no creds). */
  internalAddress?: string;
  /** Stable env-var key apps should set to consume this DB
   *  (DATABASE_URL for SQL, REDIS_URL for Redis, etc.). */
  consumerEnvKey: string;
}

/** A non-database third-party provider detected by env-var pattern.
 *  Coolify doesn't model these natively, so we infer them from the
 *  keys present in app + service env vars. */
interface InfraProvider {
  /** Stable id used by the UI for selection */
  id: string;
  category: "auth" | "email" | "payments" | "llm" | "storage";
  vendor: string; // "Stripe", "Resend", "OpenAI", …
  /** Where the env keys for this provider live */
  attachments: Array<{
    resourceUuid: string;
    resourceName: string;
    resourceKind: "app" | "service";
    keys: string[]; // matching env var keys (values redacted)
  }>;
}

/** Workspace-bundled S3 (GCS) storage that Vibn provisions for each
 *  workspace. Same record across every project in the workspace — we
 *  surface it on each project's Infrastructure tab so users can see
 *  what's available without going to settings. */
interface BundledStorage {
  status: "ready" | "pending" | "partial" | "error" | "unprovisioned";
  bucketName?: string;
  hmacAccessId?: string;
  region?: string;
  errorMessage?: string;
}

interface InfraSecretSummary {
  /** Total number of env vars across every app + service in the project */
  total: number;
  /** Per-resource breakdown for drill-down. Includes the actual env-var
   *  KEYS (never values) so the Secrets detail pane can show what's
   *  set. Values are intentionally excluded from this surface — to
   *  read or rotate them, route through apps.envs.* / services.envs.*
   *  MCP tools which audit-log and tenant-scope every access. */
  byResource: Array<{
    resourceUuid: string;
    resourceName: string;
    resourceKind: "app" | "service";
    count: number;
    keys: string[];
  }>;
}

interface Anatomy {
  project: {
    id: string;
    name: string;
    gitea?: string;
    coolifyProjectUuid?: string;
  };
  codebasesReason?: "no_repo" | "empty_repo";
  product: {
    codebases: Codebase[];
    images: ProductImage[];
  };
  hosting: {
    live: LiveEndpoint[];
    previews: Preview[];
  };
  infrastructure: {
    databases: InfraDatabase[];
    providers: InfraProvider[];
    bundledStorage: BundledStorage;
    secrets: InfraSecretSummary;
  };
}

// ──────────────────────────────────────────────────
// Gitea (codebase discovery)
// ──────────────────────────────────────────────────

interface GiteaItem {
  name: string;
  path: string;
  type: "file" | "dir" | "symlink";
}

async function giteaList(
  repo: string,
  path: string,
): Promise<GiteaItem[] | null> {
  const encoded = path ? encodeURIComponent(path).replace(/%2F/g, "/") : "";
  const res = await fetch(
    `${GITEA_API_URL}/api/v1/repos/${repo}/contents/${encoded}`,
    {
      headers: { Authorization: `token ${GITEA_API_TOKEN}` },
      next: { revalidate: 30 },
    },
  );
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Gitea ${res.status} listing ${repo}/${path}`);
  const data = await res.json();
  return Array.isArray(data) ? (data as GiteaItem[]) : null;
}

async function discoverCodebases(giteaRepo: string): Promise<{
  codebases: Codebase[];
  reason?: "empty_repo";
}> {
  const root = await giteaList(giteaRepo, "");
  if (!root) return { codebases: [], reason: "empty_repo" };

  const appsDir = root.find(
    (item) => item.type === "dir" && item.name === "apps",
  );
  let codebases: Codebase[] = [];

  if (appsDir) {
    const appsChildren = await giteaList(giteaRepo, "apps");
    if (appsChildren) {
      codebases = appsChildren
        .filter((item) => item.type === "dir")
        .map((item) => ({
          id: item.name,
          label: item.name,
          path: `apps/${item.name}`,
        }));
    }
  }

  if (codebases.length === 0) {
    const repoName = giteaRepo.split("/").pop() || "app";
    codebases = [
      {
        id: "root",
        label: repoName,
        path: "",
        hint: "Single-codebase project — repository root.",
      },
    ];
  }

  return { codebases };
}

// ──────────────────────────────────────────────────
// Coolify helpers
// ──────────────────────────────────────────────────

function normaliseRepoUrl(url: string | undefined): string {
  if (!url) return "";
  let u = url.toLowerCase();
  u = u.replace(/^https?:\/\/[^/@]*@/, "https://");
  u = u.replace(/\.git$/, "");
  return u;
}

function shortFormOfRepo(url: string | undefined): string {
  if (!url) return "";
  const cleaned = normaliseRepoUrl(url).replace(/^https?:\/\/[^/]+\//, "");
  return cleaned.replace(/\.git$/, "").toLowerCase();
}

function appMatchesRepo(app: CoolifyApplication, giteaRepo: string): boolean {
  const target = giteaRepo.toLowerCase();
  const appShort = shortFormOfRepo(app.git_repository);
  if (appShort && appShort === target) return true;
  return Boolean(
    app.git_repository && app.git_repository.toLowerCase().includes(target),
  );
}

async function loadRepoApps(
  giteaRepo: string | undefined,
): Promise<CoolifyApplication[]> {
  if (!giteaRepo) return [];
  try {
    const all = await listApplications();
    return all.filter((app) => appMatchesRepo(app, giteaRepo));
  } catch (err) {
    console.error("[anatomy] listApplications failed:", err);
    return [];
  }
}

async function loadProjectServices(
  coolifyProjectUuid: string | undefined,
): Promise<CoolifyService[]> {
  if (!coolifyProjectUuid) return [];
  try {
    return await listServicesInProject(coolifyProjectUuid);
  } catch (err) {
    console.error("[anatomy] listServicesInProject failed:", err);
    return [];
  }
}

// Coolify's service-level status aggregates ALL inner apps, including
// sidecars that don't define a healthcheck (e.g. Twenty's worker, n8n's
// queue runner). Those apps perpetually report `running:unknown` or
// `starting:unknown`, which poisons the aggregate even when the
// user-facing app is `running:healthy`.
//
// Fetching the service detail gives us per-app statuses + the
// `exclude_from_status` flag. We pick the best status from non-excluded
// apps so the project header pill reflects what the user actually
// cares about. This costs N extra Coolify GETs per anatomy call (one
// per image service) — small N in practice, parallelised below.
interface ServiceAppStatus {
  name: string;
  status?: string;
  exclude_from_status?: boolean;
  fqdn?: string;
}
async function smartServiceMetaFor(
  uuid: string,
  fallbackStatus: string | undefined,
  serviceLevelFqdn: string | undefined,
): Promise<{ status: string; fqdns: string[] }> {
  try {
    const detail = await fetch(
      `${process.env.COOLIFY_URL}/api/v1/services/${uuid}`,
      {
        headers: { Authorization: `Bearer ${process.env.COOLIFY_API_TOKEN}` },
        cache: "no-store",
      },
    );
    if (!detail.ok)
      return {
        status: fallbackStatus ?? "unknown",
        fqdns: fqdnsOf(serviceLevelFqdn),
      };
    const body = (await detail.json()) as { applications?: ServiceAppStatus[] };
    const apps = body.applications ?? [];
    const userFacing = apps.filter((a) => !a.exclude_from_status);

    // Fqdn harvesting: for image services Coolify usually leaves the
    // service-level fqdn null and stores the real domain on the inner
    // application (e.g. Twenty's `twenty` app). Prefer fqdns from
    // user-facing apps; fall back to the service-level value.
    const harvested: string[] = [];
    for (const a of userFacing)
      for (const f of fqdnsOf(a.fqdn)) harvested.push(f);
    const fqdns = harvested.length > 0 ? harvested : fqdnsOf(serviceLevelFqdn);

    if (userFacing.length === 0)
      return { status: fallbackStatus ?? "unknown", fqdns };

    const ranked = userFacing.map((a) => rankServiceAppStatus(a.status));
    let status: string;
    if (ranked.some((r) => r === "down")) status = "exited";
    else if (ranked.some((r) => r === "up")) status = "running:healthy";
    else if (ranked.some((r) => r === "transient")) status = "starting:unknown";
    else status = fallbackStatus ?? "unknown";

    return { status, fqdns };
  } catch (err) {
    console.error(`[anatomy] smartServiceMetaFor(${uuid}) failed:`, err);
    return {
      status: fallbackStatus ?? "unknown",
      fqdns: fqdnsOf(serviceLevelFqdn),
    };
  }
}
function rankServiceAppStatus(
  s?: string,
): "up" | "transient" | "down" | "unknown" {
  const v = (s ?? "").toLowerCase();
  if (!v) return "unknown";
  if (/^(running|healthy)/.test(v)) return "up";
  if (
    /^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(
      v,
    )
  )
    return "transient";
  if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(v)) return "down";
  return "unknown";
}

const isDevContainer = (svc: CoolifyService) =>
  svc.name.startsWith("vibn-dev-");

/** Extract image:version from a Coolify docker_compose_raw blob.
 *  Best-effort regex; we only want a sensible label, not perfection. */
function extractImageInfo(svc: CoolifyService): {
  image: string;
  version: string;
} {
  const raw =
    (svc as unknown as { docker_compose_raw?: string }).docker_compose_raw ??
    "";
  const m = raw.match(/image:\s*['"]?([^\s'"\n]+)['"]?/);
  if (!m) return { image: svc.service_type ?? svc.name, version: "" };
  const full = m[1];
  const at = full.lastIndexOf(":");
  if (at <= 0 || full.slice(at).includes("/")) {
    return { image: full, version: "" };
  }
  return { image: full.slice(0, at), version: full.slice(at + 1) };
}

function fqdnsOf(value: string | undefined): string[] {
  if (!value) return [];
  return value
    .split(",")
    .map((s) => {
      const trimmed = s.trim().replace(/\/$/, "");
      if (!trimmed) return "";
      // Strip default ports that won't work through a public reverse
      // proxy (https on 443, http on 80, and Coolify's internal :3000
      // which Traefik bridges to 443 anyway).
      try {
        const u = new URL(
          /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`,
        );
        const host = u.hostname;
        const port = u.port;
        const isHttps = u.protocol === "https:";
        const showPort =
          port &&
          !(
            (isHttps && (port === "443" || port === "3000")) ||
            (!isHttps && port === "80")
          );
        return showPort ? `${host}:${port}` : host;
      } catch {
        return trimmed.replace(/^https?:\/\//, "");
      }
    })
    .filter(Boolean);
}

// Heuristic: Coolify auto-generates *.sslip.io URLs as a default
// reachable hostname when no custom domain is set. If a user has
// configured a real domain, that's what we want to surface in the
// header — push sslip.io URLs to the back.
function prioritiseFqdns(fqdns: string[]): string[] {
  const isAuto = (f: string) =>
    /\.sslip\.io(?::|$)/i.test(f) || /\.coolify\.app(?::|$)/i.test(f);
  return [...fqdns].sort((a, b) => Number(isAuto(a)) - Number(isAuto(b)));
}

async function lastBuildFor(uuid: string): Promise<BuildSummary | undefined> {
  try {
    const deployments = await listApplicationDeployments(uuid);
    if (!deployments.length) return undefined;
    const finished = deployments.find((d) => d.finished_at) ?? deployments[0];
    return {
      status: finished.status,
      finishedAt: finished.finished_at,
      commit: finished.commit,
    };
  } catch (err) {
    console.error(`[anatomy] listApplicationDeployments(${uuid}) failed:`, err);
    return undefined;
  }
}

// In-flight deployments — anything queued/building/in_progress that hasn't
// finished. Used by the project header status pill so the user sees
// "Deploying…" the moment a build kicks off, instead of staring at the
// stale "Live" badge until the page is reloaded.
async function deploymentActivityFor(uuid: string): Promise<{
  lastBuild?: BuildSummary;
  inFlight?: BuildSummary;
}> {
  try {
    const deployments = await listApplicationDeployments(uuid);
    if (!deployments.length) return {};

    const isFinished = (s: string) =>
      /^(success|finished|failed|cancelled|error|exited)$/i.test(s);
    const inFlightDep = deployments.find(
      (d) => !d.finished_at && !isFinished(d.status ?? ""),
    );
    const finishedDep =
      deployments.find((d) => d.finished_at) ?? deployments[0];

    return {
      lastBuild: finishedDep
        ? {
            status: finishedDep.status,
            createdAt: finishedDep.created_at,
            finishedAt: finishedDep.finished_at,
            commit: finishedDep.commit,
          }
        : undefined,
      inFlight: inFlightDep
        ? {
            status: inFlightDep.status,
            createdAt: inFlightDep.created_at,
            finishedAt: inFlightDep.finished_at,
            commit: inFlightDep.commit,
          }
        : undefined,
    };
  } catch (err) {
    console.error(`[anatomy] deploymentActivityFor(${uuid}) failed:`, err);
    return {};
  }
}

// ──────────────────────────────────────────────────
// Infrastructure helpers
// ──────────────────────────────────────────────────

/** Coolify database type names → friendly normalised label. */
function dbTypeOf(d: CoolifyDatabase): string {
  const raw = (d.type ?? "").toLowerCase();
  if (raw.includes("postgres")) return "postgresql";
  if (raw.includes("redis")) return "redis";
  if (raw.includes("keydb")) return "keydb";
  if (raw.includes("dragonfly")) return "dragonfly";
  if (raw.includes("mongo")) return "mongodb";
  if (raw.includes("mysql") || raw.includes("mariadb")) return "mysql";
  if (raw.includes("clickhouse")) return "clickhouse";
  return raw || "database";
}

/** Best-effort host:port from the in-cluster URL (creds stripped). */
function parseInternalAddress(
  internalUrl: string | undefined,
): string | undefined {
  if (!internalUrl) return undefined;
  try {
    const u = new URL(internalUrl);
    return u.port ? `${u.hostname}:${u.port}` : u.hostname;
  } catch {
    // Coolify sometimes returns non-URL formats (e.g. raw mongo conn strings)
    const m = internalUrl.match(/@([^/]+)\/?/);
    return m ? m[1] : undefined;
  }
}

function consumerKeyFor(type: string): string {
  if (type === "redis" || type === "keydb" || type === "dragonfly")
    return "REDIS_URL";
  if (type === "mongodb") return "MONGODB_URI";
  if (type === "clickhouse") return "CLICKHOUSE_URL";
  return "DATABASE_URL";
}

async function loadDatabases(
  coolifyProjectUuid: string | undefined,
): Promise<InfraDatabase[]> {
  if (!coolifyProjectUuid) return [];
  try {
    const dbs = await listDatabasesInProject(coolifyProjectUuid);
    return dbs.map((d) => {
      const type = dbTypeOf(d);
      return {
        uuid: d.uuid,
        name: d.name,
        type,
        status: d.status ?? "unknown",
        isPublic: !!d.is_public,
        publicPort: d.public_port,
        internalAddress: parseInternalAddress(d.internal_db_url),
        consumerEnvKey: consumerKeyFor(type),
      };
    });
  } catch (err) {
    console.error("[anatomy] listDatabasesInProject failed:", err);
    return [];
  }
}

/**
 * Provider detection rules. Each rule maps a category + vendor to a
 * regex tested against env-var keys. We deliberately keep the prefix
 * specific enough to avoid false positives (e.g. `STRIPE_*` not just
 * `*KEY*`).
 */
/**
 * Provider detection rules. Categories surfaced today: Auth, Email,
 * Payments, LLM. Storage is intentionally NOT auto-detected here —
 * it's served by the workspace's bundled GCS bucket on the same tab.
 *
 * (Earlier iteration also detected SMS, Analytics, Search and
 *  Monitoring categories. Removed Apr 29 2026 to keep the surface
 *  focused on what users are actually plumbing today; rules can be
 *  added back when those categories get real UX.)
 */
const PROVIDER_RULES: Array<{
  category: InfraProvider["category"];
  vendor: string;
  pattern: RegExp;
}> = [
  // Auth
  { category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ },
  { category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ },
  { category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ },
  {
    category: "auth",
    vendor: "Supabase Auth",
    pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/,
  },
  { category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ },
  { category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ },
  {
    category: "auth",
    vendor: "Firebase Auth",
    pattern: /^FIREBASE_(AUTH|API_KEY)/,
  },
  // Email
  { category: "email", vendor: "Resend", pattern: /^RESEND_/ },
  { category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ },
  { category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ },
  { category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ },
  { category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ },
  { category: "email", vendor: "Loops", pattern: /^LOOPS_/ },
  // Payments
  {
    category: "payments",
    vendor: "Stripe",
    pattern: /^(NEXT_PUBLIC_)?STRIPE_/,
  },
  {
    category: "payments",
    vendor: "LemonSqueezy",
    pattern: /^LEMON(SQUEEZY)?_/,
  },
  { category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ },
  // LLM (a.k.a. Models)
  { category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ },
  { category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ },
  {
    category: "llm",
    vendor: "Google AI",
    pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/,
  },
  { category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ },
  { category: "llm", vendor: "Cohere", pattern: /^COHERE_/ },
  { category: "llm", vendor: "Groq", pattern: /^GROQ_/ },
  { category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ },
];

interface ResourceEnvs {
  resourceUuid: string;
  resourceName: string;
  resourceKind: "app" | "service";
  keys: string[];
}

async function loadAllEnvs(
  apps: CoolifyApplication[],
  services: CoolifyService[],
): Promise<ResourceEnvs[]> {
  const appPromises = apps.map(async (a): Promise<ResourceEnvs> => {
    try {
      const envs = await listApplicationEnvs(a.uuid);
      return {
        resourceUuid: a.uuid,
        resourceName: a.name,
        resourceKind: "app",
        keys: envs.map((e) => e.key),
      };
    } catch (err) {
      console.error(`[anatomy] listApplicationEnvs(${a.uuid}) failed:`, err);
      return {
        resourceUuid: a.uuid,
        resourceName: a.name,
        resourceKind: "app",
        keys: [],
      };
    }
  });
  const svcPromises = services.map(async (s): Promise<ResourceEnvs> => {
    try {
      const envs = await listServiceEnvs(s.uuid);
      return {
        resourceUuid: s.uuid,
        resourceName: s.name,
        resourceKind: "service",
        keys: envs.map((e) => e.key),
      };
    } catch (err) {
      console.error(`[anatomy] listServiceEnvs(${s.uuid}) failed:`, err);
      return {
        resourceUuid: s.uuid,
        resourceName: s.name,
        resourceKind: "service",
        keys: [],
      };
    }
  });
  return Promise.all([...appPromises, ...svcPromises]);
}

function detectProviders(allEnvs: ResourceEnvs[]): InfraProvider[] {
  // vendor → { category, attachments-by-resource }
  const byVendor = new Map<string, InfraProvider>();

  for (const env of allEnvs) {
    if (env.keys.length === 0) continue;
    for (const rule of PROVIDER_RULES) {
      const matches = env.keys.filter((k) => rule.pattern.test(k));
      if (matches.length === 0) continue;
      const id = `${rule.category}:${rule.vendor.toLowerCase().replace(/\s+/g, "-")}`;
      let entry = byVendor.get(id);
      if (!entry) {
        entry = {
          id,
          category: rule.category,
          vendor: rule.vendor,
          attachments: [],
        };
        byVendor.set(id, entry);
      }
      entry.attachments.push({
        resourceUuid: env.resourceUuid,
        resourceName: env.resourceName,
        resourceKind: env.resourceKind,
        keys: matches,
      });
    }
  }
  return Array.from(byVendor.values());
}

function summariseSecrets(allEnvs: ResourceEnvs[]): InfraSecretSummary {
  const byResource = allEnvs
    .filter((e) => e.keys.length > 0)
    .map((e) => ({
      resourceUuid: e.resourceUuid,
      resourceName: e.resourceName,
      resourceKind: e.resourceKind,
      count: e.keys.length,
      keys: [...e.keys].sort(),
    }))
    .sort((a, b) => b.count - a.count);
  const total = byResource.reduce((sum, r) => sum + r.count, 0);
  return { total, byResource };
}

async function loadBundledStorage(
  workspaceId: string | undefined,
): Promise<BundledStorage> {
  if (!workspaceId) return { status: "unprovisioned" };
  try {
    const ws = await getWorkspaceGcsState(workspaceId);
    if (!ws) return { status: "unprovisioned" };
    return {
      status: ws.gcp_provision_status ?? "unprovisioned",
      bucketName: ws.gcs_default_bucket_name ?? undefined,
      hmacAccessId: ws.gcs_hmac_access_id ?? undefined,
      region: VIBN_GCS_LOCATION,
      errorMessage: ws.gcp_provision_error ?? undefined,
    };
  } catch (err) {
    console.error("[anatomy] getWorkspaceGcsState failed:", err);
    return {
      status: "error",
      errorMessage: err instanceof Error ? err.message : String(err),
    };
  }
}

async function loadPreviews(projectId: string): Promise<Preview[]> {
  try {
    const rows = await query<{
      id: string;
      name: string;
      command: string | null;
      port: number;
      preview_url: string;
      state: string;
      started_at: string;
    }>(
      `SELECT id, name, command, port, preview_url, state, started_at
         FROM fs_dev_servers
        WHERE project_id = $1 AND state != 'stopped'`,
      [projectId],
    );

    // Filter out zombies: if a server is marked 'running' but the URL returns a 50x
    // Gateway error or times out, the process died. We mark it stopped so the
    // UI can trigger an auto-restart.
    const activePreviews: typeof rows = [];

    await Promise.all(
      rows.map(async (r) => {
        if (r.state !== "running") {
          activePreviews.push(r);
          return;
        }

        try {
          const controller = new AbortController();
          const timeout = setTimeout(() => controller.abort(), 2500); // Fast 2.5s timeout
          const ping = await fetch(r.preview_url, {
            method: "HEAD",
            signal: controller.signal,
          });
          clearTimeout(timeout);

          // 502/503/504 means Traefik is up but the container isn't answering.
          // 404 means Traefik doesn't even know about the route.
          if (
            ping.status === 502 ||
            ping.status === 503 ||
            ping.status === 504 ||
            ping.status === 404
          ) {
            console.warn(
              `[anatomy] Preview zombie detected for ${r.preview_url} (HTTP ${ping.status}). Marking stopped.`,
            );
            await query(
              `UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
              [r.id],
            ).catch(() => {});
          } else {
            activePreviews.push(r);
          }
        } catch (e: any) {
          // If the fetch completely fails (e.g. timeout, DNS failure), it's dead.
          console.warn(
            `[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`,
          );
          await query(
            `UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
            [r.id],
          ).catch(() => {});
        }
      }),
    );

    return sortDevPreviewsFrontendFirst(activePreviews).map((r) => ({
      id: r.id,
      name: r.name,
      command: r.command ?? undefined,
      port: r.port,
      url: r.preview_url,
      state: r.state,
      startedAt: r.started_at,
    }));
  } catch (err) {
    if (
      err instanceof Error &&
      /relation "fs_dev_servers" does not exist/i.test(err.message)
    ) {
      return [];
    }
    console.error("[anatomy] fs_dev_servers query failed:", err);
    return [];
  }
}

// ──────────────────────────────────────────────────
// Handler
// ──────────────────────────────────────────────────

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ projectId: string }> },
) {
  try {
    const { projectId } = await params;
    const session = await authSession();
    if (!session?.user?.email) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const rows = await query<{
      data: Record<string, unknown>;
      vibn_workspace_id: string | null;
    }>(
      `SELECT p.data, p.vibn_workspace_id FROM fs_projects p
       JOIN fs_users u ON u.id = p.user_id
       WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
      [projectId, session.user.email],
    );
    if (rows.length === 0) {
      return NextResponse.json({ error: "Project not found" }, { status: 404 });
    }

    const data = rows[0].data;
    const workspaceId = rows[0].vibn_workspace_id ?? undefined;
    const giteaRepo = data?.giteaRepo as string | undefined;
    let coolifyProjectUuid = data?.coolifyProjectUuid as string | undefined;

    // Self-heal: if a previous bug stored the *workspace*'s Coolify
    // project UUID on this project (which would surface every other
    // project's services as if they belonged to this one), wipe it
    // and re-provision a dedicated Coolify project on the fly.
    if (coolifyProjectUuid && workspaceId) {
      try {
        const wsRow = await query<{
          slug: string;
          coolify_project_uuid: string | null;
        }>(
          `SELECT slug, coolify_project_uuid FROM vibn_workspaces WHERE id = $1 LIMIT 1`,
          [workspaceId],
        );
        const ws = wsRow[0];
        if (
          ws &&
          ws.coolify_project_uuid &&
          ws.coolify_project_uuid === coolifyProjectUuid
        ) {
          console.warn(
            "[anatomy] Project",
            projectId,
            "had workspace-UUID stored as coolifyProjectUuid — re-provisioning.",
          );
          const projectSlug = (data?.slug as string | undefined) ?? projectId;
          const projectNm =
            (data?.productName as string | undefined) ?? projectSlug;
          const wantName = `vibn-${ws.slug}-${projectSlug}`;
          try {
            const all = await listCoolifyProjects();
            const existing = all.find((p) => p.name === wantName);
            const fresh = existing
              ? existing
              : await createCoolifyProject(
                  wantName,
                  `Vibn project ${projectNm} - workspace ${ws.slug}`,
                );
            await query(
              `UPDATE fs_projects
                  SET data = data || jsonb_build_object('coolifyProjectUuid', $2::text),
                      updated_at = NOW()
                WHERE id = $1`,
              [projectId, fresh.uuid],
            );
            coolifyProjectUuid = fresh.uuid;
          } catch (provErr) {
            console.error("[anatomy] auto-heal provisioning failed:", provErr);
            coolifyProjectUuid = undefined; // refuse to serve workspace-level resources
          }
        }
      } catch (wsErr) {
        console.error(
          "[anatomy] workspace lookup for self-heal failed:",
          wsErr,
        );
      }
    }
    const projectName =
      (data?.productName as string | undefined) ??
      (data?.name as string | undefined) ??
      "Project";

    const [
      codebasesResult,
      repoApps,
      allServices,
      previews,
      databases,
      bundledStorage,
    ] = await Promise.all([
      giteaRepo
        ? discoverCodebases(giteaRepo).catch((err) => {
            console.error("[anatomy] discoverCodebases failed:", err);
            return {
              codebases: [] as Codebase[],
              reason: "empty_repo" as const,
            };
          })
        : Promise.resolve({
            codebases: [] as Codebase[],
            reason: undefined as undefined,
          }),
      loadRepoApps(giteaRepo),
      loadProjectServices(coolifyProjectUuid),
      loadPreviews(projectId),
      loadDatabases(coolifyProjectUuid),
      loadBundledStorage(workspaceId),
    ]);

    // Pull last-build + in-flight build summaries for repo apps in
    // parallel (small N). In parallel, fan out env-var fetches to drive
    // provider/secret detection.
    const [activities, allEnvs] = await Promise.all([
      Promise.all(repoApps.map((a) => deploymentActivityFor(a.uuid))),
      loadAllEnvs(
        repoApps,
        allServices.filter((s) => !isDevContainer(s)),
      ),
    ]);

    // Image services (Coolify services minus vibn-dev-*)
    const imageServices = allServices.filter((s) => !isDevContainer(s));

    const productImages: ProductImage[] = imageServices.map((s) => {
      const { image, version } = extractImageInfo(s);
      return {
        uuid: s.uuid,
        name: s.name,
        image,
        version,
        serviceType: s.service_type,
        status: s.status,
      };
    });

    const liveFromRepo: LiveEndpoint[] = repoApps.map((app, i) => {
      const domains = prioritiseFqdns(fqdnsOf(app.fqdn));
      return {
        uuid: app.uuid,
        name: app.name,
        source: "repo",
        sourceLabel:
          shortFormOfRepo(app.git_repository) || (giteaRepo ?? "repo"),
        status: app.status,
        fqdn: domains[0],
        domains,
        branch: app.git_branch,
        buildPack: app.build_pack,
        lastBuild: activities[i]?.lastBuild,
        inFlightBuild: activities[i]?.inFlight,
      };
    });

    // Compute smart aggregate statuses + harvest inner-app fqdns for
    // every image service in parallel — see smartServiceMetaFor.
    const smartMeta = await Promise.all(
      imageServices.map((s) =>
        smartServiceMetaFor(
          s.uuid,
          s.status,
          (s as unknown as { fqdn?: string }).fqdn,
        ),
      ),
    );

    const liveFromImage: LiveEndpoint[] = imageServices.map((s, i) => {
      const domains = prioritiseFqdns(smartMeta[i].fqdns);
      const { image, version } = extractImageInfo(s);
      return {
        uuid: s.uuid,
        name: s.name,
        source: "image",
        sourceLabel: version ? `${image}:${version}` : image,
        status: smartMeta[i].status,
        fqdn: domains[0],
        domains,
      };
    });

    const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo
      ? "no_repo"
      : codebasesResult.reason;

    const anatomy: Anatomy = {
      project: {
        id: projectId,
        name: projectName,
        gitea: giteaRepo,
        coolifyProjectUuid,
      },
      codebasesReason,
      product: {
        codebases: codebasesResult.codebases,
        images: productImages,
      },
      hosting: {
        live: [...liveFromRepo, ...liveFromImage],
        previews,
      },
      infrastructure: {
        databases,
        providers: detectProviders(allEnvs),
        bundledStorage,
        secrets: summariseSecrets(allEnvs),
      },
    };

    return NextResponse.json(anatomy);
  } catch (err) {
    console.error("[anatomy API]", err);
    return NextResponse.json(
      { error: "Failed to build anatomy" },
      { status: 500 },
    );
  }
}
