#!/usr/bin/env node

import { lstat, readdir } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { readCatalogIndex } from "./catalogIndex.js";

type CatalogType = "category" | "program";

type CatalogIndex = {
  type?: unknown;
  name?: unknown;
  order?: unknown;
  versions?: Array<{ version?: unknown; status?: unknown }>;
};

type CatalogVersion = {
  version: string;
  status?: "current" | "archived";
};

type CatalogNode = {
  type: CatalogType;
  name: string;
  order: number;
  directory: string;
  versions: CatalogVersion[];
  children: CatalogNode[];
};

type Options = {
  showPaths: boolean;
  showVersions: boolean;
  target: string;
};

const collator = new Intl.Collator("ru", {
  numeric: true,
  sensitivity: "base",
});

function usage(): void {
  console.log(`Usage: pnpm tree [options] [path]

With no path, the script prints the complete tree from catalog. A path may point
to a category directory, a program directory, or its index.md file.

  --versions  show versions under each program
  --paths     show relative directory paths
  --help      show this help message`);
}

function parseArguments(args: string[]): Options {
  const targets: string[] = [];
  let showPaths = false;
  let showVersions = false;

  for (const argument of args) {
    if (argument === "--paths") {
      showPaths = true;
    } else if (argument === "--versions") {
      showVersions = true;
    } else if (argument === "--help" || argument === "-h") {
      usage();
      process.exit(0);
    } else if (argument.startsWith("-")) {
      throw new Error(`Unknown option: ${argument}`);
    } else {
      targets.push(argument);
    }
  }

  if (targets.length > 1) {
    throw new Error("Only one path may be specified");
  }

  return {
    showPaths,
    showVersions,
    target: targets[0] ?? "catalog",
  };
}

function compareNodes(left: CatalogNode, right: CatalogNode): number {
  if (left.type !== right.type) {
    return left.type === "category" ? -1 : 1;
  }
  if (left.type === "category" && left.order !== right.order) {
    return left.order - right.order;
  }
  return collator.compare(left.name, right.name);
}

async function resolveDirectory(target: string): Promise<string> {
  const absoluteTarget = path.resolve(target);
  const targetStat = await lstat(absoluteTarget);

  if (targetStat.isDirectory()) {
    return absoluteTarget;
  }
  if (targetStat.isFile() && path.basename(absoluteTarget) === "index.md") {
    return path.dirname(absoluteTarget);
  }
  throw new Error(`Expected a catalog directory or index.md file: ${target}`);
}

async function readCatalogNode(directory: string): Promise<CatalogNode> {
  const indexPath = path.join(directory, "index.md");
  const { metadata: data } = await readCatalogIndex<CatalogIndex>(indexPath);
  if (data.type !== "category" && data.type !== "program") {
    throw new Error(`${indexPath}: unknown type`);
  }
  if (typeof data.name !== "string" || data.name.length === 0) {
    throw new Error(`${indexPath}: name is missing`);
  }
  if (data.type === "category" && data.order !== undefined && !Number.isInteger(data.order)) {
    throw new Error(`${indexPath}: category order must be an integer`);
  }

  const order = data.type === "category" && typeof data.order === "number" ? data.order : 0;

  const versions =
    data.type === "program"
      ? (data.versions ?? []).map((entry, index) => {
          if (typeof entry.version !== "string" || entry.version.length === 0) {
            throw new Error(`${indexPath}: invalid version at index ${index}`);
          }
          if (
            entry.status !== undefined &&
            entry.status !== "current" &&
            entry.status !== "archived"
          ) {
            throw new Error(`${indexPath}: unknown status for version ${entry.version}`);
          }
          return { version: entry.version, status: entry.status } as CatalogVersion;
        })
      : [];

  const children: CatalogNode[] = [];
  if (data.type === "category") {
    const entries = await readdir(directory, { withFileTypes: true });
    for (const entry of entries) {
      if (!entry.isDirectory()) {
        continue;
      }

      const childDirectory = path.join(directory, entry.name);
      try {
        const childIndexStat = await lstat(path.join(childDirectory, "index.md"));
        if (childIndexStat.isFile()) {
          children.push(await readCatalogNode(childDirectory));
        }
      } catch (error) {
        const code = (error as NodeJS.ErrnoException).code;
        if (code !== "ENOENT") {
          throw error;
        }
      }
    }
  }

  children.sort(compareNodes);
  return { type: data.type, name: data.name, order, directory, versions, children };
}

function formatNode(node: CatalogNode, showPaths: boolean): string {
  const categorySuffix = node.type === "category" ? "/" : "";
  if (!showPaths) {
    return `${node.name}${categorySuffix}`;
  }

  const relativePath = path.relative(process.cwd(), node.directory) || ".";
  return `${node.name}${categorySuffix} [${relativePath}]`;
}

function printChildren(
  node: CatalogNode,
  prefix: string,
  options: Pick<Options, "showPaths" | "showVersions">,
): void {
  const entries: Array<{ label: string; node?: CatalogNode }> = node.children.map(
    (child) => ({ label: formatNode(child, options.showPaths), node: child }),
  );

  if (options.showVersions && node.type === "program") {
    entries.push(
      ...node.versions.map(({ version, status }) => ({
        label: `version ${version}${status === undefined ? "" : ` [${status}]`}`,
      })),
    );
  }

  entries.forEach((entry, index) => {
    const last = index === entries.length - 1;
    console.log(`${prefix}${last ? "└── " : "├── "}${entry.label}`);
    if (entry.node !== undefined) {
      printChildren(entry.node, `${prefix}${last ? "    " : "│   "}`, options);
    }
  });
}

async function main(): Promise<void> {
  const options = parseArguments(process.argv.slice(2));
  const directory = await resolveDirectory(options.target);
  const root = await readCatalogNode(directory);

  console.log(formatNode(root, options.showPaths));
  printChildren(root, "", options);
}

try {
  await main();
} catch (error) {
  const message = error instanceof Error ? error.message : String(error);
  console.error(`Error: ${message}`);
  process.exitCode = 1;
}
