#!/usr/bin/env node

import { closeSync, fchmodSync, openSync, readFileSync, writeFileSync } from "node:fs";
import * as base64 from "@hexagon/base64";
import { Command, Option } from "commander";
import type { Algorithm } from "./algorithm.ts";
import { authorize, type Claims, type Scope, ScopeSchema } from "./claims.ts";
import { generate } from "./generate.ts";
import type { Key, PublicKey } from "./key.ts";
import { load, loadPublic, sign, toPublicKey, verify } from "./key.ts";

const program = new Command();

program.name("moq-token").description("Generate, sign, and verify tokens for moq-relay").version("0.1.1");

program
	.command("generate")
	.description("Generate a new signing key")
	.requiredOption("--key <path>", "Path to save the key")
	.option("--algorithm <algorithm>", "Algorithm to use", "HS256")
	.option("--id <id>", "Key ID (randomly generated if not provided)")
	.option("--public <path>", "Path to save the public key (for asymmetric algorithms)")
	.option("--base64", "Output as base64url instead of JSON", false)
	.option("--root <root>", "Root path for the optional key scope", "")
	.option("--publish <path...>", "Publish prefixes the key may grant")
	.option("--subscribe <path...>", "Subscribe prefixes the key may grant")
	.action(async (options) => {
		try {
			const algorithm = options.algorithm as Algorithm;
			let key = await generate(algorithm, options.id);
			if (options.publish || options.subscribe) {
				// Parse rather than cast, so a useless scope fails here like it does
				// in the Rust CLI instead of writing an unusable key to disk.
				const scope: Scope = ScopeSchema.parse({
					root: options.root,
					...(options.publish && { put: options.publish }),
					...(options.subscribe && { get: options.subscribe }),
				});
				key = { ...key, scope };
			}

			const encodeKey = (k: object): string => {
				const json = JSON.stringify(k, null, 2);
				if (options.base64) {
					return base64.fromArrayBuffer(new TextEncoder().encode(json).buffer, true);
				}
				return json;
			};

			writePrivateFileSync(options.key, encodeKey(key));
			console.log(`Generated ${algorithm} key: ${options.key}`);

			if (options.public && key.kty !== "oct") {
				const publicKey = toPublicKey(key);
				writeFileSync(options.public, encodeKey(publicKey), "utf-8");
				console.log(`Generated public key: ${options.public}`);
			} else if (options.public && key.kty === "oct") {
				console.error("Warning: Cannot save public key for symmetric (oct) algorithm");
			}
		} catch (error) {
			console.error("Error generating key:", error instanceof Error ? error.message : error);
			process.exit(1);
		}
	});

program
	.command("sign")
	.description("Sign a token to stdout")
	.requiredOption("--key <path>", "Path to the key file")
	.option("--root <root>", "Root path for the token", "")
	.option("--publish <path...>", "Publish permission patterns (can be specified multiple times)")
	.option("--subscribe <path...>", "Subscribe permission patterns (can be specified multiple times)")
	.option("--expires <timestamp>", "Expiration time as unix timestamp", parseUnixTimestamp)
	.option("--issued <timestamp>", "Issued time as unix timestamp", parseUnixTimestamp)
	.action(async (options) => {
		try {
			const keyEncoded = readFileSync(options.key, "utf-8");
			const key = load(keyEncoded);

			const claims: Claims = {
				root: options.root,
				...(options.publish && { put: options.publish }),
				...(options.subscribe && { get: options.subscribe }),
				...(options.expires && { exp: options.expires }),
				...(options.issued && { iat: options.issued }),
			};

			const token = await sign(key, claims);
			console.log(token);
		} catch (error) {
			console.error("Error signing token:", error instanceof Error ? error.message : error);
			process.exit(1);
		}
	});

program
	.command("verify")
	.description("Verify a token from stdin, writing the payload to stdout")
	.requiredOption("--key <path>", "Path to the key file")
	.addOption(new Option("--root <root>", "Path to authorize the token against").hideHelp())
	.action(async (options) => {
		try {
			const keyEncoded = readFileSync(options.key, "utf-8");

			// Try to load as public key first (for asymmetric), fall back to symmetric key
			let key: Key | PublicKey | undefined;
			try {
				key = loadPublic(keyEncoded);
			} catch {
				key = load(keyEncoded);
			}

			// Read token from stdin
			const token = readFileSync(0, "utf-8").trim();

			const claims = await verify(key, token);
			if (options.root !== undefined) {
				authorize(claims, options.root);
			}
			console.log(JSON.stringify(claims, null, 2));
		} catch (error) {
			console.error("Error verifying token:", error instanceof Error ? error.message : error);
			process.exit(1);
		}
	});

/**
 * Write a file holding private key material, restricted to the owner on Unix.
 *
 * The `mode` on open(2) only applies when the file is created, and is masked by the umask either
 * way. Chmod the handle before writing so overwriting an existing world-readable key tightens it
 * and the secret never sits in a readable file.
 */
function writePrivateFileSync(path: string, contents: string) {
	const fd = openSync(path, "w", 0o600);
	try {
		// Windows has no equivalent of the mode bits, so the file inherits the directory's ACL.
		if (process.platform !== "win32") {
			fchmodSync(fd, 0o600);
		}
		// writeFileSync loops until every byte lands, unlike writeSync's single short-write-prone call.
		writeFileSync(fd, contents);
	} finally {
		closeSync(fd);
	}
}

function parseUnixTimestamp(value: string): number {
	const timestamp = Number.parseInt(value, 10);
	if (Number.isNaN(timestamp)) {
		throw new Error("Expected unix timestamp");
	}
	return timestamp;
}

program.parse();
