/**
 * Coolify API client for Vibn project provisioning.
 *
 * Used server-side only. Credentials from env vars:
 *   COOLIFY_URL                    — e.g. https://coolify.vibnai.com
 *   COOLIFY_API_TOKEN              — admin bearer token
 *   COOLIFY_DEFAULT_SERVER_UUID    — which Coolify server workspaces deploy to
 *   COOLIFY_DEFAULT_DESTINATION_UUID — Docker destination on that server
 */

const COOLIFY_URL = process.env.COOLIFY_URL ?? "https://coolify.vibnai.com";
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? "";
const COOLIFY_DEFAULT_SERVER_UUID =
  process.env.COOLIFY_DEFAULT_SERVER_UUID ?? "jws4g4cgssss4cw48s488woc";
const COOLIFY_DEFAULT_DESTINATION_UUID =
  process.env.COOLIFY_DEFAULT_DESTINATION_UUID ?? "zkogkggkw0wg40gccks80oo0";

export interface CoolifyProject {
  uuid: string;
  name: string;
  description?: string;
  environments?: Array<{
    id: number;
    uuid: string;
    name: string;
    project_id: number;
  }>;
}

/** All database flavors Coolify v4 can provision. */
export type CoolifyDatabaseType =
  | "postgresql"
  | "mysql"
  | "mariadb"
  | "mongodb"
  | "redis"
  | "keydb"
  | "dragonfly"
  | "clickhouse";

export interface CoolifyDatabase {
  uuid: string;
  name: string;
  type?: string;
  status: string;
  internal_db_url?: string;
  external_db_url?: string;
  is_public?: boolean;
  public_port?: number;
  project_uuid?: string;
  environment_id?: number;
  environment?: {
    id?: number;
    project_uuid?: string;
    project?: { uuid?: string };
  };
}

export interface CoolifyApplication {
  uuid: string;
  name: string;
  status: string;
  fqdn?: string;
  domains?: string;
  git_repository?: string;
  git_branch?: string;
  project_uuid?: string;
  environment_id?: number;
  environment_name?: string;
  environment?: {
    id?: number;
    project_uuid?: string;
    project?: { uuid?: string };
  };
  build_pack?: string;
}

/**
 * Coolify env var, as returned by GET /applications/{uuid}/envs.
 *
 * NOTE on build-time vars: Coolify removed `is_build_time` from the
 * **write** schema some time ago. The flag is now a derived read-only
 * attribute (`is_buildtime`, one word) computed from whether the var
 * is referenced as a Dockerfile ARG. `is_build_time` (underscored) is
 * kept here only to tolerate very old read responses — never send it
 * on POST/PATCH. See `COOLIFY_ENV_WRITE_FIELDS` below.
 */
export interface CoolifyEnvVar {
  uuid?: string;
  key: string;
  value: string;
  is_preview?: boolean;
  /** @deprecated read-only, derived server-side. Do not send on write. */
  is_build_time?: boolean;
  /** Newer one-word spelling of the same derived read-only flag. */
  is_buildtime?: boolean;
  is_runtime?: boolean;
  is_literal?: boolean;
  is_multiline?: boolean;
  is_shown_once?: boolean;
  is_shared?: boolean;
}

/**
 * The only fields Coolify v4 accepts on POST/PATCH /applications/{uuid}/envs.
 * Any other field (notably `is_build_time`) triggers a 422
 * "This field is not allowed." Build-time vs runtime is no longer a
 * writable flag — Coolify infers it at build time.
 *
 * Source of truth:
 *   https://coolify.io/docs/api-reference/api/operations/update-env-by-application-uuid
 *   https://coolify.io/docs/api-reference/api/operations/create-env-by-application-uuid
 */
const COOLIFY_ENV_WRITE_FIELDS = [
  "key",
  "value",
  "is_preview",
  "is_literal",
  "is_multiline",
  "is_shown_once",
] as const;

type CoolifyEnvWritePayload = {
  key: string;
  value: string;
  is_preview?: boolean;
  is_literal?: boolean;
  is_multiline?: boolean;
  is_shown_once?: boolean;
};

function toCoolifyEnvWritePayload(env: CoolifyEnvVar): CoolifyEnvWritePayload {
  const src = env as unknown as Record<string, unknown>;
  const out: Record<string, unknown> = {};
  for (const k of COOLIFY_ENV_WRITE_FIELDS) {
    const v = src[k];
    if (v !== undefined) out[k] = v;
  }
  return out as CoolifyEnvWritePayload;
}

export interface CoolifyPrivateKey {
  uuid: string;
  name: string;
  description?: string;
  fingerprint?: string;
}

export interface CoolifyServer {
  uuid: string;
  name: string;
  ip: string;
  is_reachable?: boolean;
  settings?: { wildcard_domain?: string | null };
}

export interface CoolifyDeployment {
  uuid: string;
  status: string;
  created_at?: string;
  finished_at?: string;
  commit?: string;
}

async function coolifyFetch(path: string, options: RequestInit = {}) {
  const url = `${COOLIFY_URL}/api/v1${path}`;
  const res = await fetch(url, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
      ...(options.headers ?? {}),
    },
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Coolify API error ${res.status} on ${path}: ${text}`);
  }
  if (res.status === 204) return null;
  return res.json();
}

// ──────────────────────────────────────────────────
// Projects
// ──────────────────────────────────────────────────

export async function listProjects(): Promise<CoolifyProject[]> {
  return coolifyFetch("/projects");
}

export async function createProject(
  name: string,
  description?: string,
): Promise<CoolifyProject> {
  // Coolify validates the description against a narrow allowlist:
  // letters, numbers, spaces, and `- _ . , ! ? ( ) ' " + = * / @ &`.
  // Anything else (colon, semicolon, brackets, pipe, …) errors out with
  // a 422 — and a 422 here used to silently fall back to the workspace
  // project, leaking services across tenants. Sanitize defensively so
  // every caller is safe.
  const cleaned = description
    ? description
        .replace(/[^A-Za-z0-9 \-_.,!?()'"+=*/@&]/g, "-")
        .replace(/-{2,}/g, "-")
        .trim()
        .slice(0, 255)
    : undefined;
  return coolifyFetch("/projects", {
    method: "POST",
    body: JSON.stringify({ name, description: cleaned }),
  });
}

export async function getProject(uuid: string): Promise<CoolifyProject> {
  return coolifyFetch(`/projects/${uuid}`);
}

export async function deleteProject(uuid: string): Promise<void> {
  await coolifyFetch(`/projects/${uuid}`, { method: "DELETE" });
}

// ──────────────────────────────────────────────────
// Servers
// ──────────────────────────────────────────────────

export async function listServers(): Promise<CoolifyServer[]> {
  return coolifyFetch("/servers");
}

// ──────────────────────────────────────────────────
// Private keys (SSH deploy keys)
// ──────────────────────────────────────────────────

export async function listPrivateKeys(): Promise<CoolifyPrivateKey[]> {
  return coolifyFetch("/security/keys");
}

export async function createPrivateKey(opts: {
  name: string;
  privateKeyPem: string;
  description?: string;
}): Promise<CoolifyPrivateKey> {
  return coolifyFetch("/security/keys", {
    method: "POST",
    body: JSON.stringify({
      name: opts.name,
      private_key: opts.privateKeyPem,
      description: opts.description ?? "",
    }),
  });
}

export async function deletePrivateKey(uuid: string): Promise<void> {
  await coolifyFetch(`/security/keys/${uuid}`, { method: "DELETE" });
}

// ──────────────────────────────────────────────────
// Databases (all 8 types)
// ──────────────────────────────────────────────────

/** Shared context used by every database-create call. */
export interface CoolifyDbCreateContext {
  projectUuid: string;
  serverUuid?: string;
  environmentName?: string;
  destinationUuid?: string;
  instantDeploy?: boolean;
}

export interface CreateDatabaseOpts extends CoolifyDbCreateContext {
  type: CoolifyDatabaseType;
  name: string;
  description?: string;
  image?: string;
  isPublic?: boolean;
  publicPort?: number;
  /** Type-specific credentials / config */
  credentials?: Record<string, unknown>;
  /** Resource limits */
  limits?: { memory?: string; cpus?: string };
}

/**
 * Create a database of the requested type. Dispatches to the right
 * POST /databases/{type} endpoint and packs the right credential
 * fields for each flavor.
 */
export async function createDatabase(
  opts: CreateDatabaseOpts,
): Promise<{ uuid: string }> {
  const {
    type,
    name,
    projectUuid,
    serverUuid = COOLIFY_DEFAULT_SERVER_UUID,
    environmentName = "production",
    destinationUuid = COOLIFY_DEFAULT_DESTINATION_UUID,
    instantDeploy = true,
    description,
    image,
    isPublic,
    publicPort,
    credentials = {},
    limits = {},
  } = opts;

  const common: Record<string, unknown> = {
    project_uuid: projectUuid,
    server_uuid: serverUuid,
    environment_name: environmentName,
    destination_uuid: destinationUuid,
    name,
    description,
    image,
    is_public: isPublic,
    public_port: publicPort,
    instant_deploy: instantDeploy,
    limits_memory: limits.memory,
    limits_cpus: limits.cpus,
  };

  return coolifyFetch(`/databases/${type}`, {
    method: "POST",
    body: JSON.stringify(stripUndefined({ ...common, ...credentials })),
  });
}

export async function listDatabases(): Promise<CoolifyDatabase[]> {
  return coolifyFetch("/databases");
}

export async function getDatabase(uuid: string): Promise<CoolifyDatabase> {
  return coolifyFetch(`/databases/${uuid}`);
}

export async function updateDatabase(
  uuid: string,
  patch: Record<string, unknown>,
): Promise<{ uuid: string }> {
  return coolifyFetch(`/databases/${uuid}`, {
    method: "PATCH",
    body: JSON.stringify(stripUndefined(patch)),
  });
}

export async function deleteDatabase(
  uuid: string,
  opts: {
    deleteConfigurations?: boolean;
    deleteVolumes?: boolean;
    dockerCleanup?: boolean;
    deleteConnectedNetworks?: boolean;
  } = {},
): Promise<void> {
  const q = new URLSearchParams();
  if (opts.deleteConfigurations !== undefined)
    q.set("delete_configurations", String(opts.deleteConfigurations));
  if (opts.deleteVolumes !== undefined)
    q.set("delete_volumes", String(opts.deleteVolumes));
  if (opts.dockerCleanup !== undefined)
    q.set("docker_cleanup", String(opts.dockerCleanup));
  if (opts.deleteConnectedNetworks !== undefined)
    q.set("delete_connected_networks", String(opts.deleteConnectedNetworks));
  const qs = q.toString();
  await coolifyFetch(`/databases/${uuid}${qs ? "?" + qs : ""}`, {
    method: "DELETE",
  });
}

export async function startDatabase(uuid: string): Promise<void> {
  await coolifyFetch(`/databases/${uuid}/start`, { method: "POST" });
}

export async function stopDatabase(uuid: string): Promise<void> {
  await coolifyFetch(`/databases/${uuid}/stop`, { method: "POST" });
}

export async function restartDatabase(uuid: string): Promise<void> {
  await coolifyFetch(`/databases/${uuid}/restart`, { method: "POST" });
}

// ──────────────────────────────────────────────────
// Applications
// ──────────────────────────────────────────────────

export type CoolifyBuildPack =
  | "nixpacks"
  | "static"
  | "dockerfile"
  | "dockercompose"
  | "dockerimage";

export interface CreatePrivateDeployKeyAppOpts {
  projectUuid: string;
  privateKeyUuid: string;
  gitRepository: string; // SSH URL: git@git.vibnai.com:vibn-mark/repo.git
  gitBranch?: string;
  portsExposes: string; // "3000"
  serverUuid?: string;
  environmentName?: string;
  destinationUuid?: string;
  buildPack?: CoolifyBuildPack;
  name?: string;
  description?: string;
  domains?: string; // comma-separated FQDNs
  isAutoDeployEnabled?: boolean;
  isForceHttpsEnabled?: boolean;
  instantDeploy?: boolean;
  installCommand?: string;
  buildCommand?: string;
  startCommand?: string;
  baseDirectory?: string;
  dockerfileLocation?: string;
  manualWebhookSecretGitea?: string;
}

export async function createPrivateDeployKeyApp(
  opts: CreatePrivateDeployKeyAppOpts,
): Promise<{ uuid: string }> {
  const body = stripUndefined({
    project_uuid: opts.projectUuid,
    server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
    environment_name: opts.environmentName ?? "production",
    destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
    private_key_uuid: opts.privateKeyUuid,
    git_repository: opts.gitRepository,
    git_branch: opts.gitBranch ?? "main",
    build_pack: opts.buildPack ?? "nixpacks",
    ports_exposes: opts.portsExposes,
    name: opts.name,
    description: opts.description,
    domains: opts.domains,
    is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true,
    is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
    instant_deploy: opts.instantDeploy ?? true,
    install_command: opts.installCommand,
    build_command: opts.buildCommand,
    start_command: opts.startCommand,
    base_directory: opts.baseDirectory,
    dockerfile_location: opts.dockerfileLocation,
    manual_webhook_secret_gitea: opts.manualWebhookSecretGitea,
  });
  return coolifyFetch("/applications/private-deploy-key", {
    method: "POST",
    body: JSON.stringify(body),
  });
}

export interface CreatePublicAppOpts {
  projectUuid: string;
  gitRepository: string; // https URL (can embed basic-auth creds for private repos)
  gitBranch?: string;
  portsExposes: string;
  serverUuid?: string;
  environmentName?: string;
  destinationUuid?: string;
  buildPack?: CoolifyBuildPack;
  name?: string;
  description?: string;
  domains?: string;
  isAutoDeployEnabled?: boolean;
  isForceHttpsEnabled?: boolean;
  instantDeploy?: boolean;
  installCommand?: string;
  buildCommand?: string;
  startCommand?: string;
  baseDirectory?: string;
  dockerfileLocation?: string;
  dockerComposeLocation?: string;
  manualWebhookSecretGitea?: string;
}

export async function createPublicApp(
  opts: CreatePublicAppOpts,
): Promise<{ uuid: string }> {
  const body = stripUndefined({
    project_uuid: opts.projectUuid,
    server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
    environment_name: opts.environmentName ?? "production",
    destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
    git_repository: opts.gitRepository,
    git_branch: opts.gitBranch ?? "main",
    build_pack: opts.buildPack ?? "nixpacks",
    ports_exposes: opts.portsExposes,
    name: opts.name,
    description: opts.description,
    domains: opts.domains,
    is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true,
    is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
    instant_deploy: opts.instantDeploy ?? true,
    install_command: opts.installCommand,
    build_command: opts.buildCommand,
    start_command: opts.startCommand,
    base_directory: opts.baseDirectory,
    dockerfile_location: opts.dockerfileLocation,
    docker_compose_location: opts.dockerComposeLocation,
    manual_webhook_secret_gitea: opts.manualWebhookSecretGitea,
  });
  return coolifyFetch("/applications/public", {
    method: "POST",
    body: JSON.stringify(body),
  });
}

// ──────────────────────────────────────────────────
// Repo-free app creation (Docker image / raw compose)
// ──────────────────────────────────────────────────

export interface CreateDockerImageAppOpts {
  projectUuid: string;
  image: string; // e.g. "twentyhq/twenty:1.23.0" or "nginx:alpine"
  name?: string;
  portsExposes?: string; // default "80"
  domains?: string;
  description?: string;
  serverUuid?: string;
  environmentName?: string;
  destinationUuid?: string;
  isForceHttpsEnabled?: boolean;
  instantDeploy?: boolean;
}

export async function createDockerImageApp(
  opts: CreateDockerImageAppOpts,
): Promise<{ uuid: string }> {
  const body = stripUndefined({
    project_uuid: opts.projectUuid,
    server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
    environment_name: opts.environmentName ?? "production",
    destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
    docker_registry_image_name: opts.image,
    name: opts.name,
    description: opts.description,
    ports_exposes: opts.portsExposes ?? "80",
    domains: opts.domains,
    is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
    instant_deploy: opts.instantDeploy ?? false,
  });
  return coolifyFetch("/applications/dockerimage", {
    method: "POST",
    body: JSON.stringify(body),
  });
}

export interface CreateDockerComposeAppOpts {
  projectUuid: string;
  composeRaw: string; // raw docker-compose YAML as a string
  name?: string;
  description?: string;
  serverUuid?: string;
  environmentName?: string;
  destinationUuid?: string;
  isForceHttpsEnabled?: boolean;
  instantDeploy?: boolean;
  /**
   * Map compose service(s) to public domain(s) after creation.
   * Array of { service, domain } pairs. The first entry becomes the
   * primary public URL.
   */
  composeDomains?: Array<{ service: string; domain: string }>;
}

/**
 * Create an inline Docker Compose stack.
 *
 * Coolify's /applications/dockercompose endpoint creates a **Service**
 * resource (not an Application). Services have their own API:
 *   /services/{uuid}          GET/PATCH
 *   /services/{uuid}/envs     GET/POST
 *   /services/{uuid}/start    POST
 *   /services/{uuid}/stop     POST
 *   /services/{uuid}          DELETE
 * The UUID returned here is a service UUID; callers should treat it as
 * such and use the service helpers below for lifecycle operations.
 */
export async function createDockerComposeApp(
  opts: CreateDockerComposeAppOpts,
): Promise<{ uuid: string; resourceType: "service" }> {
  const body = stripUndefined({
    project_uuid: opts.projectUuid,
    server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
    environment_name: opts.environmentName ?? "production",
    destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
    name: opts.name,
    description: opts.description,
    docker_compose_raw: Buffer.from(opts.composeRaw, "utf8").toString("base64"),
    instant_deploy: opts.instantDeploy ?? false,
  });
  const created = (await coolifyFetch("/applications/dockercompose", {
    method: "POST",
    body: JSON.stringify(body),
  })) as { uuid: string };
  return { uuid: created.uuid, resourceType: "service" };
}

// ── Coolify Services API ─────────────────────────────────────────────

export async function startService(uuid: string): Promise<void> {
  await coolifyFetch(`/services/${uuid}/start`, { method: "POST" });
}

export async function stopService(uuid: string): Promise<void> {
  await coolifyFetch(`/services/${uuid}/stop`, { method: "POST" });
}

export interface ServiceEnvVar {
  key: string;
  value: string;
  is_preview?: boolean;
  is_literal?: boolean;
}

export async function listServiceEnvs(uuid: string): Promise<ServiceEnvVar[]> {
  return coolifyFetch(`/services/${uuid}/envs`);
}

export async function bulkUpsertServiceEnvs(
  uuid: string,
  envs: ServiceEnvVar[],
): Promise<void> {
  await coolifyFetch(`/services/${uuid}/envs/bulk`, {
    method: "PUT",
    body: JSON.stringify({ data: envs }),
  });
}

export async function upsertServiceEnv(
  uuid: string,
  env: ServiceEnvVar,
): Promise<void> {
  // Coolify auto-creates env entries (with empty values) for every
  // ${VAR} reference in the compose file at service-creation time.
  // POST to /envs returns "already exists" for those — we must use
  // PATCH to update them. Try POST first, fall back to PATCH.
  try {
    await coolifyFetch(`/services/${uuid}/envs`, {
      method: "POST",
      body: JSON.stringify(env),
    });
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : String(err);
    if (msg.includes("already exists")) {
      await coolifyFetch(`/services/${uuid}/envs`, {
        method: "PATCH",
        body: JSON.stringify(env),
      });
      return;
    }
    throw err;
  }
}

export async function listAllServices(): Promise<
  Array<Record<string, unknown>>
> {
  return coolifyFetch("/services") as Promise<Array<Record<string, unknown>>>;
}

export async function updateApplication(
  uuid: string,
  patch: Record<string, unknown>,
): Promise<{ uuid: string }> {
  return coolifyFetch(`/applications/${uuid}`, {
    method: "PATCH",
    body: JSON.stringify(stripUndefined(patch)),
  });
}

export async function setApplicationDomains(
  uuid: string,
  domains: string[],
  opts: {
    forceOverride?: boolean;
    /**
     * Build pack of the target app. Required to dispatch to the right
     * Coolify field:
     *   - dockercompose  → docker_compose_domains (per-service JSON)
     *   - everything else → domains (comma-separated string)
     * If omitted we GET the app and detect it.
     */
    buildPack?: string;
    /**
     * For compose apps only: which compose service should receive the
     * public domain(s). Defaults to 'server' (matches Twenty, Plane,
     * Cal.com). Ignored for non-compose apps.
     */
    composeService?: string;
  } = {},
): Promise<{ uuid: string }> {
  // Coolify validates each entry as a URL, so bare hostnames need a scheme.
  const normalized = domains.map((d) => {
    const trimmed = d.trim();
    if (/^https?:\/\//i.test(trimmed)) return trimmed;
    return `https://${trimmed}`;
  });

  let buildPack = opts.buildPack;
  if (!buildPack) {
    const app = await getApplication(uuid);
    buildPack = (app.build_pack ?? "nixpacks") as string;
  }

  // ── Compose apps: set per-service domains ──────────────────────────
  // Coolify hard-rejects the top-level `domains` field for dockercompose
  // (HTTP 422: "Use docker_compose_domains instead"). Domains live per
  // compose service — we default to the `server` service, which matches
  // the majority of self-hostable apps (Twenty, Plane, Cal.com, etc.).
  if (buildPack === "dockercompose") {
    const service = (opts.composeService ?? "server").trim();
    // Coolify accepts an array of {name, domain}; ONE entry per service.
    // Multiple domains → comma-join into the single service's `domain`
    // field (Coolify splits on comma internally).
    const payload = [{ name: service, domain: normalized.join(",") }];
    return updateApplication(uuid, {
      docker_compose_domains: payload,
      is_force_https_enabled: true,
    });
  }

  // ── Single-container apps: top-level `domains` (maps to fqdn) ──────
  // Coolify maps `domains` → the DB `fqdn` column, but only when the
  // destination server has `proxy.type=TRAEFIK`/`CADDY` AND
  // `is_build_server=false` (Server::isProxyShouldRun() returns true).
  // If either is misconfigured, the PATCH silently drops the field
  // (200 OK but fqdn unchanged). Fix that in Coolify's server config,
  // not here. Sending `fqdn` directly returns 422.
  return updateApplication(uuid, {
    domains: normalized.join(","),
    force_domain_override: opts.forceOverride ?? true,
    is_force_https_enabled: true,
  });
}

export async function deleteApplication(
  uuid: string,
  opts: {
    deleteConfigurations?: boolean;
    deleteVolumes?: boolean;
    dockerCleanup?: boolean;
    deleteConnectedNetworks?: boolean;
  } = {},
): Promise<void> {
  const q = new URLSearchParams();
  if (opts.deleteConfigurations !== undefined)
    q.set("delete_configurations", String(opts.deleteConfigurations));
  if (opts.deleteVolumes !== undefined)
    q.set("delete_volumes", String(opts.deleteVolumes));
  if (opts.dockerCleanup !== undefined)
    q.set("docker_cleanup", String(opts.dockerCleanup));
  if (opts.deleteConnectedNetworks !== undefined)
    q.set("delete_connected_networks", String(opts.deleteConnectedNetworks));
  const qs = q.toString();
  await coolifyFetch(`/applications/${uuid}${qs ? "?" + qs : ""}`, {
    method: "DELETE",
  });
}

export async function listApplications(): Promise<CoolifyApplication[]> {
  return coolifyFetch("/applications");
}

export async function deployApplication(
  uuid: string,
  opts: { force?: boolean } = {},
): Promise<{ deployment_uuid: string }> {
  // Coolify v4 exposes deploy as POST /deploy?uuid=...&force=...
  // The older /applications/{uuid}/deploy path is a 404 on current
  // Coolify releases.
  const q = new URLSearchParams({ uuid });
  if (opts.force) q.set("force", "true");
  const res = await coolifyFetch(`/deploy?${q.toString()}`, { method: "POST" });
  // Response shape: { deployments: [{ deployment_uuid, ... }] } or direct object.
  const first = Array.isArray(res?.deployments) ? res.deployments[0] : res;
  return {
    deployment_uuid: first?.deployment_uuid ?? first?.uuid ?? "",
  };
}

export async function getApplication(
  uuid: string,
): Promise<CoolifyApplication> {
  return coolifyFetch(`/applications/${uuid}`);
}

export async function startApplication(uuid: string): Promise<void> {
  await coolifyFetch(`/applications/${uuid}/start`, { method: "POST" });
}

export async function stopApplication(uuid: string): Promise<void> {
  await coolifyFetch(`/applications/${uuid}/stop`, { method: "POST" });
}

export async function restartApplication(uuid: string): Promise<void> {
  await coolifyFetch(`/applications/${uuid}/restart`, { method: "POST" });
}

export async function getDeploymentLogs(
  deploymentUuid: string,
): Promise<{ logs: string }> {
  return coolifyFetch(`/deployments/${deploymentUuid}/logs`);
}

/**
 * Coolify's "runtime logs" endpoint. Returns `{ logs: "…" }` for simple
 * Dockerfile/nixpacks apps; returns an empty string for `dockercompose`
 * apps (Coolify v4 doesn't know which of the compose services to tail).
 * Use coolify-logs.getApplicationRuntimeLogs for the compose-aware path.
 */
export async function getApplicationRuntimeLogsFromApi(
  uuid: string,
  lines = 200,
): Promise<{ logs: string }> {
  // If it's a compose service, the applications endpoint will 404, so we try the services endpoint fallback.
  // Note: Coolify API doesn't currently expose /services/{uuid}/logs natively,
  // but we should avoid throwing a 400 on the /applications path if it's explicitly a service.
  try {
    return await coolifyFetch(
      `/applications/${uuid}/logs?lines=${Math.max(1, Math.min(lines, 5000))}`,
    );
  } catch (err: any) {
    if (err?.status === 404 || err?.message?.includes("404")) {
      try {
        return await coolifyFetch(
          `/services/${uuid}/logs?lines=${Math.max(1, Math.min(lines, 5000))}`,
        );
      } catch (svcErr) {
        throw err; // throw original applications err if service logs don't exist either
      }
    }
    throw err;
  }
}

export async function listApplicationDeployments(
  uuid: string,
): Promise<CoolifyDeployment[]> {
  // Coolify v4 nests this under /deployments/applications/{uuid}
  // and returns { count, deployments }. Normalize to a flat array.
  const raw = await coolifyFetch(`/deployments/applications/${uuid}?take=50`);
  if (Array.isArray(raw)) return raw as CoolifyDeployment[];
  return (raw?.deployments ?? []) as CoolifyDeployment[];
}

// ──────────────────────────────────────────────────
// Legacy monorepo helper (still used by older flows)
// ──────────────────────────────────────────────────

export async function createMonorepoAppService(opts: {
  projectUuid: string;
  appName: string;
  gitRepo: string;
  gitBranch?: string;
  domain: string;
  serverUuid?: string;
  environmentName?: string;
}): Promise<CoolifyApplication> {
  const {
    projectUuid,
    appName,
    gitRepo,
    gitBranch = "main",
    domain,
    serverUuid = COOLIFY_DEFAULT_SERVER_UUID,
    environmentName = "production",
  } = opts;
  return coolifyFetch(`/applications`, {
    method: "POST",
    body: JSON.stringify({
      project_uuid: projectUuid,
      name: appName,
      git_repository: gitRepo,
      git_branch: gitBranch,
      server_uuid: serverUuid,
      environment_name: environmentName,
      build_pack: "nixpacks",
      build_command: `pnpm install && turbo run build --filter=${appName}`,
      start_command: `turbo run start --filter=${appName}`,
      ports_exposes: "3000",
      fqdn: `https://${domain}`,
    }),
  });
}

// ──────────────────────────────────────────────────
// Environment variables
// ──────────────────────────────────────────────────

export async function listApplicationEnvs(
  uuid: string,
): Promise<CoolifyEnvVar[]> {
  return coolifyFetch(`/applications/${uuid}/envs`);
}

export async function upsertApplicationEnv(
  uuid: string,
  env: CoolifyEnvVar & { is_preview?: boolean },
): Promise<CoolifyEnvVar> {
  // Strip any read-only/derived fields (`is_build_time`, `is_buildtime`,
  // `is_runtime`, `is_shared`, `uuid`) before sending — Coolify returns
  // 422 "This field is not allowed." for anything outside the write
  // schema. See COOLIFY_ENV_WRITE_FIELDS.
  const payload = toCoolifyEnvWritePayload(env);
  try {
    return await coolifyFetch(`/applications/${uuid}/envs`, {
      method: "PATCH",
      body: JSON.stringify(payload),
    });
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    if (msg.includes("404") || msg.includes("405")) {
      return coolifyFetch(`/applications/${uuid}/envs`, {
        method: "POST",
        body: JSON.stringify(payload),
      });
    }
    throw err;
  }
}

export async function deleteApplicationEnv(
  uuid: string,
  key: string,
): Promise<void> {
  await coolifyFetch(`/applications/${uuid}/envs/${encodeURIComponent(key)}`, {
    method: "DELETE",
  });
}

// ──────────────────────────────────────────────────
// Services (raw Coolify service resources)
// ──────────────────────────────────────────────────

export interface CoolifyService {
  uuid: string;
  name: string;
  status?: string;
  /** Coolify template slug the service was provisioned from, e.g. "pocketbase". */
  service_type?: string;
  project_uuid?: string;
  environment_id?: number;
  environment?: {
    id?: number;
    project_uuid?: string;
    project?: { uuid?: string };
  };
}

export async function listServices(): Promise<CoolifyService[]> {
  return coolifyFetch("/services");
}

export async function getService(uuid: string): Promise<CoolifyService> {
  return coolifyFetch(`/services/${uuid}`);
}

export async function createService(opts: {
  projectUuid: string;
  type: string; // e.g. "pocketbase", "authentik", "zitadel"
  name: string;
  description?: string;
  serverUuid?: string;
  environmentName?: string;
  destinationUuid?: string;
  instantDeploy?: boolean;
}): Promise<{ uuid: string }> {
  const body = stripUndefined({
    project_uuid: opts.projectUuid,
    server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
    environment_name: opts.environmentName ?? "production",
    destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
    type: opts.type,
    name: opts.name,
    description: opts.description,
    instant_deploy: opts.instantDeploy ?? true,
  });
  return coolifyFetch("/services", {
    method: "POST",
    body: JSON.stringify(body),
  });
}

/**
 * Patch a Coolify Service with arbitrary fields. Used for things like
 * `urls` (custom domains), `name`, `description`, etc.
 */
export async function updateService(
  uuid: string,
  patch: Record<string, unknown>,
): Promise<{ uuid: string }> {
  return coolifyFetch(`/services/${uuid}`, {
    method: "PATCH",
    body: JSON.stringify(stripUndefined(patch)),
  });
}

/**
 * Set custom URLs on a Coolify Service. Coolify auto-assigns sslip.io
 * domains at service-creation time; this PATCH replaces them with the
 * caller-supplied FQDN.
 *
 * Each entry in `urls` maps a compose service name (the YAML key) to a
 * full URL. For most one-click templates the primary HTTP service is
 * named after the template slug (e.g. template "twenty" → service
 * "twenty"). If a template uses a different name, callers should pass
 * the explicit mapping.
 *
 * Reasoning behind the shape: Coolify expects
 *   { urls: [{ name: "twenty", url: "https://my.example.com" }, ...] }
 * where `name` is the docker-compose service key. Passing only `url`
 * silently no-ops.
 */
export async function setServiceDomains(
  uuid: string,
  urls: Array<{ name: string; url: string }>,
): Promise<{ uuid: string }> {
  const normalized = urls.map((u) => ({
    name: u.name,
    url: /^https?:\/\//i.test(u.url.trim())
      ? u.url.trim()
      : `https://${u.url.trim()}`,
  }));
  return updateService(uuid, { urls: normalized });
}

// ── Coolify Service Templates (one-click catalog) ────────────────────
//
// Coolify maintains a curated catalog of 320+ deployable services
// (CRMs, AI tools, CMSes, dashboards, databases, etc). Each entry has
// a slug ("twenty", "n8n", "supabase"), a slogan, tags, a logo path
// and a base64-encoded docker-compose.yml.
//
// Coolify itself does NOT expose this catalog over its REST API — the
// Laravel helper `get_service_templates()` reads it directly from
// `/var/www/html/templates/service-templates.json`. To avoid SSH-ing
// into the Coolify host every time, we fetch the canonical JSON from
// the upstream repo and cache it in memory for an hour.
//
// Source of truth:
//   https://github.com/coollabsio/coolify/blob/main/templates/service-templates.json

export interface CoolifyServiceTemplate {
  /** Slug used as the `type` in `POST /services` (e.g. "twenty"). */
  slug: string;
  /** One-line marketing description from upstream. */
  slogan?: string;
  /** Free-form tags ("crm", "ai", "wiki", …). */
  tags?: string[];
  /** Coarse category (often missing). */
  category?: string;
  /** Path under coollabsio/coolify-static/svgs (no full URL). */
  logo?: string;
  /** Default HTTP port the primary service listens on. */
  port?: number;
  /** Documentation URL provided by the template author. */
  documentation?: string;
  /** Minimum Coolify version required. */
  minversion?: string;
}

const TEMPLATES_URL =
  process.env.COOLIFY_TEMPLATES_URL ??
  "https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json";

let templatesCache: {
  fetchedAt: number;
  data: Record<string, CoolifyServiceTemplate>;
} | null = null;
const TEMPLATES_TTL_MS = 60 * 60 * 1000;

/**
 * Fetch the full Coolify service template catalog. In-memory cache for
 * one hour because the upstream JSON is ~1MB and only changes when
 * Coolify ships new templates.
 */
export async function listServiceTemplates(
  opts: { force?: boolean } = {},
): Promise<Record<string, CoolifyServiceTemplate>> {
  const now = Date.now();
  if (
    !opts.force &&
    templatesCache &&
    now - templatesCache.fetchedAt < TEMPLATES_TTL_MS
  ) {
    return templatesCache.data;
  }
  const res = await fetch(TEMPLATES_URL, { cache: "no-store" });
  if (!res.ok)
    throw new Error(
      `Failed to fetch service templates: ${res.status} ${res.statusText}`,
    );
  const raw = (await res.json()) as Record<string, Record<string, unknown>>;
  const data: Record<string, CoolifyServiceTemplate> = {};
  for (const [slug, t] of Object.entries(raw)) {
    data[slug] = {
      slug,
      slogan: typeof t.slogan === "string" ? t.slogan : undefined,
      tags: Array.isArray(t.tags)
        ? t.tags.filter((x): x is string => typeof x === "string")
        : undefined,
      category: typeof t.category === "string" ? t.category : undefined,
      logo: typeof t.logo === "string" ? t.logo : undefined,
      // Coolify's catalog stores port as either a number (e.g. 3000)
      // or a numeric string (e.g. "3000") — handle both.
      port:
        typeof t.port === "number"
          ? t.port
          : typeof t.port === "string" && /^\d+$/.test(t.port.trim())
            ? Number(t.port.trim())
            : undefined,
      documentation:
        typeof t.documentation === "string" ? t.documentation : undefined,
      minversion: typeof t.minversion === "string" ? t.minversion : undefined,
    };
  }
  templatesCache = { fetchedAt: now, data };
  return data;
}

/**
 * Search the template catalog by slug, slogan, or tag (case-insensitive
 * substring match). Returns at most `limit` matches sorted by:
 *   1. exact slug match
 *   2. slug starts-with
 *   3. tag match
 *   4. slogan substring
 */
export async function searchServiceTemplates(
  query: string,
  opts: { limit?: number; tag?: string } = {},
): Promise<CoolifyServiceTemplate[]> {
  const all = await listServiceTemplates();
  const q = query.trim().toLowerCase();
  const tagFilter = opts.tag?.trim().toLowerCase();
  const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));

  const scored: Array<{ score: number; t: CoolifyServiceTemplate }> = [];
  for (const t of Object.values(all)) {
    if (
      tagFilter &&
      !(t.tags ?? []).some((x) => x.toLowerCase().includes(tagFilter))
    )
      continue;
    let score = 0;
    const slug = t.slug.toLowerCase();
    const slogan = (t.slogan ?? "").toLowerCase();
    const tags = (t.tags ?? []).map((x) => x.toLowerCase());
    if (!q) {
      score = 1; // tag-only filter — include everything that passed
    } else if (slug === q) score = 1000;
    else if (slug.startsWith(q)) score = 500;
    else if (slug.includes(q)) score = 300;
    else if (tags.includes(q)) score = 200;
    else if (tags.some((x) => x.includes(q))) score = 100;
    else if (slogan.includes(q)) score = 50;
    if (score > 0) scored.push({ score, t });
  }
  scored.sort((a, b) => b.score - a.score || a.t.slug.localeCompare(b.t.slug));
  return scored.slice(0, limit).map((s) => s.t);
}

export async function deleteService(
  uuid: string,
  opts: {
    deleteConfigurations?: boolean;
    deleteVolumes?: boolean;
    dockerCleanup?: boolean;
    deleteConnectedNetworks?: boolean;
  } = {},
): Promise<void> {
  const q = new URLSearchParams();
  if (opts.deleteConfigurations !== undefined)
    q.set("delete_configurations", String(opts.deleteConfigurations));
  if (opts.deleteVolumes !== undefined)
    q.set("delete_volumes", String(opts.deleteVolumes));
  if (opts.dockerCleanup !== undefined)
    q.set("docker_cleanup", String(opts.dockerCleanup));
  if (opts.deleteConnectedNetworks !== undefined)
    q.set("delete_connected_networks", String(opts.deleteConnectedNetworks));
  const qs = q.toString();
  await coolifyFetch(`/services/${uuid}${qs ? "?" + qs : ""}`, {
    method: "DELETE",
  });
}

// ──────────────────────────────────────────────────
// Tenant helpers — every endpoint that returns an app/db/service runs
// through one of these so cross-project access is impossible.
//
// Coolify v4's /applications, /databases and /services list endpoints do NOT
// include `project_uuid` on the returned resources. The authoritative link is
// `environment_id`, which we match against the project's environments (fetched
// via /projects/{uuid}).
//
// For efficient listing per project, we use `/projects/{uuid}/{envName}` which
// returns the resources scoped to that project+environment in one call.
// ──────────────────────────────────────────────────

export class TenantError extends Error {
  status = 403 as const;
}

function envIdOf(
  resource: CoolifyApplication | CoolifyDatabase | CoolifyService,
): number | null {
  return (
    (typeof resource.environment_id === "number"
      ? resource.environment_id
      : null) ??
    (resource.environment && typeof resource.environment.id === "number"
      ? resource.environment.id
      : null)
  );
}

function explicitProjectUuidOf(
  resource: CoolifyApplication | CoolifyDatabase | CoolifyService,
): string | null {
  return (
    resource.project_uuid ??
    resource.environment?.project_uuid ??
    resource.environment?.project?.uuid ??
    null
  );
}

/** Fetch the set of environment IDs that belong to a project. */
async function projectEnvIds(projectUuid: string): Promise<Set<number>> {
  const project = await getProject(projectUuid);
  const ids = new Set<number>();
  for (const env of project.environments ?? []) {
    if (typeof env.id === "number") ids.add(env.id);
  }
  return ids;
}

async function ensureResourceInProject(
  resource: CoolifyApplication | CoolifyDatabase | CoolifyService,
  resourceKind: string,
  expectedProjectUuid: string,
): Promise<void> {
  const explicit = explicitProjectUuidOf(resource);
  if (explicit && explicit === expectedProjectUuid) return;
  if (explicit && explicit !== expectedProjectUuid) {
    throw new TenantError(
      `${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}`,
    );
  }
  const envId = envIdOf(resource);
  if (envId == null) {
    throw new TenantError(
      `${resourceKind} ${resource.uuid} has no environment_id; cannot verify project ${expectedProjectUuid}`,
    );
  }
  const envIds = await projectEnvIds(expectedProjectUuid);
  if (!envIds.has(envId)) {
    throw new TenantError(
      `${resourceKind} ${resource.uuid} does not belong to project ${expectedProjectUuid}`,
    );
  }
}

export async function getApplicationInProject(
  appUuid: string,
  expectedProjectUuid: string,
): Promise<CoolifyApplication> {
  const app = await getApplication(appUuid);
  await ensureResourceInProject(app, "Application", expectedProjectUuid);
  return app;
}

export async function getDatabaseInProject(
  dbUuid: string,
  expectedProjectUuid: string,
): Promise<CoolifyDatabase> {
  const db = await getDatabase(dbUuid);
  await ensureResourceInProject(db, "Database", expectedProjectUuid);
  return db;
}

export async function getServiceInProject(
  serviceUuid: string,
  expectedProjectUuid: string,
): Promise<CoolifyService> {
  const svc = await getService(serviceUuid);
  await ensureResourceInProject(svc, "Service", expectedProjectUuid);
  return svc;
}

// ──────────────────────────────────────────────────
// Workspace-set-aware variants
// ──────────────────────────────────────────────────
// Each Vibn workspace owns multiple Coolify projects (the legacy
// `vibn-ws-{slug}` plus one per Vibn project: `vibn-{slug}-{project-slug}`).
// These helpers verify a resource belongs to ANY of the workspace's owned
// projects — used by single-resource MCP tools (apps.get/delete/exec/etc.)
// so they keep working after we shifted to per-project Coolify projects.

async function ensureResourceInWorkspaceProjects(
  resource: CoolifyApplication | CoolifyDatabase | CoolifyService,
  resourceKind: string,
  ownedProjectUuids: Set<string>,
): Promise<void> {
  if (ownedProjectUuids.size === 0) {
    throw new TenantError(
      `${resourceKind} ${resource.uuid}: workspace owns no Coolify projects`,
    );
  }
  const explicit = explicitProjectUuidOf(resource);
  if (explicit && ownedProjectUuids.has(explicit)) return;
  if (explicit && !ownedProjectUuids.has(explicit)) {
    throw new TenantError(
      `${resourceKind} ${resource.uuid} does not belong to this workspace`,
    );
  }
  const envId = envIdOf(resource);
  if (envId == null) {
    throw new TenantError(
      `${resourceKind} ${resource.uuid} has no environment_id; cannot verify workspace membership`,
    );
  }
  // Build env_id → project_uuid map from all owned projects (parallel fetch).
  const projects = await Promise.allSettled(
    Array.from(ownedProjectUuids).map((uuid) => getProject(uuid)),
  );
  const allowedEnvIds = new Set<number>();
  for (const r of projects) {
    if (r.status === "fulfilled") {
      for (const env of r.value.environments ?? []) {
        if (typeof env.id === "number") allowedEnvIds.add(env.id);
      }
    }
  }
  if (!allowedEnvIds.has(envId)) {
    throw new TenantError(
      `${resourceKind} ${resource.uuid} does not belong to this workspace`,
    );
  }
}

export async function getApplicationInWorkspace(
  appUuid: string,
  ownedProjectUuids: Set<string>,
): Promise<CoolifyApplication> {
  const app = await getApplication(appUuid);
  await ensureResourceInWorkspaceProjects(
    app,
    "Application",
    ownedProjectUuids,
  );
  return app;
}

export async function getDatabaseInWorkspace(
  dbUuid: string,
  ownedProjectUuids: Set<string>,
): Promise<CoolifyDatabase> {
  const db = await getDatabase(dbUuid);
  await ensureResourceInWorkspaceProjects(db, "Database", ownedProjectUuids);
  return db;
}

export async function getServiceInWorkspace(
  serviceUuid: string,
  ownedProjectUuids: Set<string>,
): Promise<CoolifyService> {
  const svc = await getService(serviceUuid);
  await ensureResourceInWorkspaceProjects(svc, "Service", ownedProjectUuids);
  return svc;
}

/**
 * Response shape of GET /projects/{uuid}/{envName}.
 * Coolify splits databases by flavor across sibling arrays.
 */
interface CoolifyProjectEnvResources {
  id: number;
  uuid: string;
  name: string;
  applications?: CoolifyApplication[];
  services?: CoolifyService[];
  postgresqls?: CoolifyDatabase[];
  mysqls?: CoolifyDatabase[];
  mariadbs?: CoolifyDatabase[];
  mongodbs?: CoolifyDatabase[];
  redis?: CoolifyDatabase[];
  keydbs?: CoolifyDatabase[];
  dragonflies?: CoolifyDatabase[];
  clickhouses?: CoolifyDatabase[];
}

/** Maps Coolify's plural endpoint key → the engine label we want to
 *  surface on every CoolifyDatabase record. Coolify's flattened
 *  per-resource shape doesn't include `type`, so we derive it from
 *  whichever array we pulled the row out of. */
const DB_ARRAY_KEYS_TO_TYPE: Array<{
  key: keyof CoolifyProjectEnvResources;
  type: string;
}> = [
  { key: "postgresqls", type: "postgresql" },
  { key: "mysqls", type: "mysql" },
  { key: "mariadbs", type: "mariadb" },
  { key: "mongodbs", type: "mongodb" },
  { key: "redis", type: "redis" },
  { key: "keydbs", type: "keydb" },
  { key: "dragonflies", type: "dragonfly" },
  { key: "clickhouses", type: "clickhouse" },
];

async function getProjectEnvResources(
  projectUuid: string,
  envName: string,
): Promise<CoolifyProjectEnvResources> {
  return coolifyFetch(
    `/projects/${projectUuid}/${encodeURIComponent(envName)}`,
  );
}

async function forEachEnv<T>(
  projectUuid: string,
  collect: (envResources: CoolifyProjectEnvResources) => T[],
): Promise<T[]> {
  const project = await getProject(projectUuid);
  const out: T[] = [];
  for (const env of project.environments ?? []) {
    const envResources = await getProjectEnvResources(projectUuid, env.name);
    out.push(...collect(envResources));
  }
  return out;
}

export async function listApplicationsInProject(
  projectUuid: string,
): Promise<CoolifyApplication[]> {
  return forEachEnv(projectUuid, (r) => r.applications ?? []);
}

export async function listDatabasesInProject(
  projectUuid: string,
): Promise<CoolifyDatabase[]> {
  return forEachEnv(projectUuid, (r) => {
    const out: CoolifyDatabase[] = [];
    for (const { key, type } of DB_ARRAY_KEYS_TO_TYPE) {
      const arr = r[key];
      if (!Array.isArray(arr)) continue;
      for (const db of arr as CoolifyDatabase[]) {
        // Always tag with the engine we derived from the array key —
        // Coolify itself doesn't set `type` on the individual record.
        out.push({ ...db, type: db.type ?? type });
      }
    }
    return out;
  });
}

export async function listServicesInProject(
  projectUuid: string,
): Promise<CoolifyService[]> {
  return forEachEnv(projectUuid, (r) => r.services ?? []);
}

/** @deprecated Use getApplicationInProject / ensureResourceInProject instead. */
export function projectUuidOf(
  resource: CoolifyApplication | CoolifyDatabase | CoolifyService,
): string | null {
  return explicitProjectUuidOf(resource);
}

// ──────────────────────────────────────────────────
// util
// ──────────────────────────────────────────────────

function stripUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
  const out: Partial<T> = {};
  for (const [k, v] of Object.entries(obj)) {
    if (v !== undefined) (out as Record<string, unknown>)[k] = v;
  }
  return out;
}

export { COOLIFY_DEFAULT_SERVER_UUID, COOLIFY_DEFAULT_DESTINATION_UUID };
