/**
 * Database introspection helpers — list tables and preview rows.
 *
 * Coolify-managed databases run in their own docker network, unreachable
 * from vibn-frontend's container. We route through the Coolify host via
 * SSH and `docker exec` into the database container itself, where the
 * native client (psql / redis-cli / mongosh) is already installed and
 * pre-authenticated via the container's own POSTGRES_USER / etc envs.
 *
 * v1 supports Postgres; Redis + MongoDB are stubbed and return helpful
 * "not yet supported" errors so the UI can render a useful empty state.
 *
 * Hard limits, by design:
 *   - 50 tables max returned per call
 *   - 50 rows max per table preview
 *   - 200 chars truncation per cell value
 *   - 8s wall-clock SSH timeout
 *   - Only SELECT-style queries; never any mutation
 */

import { runOnCoolifyHost } from "./coolify-ssh";
import { listContainersForApp } from "./coolify-containers";

export interface IntrospectedTable {
  schema: string;
  name: string;
  /** Approximate row count from pg_class.reltuples — fast, not exact. */
  approxRows?: number;
}

export interface PreviewedTable {
  columns: string[];
  rows: Array<Record<string, string>>;
  truncated: boolean;
  totalRowsApprox?: number;
}

const MAX_TABLES = 50;
const MAX_ROWS = 50;
const MAX_CELL_CHARS = 200;
const SSH_TIMEOUT_MS = 8_000;

/** Find the running container for a Coolify database uuid. */
async function resolveDbContainer(dbUuid: string): Promise<string> {
  const containers = await listContainersForApp(dbUuid);
  const running = containers.find(c => /up /i.test(c.status));
  const target = running ?? containers[0];
  if (!target) {
    throw new Error(`No container found for database ${dbUuid}. Database may be stopped.`);
  }
  return target.name;
}

/** Single-quote escape a token for bash. */
function sq(s: string): string {
  return `'${s.replace(/'/g, `'\\''`)}'`;
}

// ──────────────────────────────────────────────────
// Postgres
// ──────────────────────────────────────────────────

/**
 * List tables across every non-system schema in every non-template database.
 *
 * Coolify reports a single `postgres_db` per database resource, but the
 * cluster usually has more than one db inside (e.g. Twenty CRM connects
 * to `default`, not `postgres`). We enumerate every non-template db with
 * `psql -l`-style query, then union table listings across all of them.
 *
 * When there's more than one db, the IntrospectedTable.schema field is
 * stamped as `<db>.<schema>` so the UI can disambiguate; with a single
 * db we keep the bare schema name so `public` flattens like before.
 */
async function pgListTables(container: string): Promise<IntrospectedTable[]> {
  const dbs = await pgListDatabases(container);
  if (dbs.length === 0) return [];

  const tablesSql = `
    SELECT n.nspname || '|' || c.relname || '|' || COALESCE(c.reltuples::bigint, 0)
      FROM pg_class c
      JOIN pg_namespace n ON n.oid = c.relnamespace
     WHERE c.relkind IN ('r', 'p')
       AND n.nspname NOT IN ('pg_catalog', 'information_schema')
       AND n.nspname NOT LIKE 'pg_%'
     ORDER BY n.nspname, c.relname
     LIMIT ${MAX_TABLES + 1};
  `.replace(/\s+/g, " ").trim();

  const all: IntrospectedTable[] = [];
  const multi = dbs.length > 1;
  for (const db of dbs) {
    if (all.length >= MAX_TABLES) break;
    const cmd =
      `docker exec ${sq(container)} bash -c ` +
      sq(`psql -U "$POSTGRES_USER" -d ${sqInner(db)} -tAF '|' -c ${sqInner(tablesSql)}`);
    const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS });
    if (res.code !== 0) {
      // Skip dbs the role can't connect to (e.g. owned by another user).
      continue;
    }
    for (const line of res.stdout.split("\n")) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      const [schema, name, rowsStr] = trimmed.split("|");
      if (!schema || !name) continue;
      const approxRows = Number(rowsStr);
      all.push({
        schema: multi ? `${db}.${schema}` : schema,
        name,
        approxRows: Number.isFinite(approxRows) && approxRows >= 0 ? approxRows : undefined,
      });
      if (all.length >= MAX_TABLES) break;
    }
  }
  return all.slice(0, MAX_TABLES);
}

/** Enumerate non-template, connectable databases in the cluster. */
async function pgListDatabases(container: string): Promise<string[]> {
  const sql =
    `SELECT datname FROM pg_database ` +
    `WHERE datistemplate = false AND datallowconn = true ` +
    `ORDER BY (datname = 'postgres'), datname;`; // user dbs first
  const cmd =
    `docker exec ${sq(container)} bash -c ` +
    sq(`psql -U "$POSTGRES_USER" -d postgres -tA -c ${sqInner(sql)}`);
  const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS });
  if (res.code !== 0) {
    throw new Error(`psql -l exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`);
  }
  return res.stdout
    .split("\n")
    .map(l => l.trim())
    .filter(name => name && /^[A-Za-z0-9_]+$/.test(name));
}

/**
 * Preview the first MAX_ROWS rows of a single table. Identifiers are
 * locked to /^[A-Za-z0-9_]+$/ so we can safely splice them into SQL —
 * Postgres identifiers can technically be wider, but anything outside
 * that range gets rejected up-front to keep the surface small.
 */
async function pgPreviewTable(
  container: string,
  schema: string,
  table: string,
): Promise<PreviewedTable> {
  // Schema can be either bare ("public") or db-qualified ("default.public")
  // when the cluster has more than one database.
  let database = "postgres";
  let bareSchema = schema;
  if (schema.includes(".")) {
    const [db, rest] = schema.split(".", 2);
    database = db;
    bareSchema = rest;
  }

  if (
    !/^[A-Za-z0-9_]+$/.test(bareSchema) ||
    !/^[A-Za-z0-9_]+$/.test(table) ||
    !/^[A-Za-z0-9_]+$/.test(database)
  ) {
    throw new Error("Invalid identifier");
  }

  // Build `SELECT row_to_json(t)::text FROM (SELECT * FROM s.t LIMIT N) t`
  // so each output line is a self-contained JSON object that's resilient
  // to embedded pipes / newlines.
  const sql =
    `SELECT row_to_json(t)::text ` +
    `FROM (SELECT * FROM "${bareSchema}"."${table}" LIMIT ${MAX_ROWS + 1}) t;`;

  const cmd =
    `docker exec ${sq(container)} bash -c ` +
    sq(`psql -U "$POSTGRES_USER" -d ${sqInner(database)} -tA -c ${sqInner(sql)}`);

  const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS });
  if (res.code !== 0) {
    throw new Error(`psql exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`);
  }

  const lines = res.stdout.split("\n").map(l => l.trim()).filter(Boolean);
  const truncated = lines.length > MAX_ROWS;
  const rowJson = lines.slice(0, MAX_ROWS);

  const columnSet = new Set<string>();
  const parsed: Record<string, unknown>[] = [];
  for (const line of rowJson) {
    try {
      const obj = JSON.parse(line) as Record<string, unknown>;
      Object.keys(obj).forEach(k => columnSet.add(k));
      parsed.push(obj);
    } catch {
      // Skip non-JSON lines (shouldn't happen with -tA, but be safe).
    }
  }

  // Stable column order: order they appeared in the first row, then
  // any extras at the end.
  const firstRowKeys = parsed[0] ? Object.keys(parsed[0]) : [];
  const extras = [...columnSet].filter(k => !firstRowKeys.includes(k));
  const columns = [...firstRowKeys, ...extras];

  const rows = parsed.map(row => {
    const out: Record<string, string> = {};
    for (const col of columns) {
      out[col] = formatCell(row[col]);
    }
    return out;
  });

  return { columns, rows, truncated };
}

/** Inner sq for the bash -c '…' wrapper: we need to single-quote the
 *  SQL itself, but we're already inside one layer of single quotes
 *  for `bash -c '…'` — switch to double quotes for the inner level
 *  and rely on the fact that psql's -c argument tolerates them. */
function sqInner(s: string): string {
  return `"${s.replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`;
}

function formatCell(v: unknown): string {
  if (v == null) return "—";
  if (typeof v === "string") {
    return v.length > MAX_CELL_CHARS ? v.slice(0, MAX_CELL_CHARS) + "…" : v;
  }
  if (typeof v === "number" || typeof v === "boolean") return String(v);
  const json = JSON.stringify(v);
  return json.length > MAX_CELL_CHARS ? json.slice(0, MAX_CELL_CHARS) + "…" : json;
}

// ──────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────

export class IntrospectionUnsupportedError extends Error {
  constructor(public engine: string) {
    super(`Introspection not yet supported for ${engine}`);
  }
}

export async function listTables(
  dbUuid: string,
  engine: string,
): Promise<IntrospectedTable[]> {
  if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine);
  const container = await resolveDbContainer(dbUuid);
  return pgListTables(container);
}

export async function previewTable(
  dbUuid: string,
  engine: string,
  schema: string,
  table: string,
): Promise<PreviewedTable> {
  if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine);
  const container = await resolveDbContainer(dbUuid);
  return pgPreviewTable(container, schema, table);
}
