import { newId, NotFoundError } from '@briven/shared';
import { and, desc, eq } from 'drizzle-orm';

import { getDb } from '../db/client.js';
import { log } from '../lib/logger.js';
import {
  deployments,
  type Deployment,
  type DeploymentStatus,
  type NewDeployment,
} from '../db/schema.js';
import { publishEvent } from './outbound-webhooks.js';

/**
 * Source-file extensions a function file may carry. A deployed function's
 * identity is its bare basename, but callers historically sent (and the
 * bundle still keys by) the on-disk filename with one of these extensions.
 */
const SOURCE_EXTENSIONS = ['.tsx', '.ts', '.mjs', '.cjs', '.js'] as const;

/**
 * Strip a single trailing source extension from a function name so the
 * stored/registered name is the bare basename:
 *   `listNotes.ts`  → `listNotes`
 *   `my.helper.ts`  → `my.helper`   (only the known extension is removed)
 *   `listNotes`     → `listNotes`   (already bare — unchanged)
 * Bundle keys are intentionally NOT run through this — the runtime reads
 * source from `functions/<name>.ts` and relies on the extension staying.
 */
export function stripFunctionExt(name: string): string {
  for (const ext of SOURCE_EXTENSIONS) {
    if (name.endsWith(ext)) return name.slice(0, -ext.length);
  }
  return name;
}

export interface CreateDeploymentInput {
  projectId: string;
  triggeredBy: string | null;
  apiKeyId: string | null;
  schemaDiffSummary?: Record<string, unknown>;
  schemaSnapshot?: Record<string, unknown>;
  functionCount?: number;
  functionNames?: readonly string[];
  bundle?: Readonly<Record<string, string>>;
}

export async function createDeployment(input: CreateDeploymentInput): Promise<Deployment> {
  const row: NewDeployment = {
    id: newId('d'),
    projectId: input.projectId,
    triggeredBy: input.triggeredBy,
    apiKeyId: input.apiKeyId,
    status: 'pending',
    schemaDiffSummary: input.schemaDiffSummary ?? null,
    schemaSnapshot: input.schemaSnapshot ?? null,
    functionCount: input.functionCount != null ? String(input.functionCount) : null,
    // Register the bare basename as the function's identity. This normalises
    // every write path (CLI deploy + PATCH /deployments/latest, which derives
    // names from bundle keys) so an invoke for `listNotes` matches.
    functionNames: input.functionNames ? input.functionNames.map(stripFunctionExt) : null,
    bundle: input.bundle ? { ...input.bundle } : null,
  };
  const db = getDb();
  const [created] = await db.insert(deployments).values(row).returning();
  if (!created) throw new Error('deployment insert returned no row');
  return created;
}

export async function getDeploymentBundle(
  deploymentId: string,
): Promise<Readonly<Record<string, string>> | null> {
  const db = getDb();
  const [row] = await db
    .select({ bundle: deployments.bundle })
    .from(deployments)
    .where(eq(deployments.id, deploymentId))
    .limit(1);
  return (row?.bundle as Record<string, string> | null) ?? null;
}

/**
 * The most recent deployment eligible to serve invokes. Phase 1 scope:
 * any deployment other than `failed` or `cancelled` counts — there is no
 * runner yet that transitions pending→succeeded. Phase 2 tightens this to
 * `succeeded` only once the shard worker lands.
 */
export async function getCurrentDeployment(projectId: string): Promise<Deployment | null> {
  const db = getDb();
  const [row] = await db
    .select()
    .from(deployments)
    .where(eq(deployments.projectId, projectId))
    .orderBy(desc(deployments.createdAt))
    .limit(1);
  if (!row) return null;
  if (row.status === 'failed' || row.status === 'cancelled') return null;
  return row;
}

/**
 * The most recent deployment whose schema snapshot is authoritative for the
 * project. "Authoritative" means `succeeded`, or — before any deployment has
 * ever succeeded — the most recent `pending`/`running` one. This mirrors
 * what a CLI needs to compute the next diff against.
 */
export async function getCurrentSchema(projectId: string): Promise<{
  deploymentId: string | null;
  snapshot: Record<string, unknown> | null;
}> {
  const db = getDb();
  const [succeeded] = await db
    .select({ id: deployments.id, snapshot: deployments.schemaSnapshot })
    .from(deployments)
    .where(and(eq(deployments.projectId, projectId), eq(deployments.status, 'succeeded')))
    .orderBy(desc(deployments.createdAt))
    .limit(1);
  if (succeeded) {
    return {
      deploymentId: succeeded.id,
      snapshot: (succeeded.snapshot as Record<string, unknown> | null) ?? null,
    };
  }
  return { deploymentId: null, snapshot: null };
}

export async function listDeploymentsForProject(
  projectId: string,
  limit = 50,
): Promise<Deployment[]> {
  const db = getDb();
  return db
    .select()
    .from(deployments)
    .where(eq(deployments.projectId, projectId))
    .orderBy(desc(deployments.createdAt))
    .limit(limit);
}

export async function getDeployment(projectId: string, deploymentId: string): Promise<Deployment> {
  const db = getDb();
  const [row] = await db
    .select()
    .from(deployments)
    .where(and(eq(deployments.id, deploymentId), eq(deployments.projectId, projectId)))
    .limit(1);
  if (!row) throw new NotFoundError('deployment', deploymentId);
  return row;
}

export interface TransitionDeploymentInput {
  projectId: string;
  deploymentId: string;
  status: DeploymentStatus;
  errorCode?: string | null;
  errorMessage?: string | null;
}

export async function transitionDeployment(input: TransitionDeploymentInput): Promise<Deployment> {
  const existing = await getDeployment(input.projectId, input.deploymentId);
  const now = new Date();
  const patch: Partial<Deployment> = { status: input.status };
  if (input.status === 'running' && !existing.startedAt) patch.startedAt = now;
  if (input.status === 'succeeded' || input.status === 'failed' || input.status === 'cancelled') {
    patch.finishedAt = now;
    if (!existing.startedAt) patch.startedAt = now;
  }
  if (input.errorCode !== undefined) patch.errorCode = input.errorCode;
  if (input.errorMessage !== undefined) patch.errorMessage = input.errorMessage;

  const db = getDb();
  const [updated] = await db
    .update(deployments)
    .set(patch)
    .where(eq(deployments.id, input.deploymentId))
    .returning();
  if (!updated) throw new Error('deployment update returned no row');

  // Fan-out to outbound subscribers on terminal transitions only. Errors
  // here are swallowed: the deploy state-machine is the load-bearing
  // path, the notification is opportunistic.
  if (input.status === 'succeeded' || input.status === 'failed') {
    const eventType = input.status === 'succeeded' ? 'deploy.succeeded' : 'deploy.failed';
    void publishEvent({
      projectId: input.projectId,
      eventType,
      payload: {
        projectId: input.projectId,
        deploymentId: updated.id,
        status: updated.status,
        finishedAt: updated.finishedAt?.toISOString() ?? new Date().toISOString(),
        errorCode: updated.errorCode ?? null,
        errorMessage: updated.errorMessage ?? null,
      },
    }).catch((err: unknown) => {
      log.warn('outbound_publish_failed', {
        event: eventType,
        projectId: input.projectId,
        deploymentId: updated.id,
        message: err instanceof Error ? err.message : String(err),
      });
    });
  }
  return updated;
}

export async function cancelPendingDeployment(
  projectId: string,
  deploymentId: string,
): Promise<Deployment> {
  const existing = await getDeployment(projectId, deploymentId);
  if (existing.status !== 'pending' && existing.status !== 'running') {
    return existing;
  }
  return transitionDeployment({
    projectId,
    deploymentId,
    status: 'cancelled',
  });
}
