#!/usr/bin/env node
// @ts-check
/**
 * Generates (or verifies) the database schema documentation under `docs/generated/`.
 *
 * The schema is defined by the migrations, not the entities (entities run with
 * `synchronize: false`). So we bring an empty database to the fully-migrated
 * state by running the `@n8n/db` migration arrays, then point `tbls` at it to
 * render Markdown + a Mermaid ER diagram.
 *
 * One database type per process: `migration-helpers.ts` captures `DB_TYPE` /
 * `DB_TABLE_PREFIX` from the DI config at module-load time, so the env must be
 * set BEFORE `@n8n/db` is imported and cannot be changed afterwards.
 *
 *   node scripts/schema-docs.mjs <doc|diff> --db=<sqlite|postgres> [--docker]
 *
 * `--docker` (auto-enabled when `CI` is set) runs tbls via its image instead of
 * a local binary.
 */
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const PKG_ROOT = resolve(__dirname, '..');
const REPO_ROOT = resolve(__dirname, '../../../../');
const TMP_DIR = resolve(PKG_ROOT, '.tmp-schema-docs');

// Pinned by tag + digest for reproducible CI runs; bump deliberately.
const TBLS_IMAGE =
	'ghcr.io/k1low/tbls:v1.94.5@sha256:ae8a3bff6d4f8495d13a7982cd71fac3e8a3d1dd394888f2c44ef82216aa14e4';

const POSTGRES_VERSIONS_PATH = resolve(
	REPO_ROOT,
	'packages/testing/containers/postgres-versions.json',
);

function primaryPostgresImage() {
	return JSON.parse(readFileSync(POSTGRES_VERSIONS_PATH, 'utf8')).primary;
}

/** Thrown for expected, user-facing failures so `main`'s cleanup still runs. */
class FailError extends Error {}

function fail(msg) {
	throw new FailError(msg);
}

/** Turns a spawn failure into an actionable message for missing binaries. */
function spawnErrorMessage(cmd, err) {
	if (err.code === 'ENOENT') {
		if (cmd === 'tbls') {
			return 'tbls is not installed. Install it with `brew install tbls` (see https://github.com/k1LoW/tbls#install), or re-run with --docker to use the tbls Docker image instead.';
		}
		if (cmd === 'docker') {
			return 'docker is not installed or not on PATH. Install Docker, or drop --docker to use a local tbls binary.';
		}
	}
	return `failed to spawn ${cmd}: ${err.message}`;
}

function parseArgs(argv) {
	const args = { command: null, dbType: null, docker: !!process.env.CI };
	for (const arg of argv) {
		if (arg === '--docker') args.docker = true;
		else if (arg.startsWith('--db=')) args.dbType = arg.slice('--db='.length);
		else if (!arg.startsWith('--') && !args.command) args.command = arg;
	}
	return args;
}

/** Runs a command, inheriting stdio. Resolves with the exit code. */
function run(cmd, cmdArgs, env) {
	return new Promise((res, rej) => {
		const child = spawn(cmd, cmdArgs, { cwd: REPO_ROOT, env, stdio: 'inherit' });
		child.on('error', (err) => rej(new FailError(spawnErrorMessage(cmd, err))));
		child.on('close', (code) => res(code ?? 1));
	});
}

/** Runs a command, capturing stdout (used for `tbls diff`). */
function capture(cmd, cmdArgs, env) {
	return new Promise((res, rej) => {
		let stdout = '';
		const child = spawn(cmd, cmdArgs, { cwd: REPO_ROOT, env });
		child.on('error', (err) => rej(new FailError(spawnErrorMessage(cmd, err))));
		child.stdout.on('data', (d) => (stdout += d));
		child.stderr.on('data', (d) => process.stderr.write(d));
		child.on('close', (code) => res({ code: code ?? 1, stdout }));
	});
}

/** Runs a command, suppressing all output. Resolves with the exit code. */
function runQuiet(cmd, cmdArgs, env) {
	return new Promise((res, rej) => {
		const child = spawn(cmd, cmdArgs, { cwd: REPO_ROOT, env, stdio: 'ignore' });
		child.on('error', (err) => rej(new FailError(spawnErrorMessage(cmd, err))));
		child.on('close', (code) => res(code ?? 1));
	});
}

/** Runs a command, forwarding all output to stderr. Resolves with the exit code. */
function runToStderr(cmd, cmdArgs, env) {
	return new Promise((res, rej) => {
		const child = spawn(cmd, cmdArgs, {
			cwd: REPO_ROOT,
			env,
			stdio: ['ignore', 'pipe', 'pipe'],
		});
		child.on('error', (err) => rej(new FailError(spawnErrorMessage(cmd, err))));
		child.stdout.on('data', (d) => process.stderr.write(d));
		child.stderr.on('data', (d) => process.stderr.write(d));
		child.on('close', (code) => res(code ?? 1));
	});
}

/** Spins up an empty database and returns its connection details + a cleanup fn. */
async function provision(dbType) {
	if (dbType === 'sqlite') {
		const file = resolve(TMP_DIR, 'schema.sqlite');
		mkdirSync(TMP_DIR, { recursive: true });
		rmSync(file, { force: true });
		process.env.DB_TYPE = 'sqlite';
		process.env.DB_SQLITE_DATABASE = file;
		process.env.DB_TABLE_PREFIX = '';
		return {
			dataSourceOptions: { type: 'sqlite', database: file },
			file,
			cleanup: () => rmSync(file, { force: true }),
		};
	}

	// postgres: spin a throwaway container on the version the rest of the test
	// tooling defaults to.
	const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
	const container = await new PostgreSqlContainer(primaryPostgresImage()).start();
	const conn = {
		host: container.getHost(),
		port: container.getMappedPort(5432),
		username: container.getUsername(),
		password: container.getPassword(),
		database: container.getDatabase(),
		schema: 'public',
	};
	process.env.DB_TYPE = 'postgresdb';
	process.env.DB_POSTGRESDB_HOST = conn.host;
	process.env.DB_POSTGRESDB_PORT = String(conn.port);
	process.env.DB_POSTGRESDB_DATABASE = conn.database;
	process.env.DB_POSTGRESDB_USER = conn.username;
	process.env.DB_POSTGRESDB_PASSWORD = conn.password;
	process.env.DB_POSTGRESDB_SCHEMA = conn.schema;
	process.env.DB_TABLE_PREFIX = '';
	return {
		dataSourceOptions: { type: 'postgres', ...conn },
		containerId: container.getId(),
		cleanup: () => container.stop(),
	};
}

/** Builds the tbls DSN for the freshly-migrated database. */
function buildDsn(dbType, provisioned, docker) {
	if (dbType === 'sqlite') {
		// tbls reads the file directly; under Docker it lives in the /work mount.
		const filePath = docker ? `/work/${relative(REPO_ROOT, provisioned.file)}` : provisioned.file;
		return `sqlite://${filePath}`;
	}
	const conn = provisioned.dataSourceOptions;
	// Under Docker, tbls shares the Postgres container's network namespace
	// (--network container:<id> below), so it connects to the unmapped port on
	// localhost. Going through the host-mapped port instead would break on
	// hosts whose firewall drops container→host traffic (e.g. nftables setups).
	if (docker) {
		return `postgres://${conn.username}:${conn.password}@127.0.0.1:5432/${conn.database}?sslmode=disable&search_path=public`;
	}
	return `postgres://${conn.username}:${conn.password}@${conn.host}:${conn.port}/${conn.database}?sslmode=disable&search_path=public`;
}

/** Pre-pulls the tbls image, retrying on transient registry/network errors. */
async function pullImageWithRetry(image, env, attempts = 3) {
	if ((await runQuiet('docker', ['image', 'inspect', image], env)) === 0) return;

	for (let attempt = 1; attempt <= attempts; attempt++) {
		const code = await runToStderr('docker', ['pull', image], env);
		if (code === 0) return;
		if ((await runQuiet('docker', ['image', 'inspect', image], env)) === 0) {
			console.warn('docker pull failed, but the cached tbls image is available; using it.');
			return;
		}
		if (attempt === attempts) fail(`docker pull ${image} failed after ${attempts} attempts`);
		const delayMs = 5000 * attempt;
		console.warn(`docker pull failed (attempt ${attempt}/${attempts}); retrying in ${delayMs}ms…`);
		await new Promise((res) => setTimeout(res, delayMs));
	}
}

/** Invokes tbls (binary locally, Docker image in CI). */
async function tbls(command, dbType, dsn, docker, networkContainerId) {
	const config = `.tbls.${dbType}.yml`;
	const env = { ...process.env, TBLS_DSN: dsn };
	const args = command === 'diff' ? ['diff'] : ['doc', '--force'];
	args.push('-c', config);

	if (docker) {
		// Pull up front with retry so a flaky registry/network doesn't fail the
		// run; `docker run` then uses the cached image.
		await pullImageWithRetry(TBLS_IMAGE, env);

		const dockerArgs = [
			'run',
			'--rm',
			// Join the DB container's network namespace so the DSN's localhost port
			// resolves inside it — no dependency on container→host connectivity.
			...(networkContainerId ? ['--network', `container:${networkContainerId}`] : []),
			'-e',
			`TBLS_DSN=${dsn}`,
			'-v',
			`${REPO_ROOT}:/work`,
			'-w',
			'/work',
			TBLS_IMAGE,
			...args,
		];
		return command === 'diff'
			? capture('docker', dockerArgs, env)
			: { code: await run('docker', dockerArgs, env), stdout: '' };
	}
	return command === 'diff'
		? capture('tbls', args, env)
		: { code: await run('tbls', args, env), stdout: '' };
}

async function main() {
	let { command, dbType, docker } = parseArgs(process.argv.slice(2));
	if (command !== 'doc' && command !== 'diff') {
		fail('usage: schema-docs.mjs <doc|diff> --db=<sqlite|postgres> [--docker]');
	}
	if (dbType !== 'sqlite' && dbType !== 'postgres') {
		fail('--db must be sqlite or postgres');
	}

	// Resolve which tbls runtime to use, before spinning up a DB and running
	// migrations. Prefer a local tbls binary; fall back to the Docker image when
	// it's absent. `--docker` (or CI) forces the Docker path.
	const probeBin = (cmd) => {
		const { error, status } = spawnSync(cmd, ['version'], { stdio: 'ignore' });
		if (error) return 'missing';
		return status === 0 ? 'ok' : 'broken';
	};

	if (docker) {
		const state = probeBin('docker');
		if (state === 'missing') fail(spawnErrorMessage('docker', { code: 'ENOENT' }));
		if (state === 'broken')
			fail('docker is installed but not responding — is the Docker daemon running?');
	} else if (probeBin('tbls') !== 'ok') {
		if (probeBin('docker') === 'ok') {
			docker = true;
			console.info(
				'tbls not available — falling back to running in Docker. ' +
					'Install tbls (`brew install tbls` or see ' +
					'https://github.com/k1LoW/tbls#install) to run locally.',
			);
		} else {
			fail(
				'neither tbls nor docker is available. Install tbls (`brew install tbls`, ' +
					'see https://github.com/k1LoW/tbls#install) or ensure Docker is set up.',
			);
		}
	}

	const provisioned = await provision(dbType);
	try {
		// Imported only now: env above must be set before @n8n/db evaluates its
		// migration context from the DI config.
		const { DataSource } = await import('@n8n/typeorm');
		// Imported from built dist by path: the package can't resolve its own name.
		const { entities, sqliteMigrations, postgresMigrations, wrapMigration } = await import(
			new URL('../dist/index.js', import.meta.url).href
		);
		const migrations = dbType === 'sqlite' ? sqliteMigrations : postgresMigrations;

		const dataSource = new DataSource({
			...provisioned.dataSourceOptions,
			// Some migrations use the EntityManager, so entity metadata must be registered.
			entities: Object.values(entities),
			synchronize: false,
			migrationsTableName: 'migrations',
			migrations,
		});
		await dataSource.initialize();
		// Wrap only after initialize(): TypeORM constructs each migration during
		// metadata building, and wrapping installs a `transaction` getter that
		// would clash with migrations setting `transaction` in their constructor.
		migrations.forEach(wrapMigration);
		await dataSource.runMigrations({ transaction: 'each' });
		await dataSource.destroy();

		const dsn = buildDsn(dbType, provisioned, docker);
		const { code, stdout } = await tbls(command, dbType, dsn, docker, provisioned.containerId);

		if (command === 'diff') {
			// `tbls diff` prints the unified diff to stdout and exits non-zero when
			// the docs are stale. An empty stdout with a non-zero exit is a genuine
			// tbls error (e.g. cannot connect), so check the diff output first.
			if (stdout.trim() !== '') {
				process.stdout.write(stdout);
				fail(
					`schema docs for ${dbType} are out of date. Run \`pnpm db:schema:docs\` and commit the changes.`,
				);
			}
			if (code !== 0) fail(`tbls diff failed (exit ${code})`);
			console.log(`✓ ${dbType} schema docs are up to date`);
		} else if (code !== 0) {
			fail(`tbls doc failed (exit ${code})`);
		} else {
			console.log(`✓ generated ${dbType} schema docs in docs/generated/${dbType}-schema`);
		}
	} finally {
		await provisioned.cleanup();
	}
}

try {
	await main();
} catch (err) {
	if (err instanceof FailError) {
		console.error(`error: ${err.message}`);
		process.exitCode = 1;
	} else {
		throw err;
	}
}
