#!/usr/bin/env bun

// pb - render source files as syntax-highlighted HTML and upload to S3 or a remote host
//
// usage: pb -f|--file <file> [-l|--lang <language>] [--s3 | -d|--dest user@host:/path]
// example: pb -f main.go
//          pb -f main.go --lang go --s3
//          pb -f main.go --dest user@example.com:/var/www/html/
//
// s3 env vars (required when using --s3):
//   AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
//   S3_BUCKET or AWS_BUCKET (bucket name)
//   AWS_ENDPOINT_URL (optional, for S3-compatible stores)

import { S3Client } from "bun";
import { basename } from "node:path";
import { bundledLanguages, codeToHtml, type BundledLanguage } from "shiki";
import template from "./index.html" with { type: "text" };

type Upload = { kind: "none" } | { kind: "s3" } | { kind: "rsync"; dest: string };

type Options = {
  language: BundledLanguage;
  file: string;
  upload: Upload;
};

function usage(exitCode = 0): never {
  const command = basename(Bun.argv[1] ?? "pb");
  const output = exitCode === 0 ? console.log : console.error;
  output(`usage: ${command} -f|--file <file> [-l|--lang <language>] [--s3 | -d|--dest user@host:/path]

options:
  -f, --file <file>             Source file to render
  -l, --lang <language>         Shiki language override
  --s3                          Upload the generated HTML to S3 using env vars
  -d, --dest <user@host:/path>  Upload the generated HTML via rsync over SSH
  -h, --help                    Show this help`);
  process.exit(exitCode);
}

function parseArgs(argv: string[]): Options {
  const options: Partial<Options> = { upload: { kind: "none" } };

  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]!;
    switch (arg) {
      case "--help":
      case "-h":
        usage();
      case "-f":
      case "--file":
        options.file = argv[++i];
        break;
      case "-l":
      case "--lang":
        options.language = parseLanguage(argv[++i]!);
        break;
      case "--s3":
        if (options.upload?.kind === "rsync") {
          console.error("--s3 and --dest are mutually exclusive");
          usage(2);
        }
        options.upload = { kind: "s3" };
        break;
      case "--dest":
      case "-d":
        if (options.upload?.kind === "s3") {
          console.error("--s3 and --dest are mutually exclusive");
          usage(2);
        }
        options.upload = { kind: "rsync", dest: argv[++i]! };
        break;
      default:
        console.error(`unknown option: ${arg}`);
        usage(2);
    }
  }

  if (!options.file) {
    console.error("missing required option: -f");
    usage(2);
  }
  options.language ??= detectLanguage(options.file);

  return options as Options;
}

async function render(
  template: string,
  language: BundledLanguage,
  file: string,
  code: string,
): Promise<string> {
  const highlighted = await codeToHtml(code, {
    lang: language,
    themes: { light: "vitesse-light", dark: "vitesse-black" },
    transformers: [
      {
        line(node, line) {
          node.properties ||= {};
          node.properties.id = `L${line}`;
          node.children.unshift({
            type: "element",
            tagName: "a",
            properties: {
              class: "line-number",
              href: `#L${line}`,
              ariaLabel: `Line ${line}`,
              dataLine: String(line),
            },
            children: [],
          });
        },
      },
    ],
  });

  return template
    .replaceAll("{{title}}", basename(file))
    .replaceAll("{{rawHref}}", encodeURIComponent(basename(file)))
    .replaceAll("{{highlighted}}", highlighted);
}

function parseLanguage(language: string): BundledLanguage {
  if (language in bundledLanguages) {
    return language as BundledLanguage;
  }

  console.error(`unsupported language: ${language}`);
  usage(2);
}

function detectLanguage(file: string): BundledLanguage {
  const base = basename(file).toLowerCase();
  const extension = base.split(".").at(-1);
  const language = extension ? languageFromCandidate(extension) : undefined;
  if (language) {
    return language;
  }

  console.error(`could not detect language from file extension: ${file}`);
  console.error("pass -l with a Shiki bundled language id");
  usage(2);
}

function languageFromCandidate(candidate: string): BundledLanguage | undefined {
  if (candidate in bundledLanguages) {
    return candidate as BundledLanguage;
  }
}

async function uploadToS3(inputFile: string, outputFile: string, html: string) {
  const client = new S3Client();
  await client.write(outputFile, html, { type: "text/html; charset=utf-8" });
  await client.write(inputFile, Bun.file(inputFile));
  console.log(`uploaded ${outputFile}`);
  console.log(`uploaded ${inputFile}`);
}

async function uploadViaRsync(inputFile: string, outputFile: string, dest: string) {
  const proc = Bun.spawn(["rsync", "-e", "ssh", outputFile, inputFile, dest], {
    stdout: "inherit",
    stderr: "inherit",
  });
  const code = await proc.exited;
  if (code !== 0) {
    console.error(`rsync exited with code ${code}`);
    process.exit(code);
  }
  console.log(`uploaded ${outputFile} to ${dest}`);
  console.log(`uploaded ${inputFile} to ${dest}`);
}

async function main() {
  const options = parseArgs(Bun.argv.slice(2));
  const code = await Bun.file(options.file).text();
  const html = await render(String(template), options.language, options.file, code);
  const outputFile = `${options.file}.html`;

  await Bun.write(outputFile, html);
  console.log(`wrote ${outputFile}`);

  switch (options.upload.kind) {
    case "s3":
      await uploadToS3(options.file, outputFile, html);
      break;
    case "rsync":
      await uploadViaRsync(options.file, outputFile, options.upload.dest);
      break;
  }
}

await main();
