#!/usr/bin/env node

import { spawn } from "node:child_process";
import { access, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { readCatalogIndex } from "../scripts/catalogIndex.js";

type ProgramFile = {
  path?: unknown;
  platform?: unknown;
};

type ProgramVersion = {
  status?: unknown;
  files?: ProgramFile[];
};

type ProgramIndex = {
  type?: unknown;
  name?: unknown;
  screenshots?: unknown;
  versions?: ProgramVersion[];
};

type Program = {
  archive: string;
  directory: string;
  indexPath: string;
  name: string;
  slug: string;
};

type RemoteResult = {
  success?: boolean;
  reason?: string;
  title?: string;
  error?: string;
  selected?: string;
  installer?: boolean;
  width?: number;
  height?: number;
  windows?: Array<{ title?: string }>;
};

type Failure = {
  name: string;
  path: string;
  reason: string;
  details: string;
};

type Options = {
  catalogDirectory: string;
  host: string;
  importDirectory?: string;
  limit?: number;
  program?: string;
  replace: boolean;
  waitSeconds: number;
  write: boolean;
};

const remoteRoot = "C:\\codex\\catalog-screenshots";
const remoteScpRoot = "C:/codex/catalog-screenshots";
const programsWithoutStandaloneGui = new Set([
  "fardes",
  "farobex",
  "filescomparer",
  "hex2vkp",
  "midletsigner",
  "openall",
  "sifs",
  "slfc",
  "txt2wmlc",
  "vmo2wav",
  "vsofs",
  "wav-amr-converter",
  "zeesiemens",
]);
const errorTitle = /error|exception|failed|failure|cannot|can't|not found|access violation|achtung|ошибка|предупреждение/i;
const installerTitle = /setup|installer|installshield|installation|self-extract|winrar|установк|мастер установки|самораспаковывающ/i;
const initialKeys = new Map([
  [
    "brew-graphicpatch-creator",
    "ENTER,TEXT=C:\\codex\\catalog-screenshots\\work\\brew-graphicpatch-creator\\brewrc.exe,ENTER",
  ],
  ["brew-apploader", "CLOSE-SMALL,CLOSE-SMALL"],
  ["efs-explorer-lite", "ENTER"],
  ["flash-imager", "ENTER"],
  ["flex-table-edit", "ENTER"],
  ["gagin", "CLOSE-SMALL,CLOSE-SMALL"],
  ["freia", "SELECT-TITLE=Engine"],
  ["initmap", "CLOSE-LARGEST"],
  ["s65-key-definer", "ENTER"],
  ["siemens-screenshot", "ENTER"],
]);

function usage(): void {
  console.log(`Usage: pnpm screenshots [options] [catalog-directory]

Capture one screenshot for each Windows program.

  --host HOST       Windows SSH host (default: win)
  --import DIR      import reviewed SLUG.png files without connecting to Windows
  --program SLUG    process one program directory
  --limit COUNT     stop after COUNT programs
  --replace         recapture only existing img/SLUG.png screenshots
  --wait SECONDS    wait for a program window (default: 8)
  --write           save successful screenshots and update index.md
  --help            show this help message`);
}

function parseArguments(args: string[]): Options {
  const targets: string[] = [];
  let host = "win";
  let importDirectory: string | undefined;
  let limit: number | undefined;
  let program: string | undefined;
  let replace = false;
  let waitSeconds = 8;
  let write = false;

  for (let index = 0; index < args.length; index += 1) {
    const argument = args[index];
    if (argument === "--host") {
      host = args[++index] ?? "";
    } else if (argument === "--import") {
      importDirectory = path.resolve(args[++index] ?? "");
    } else if (argument === "--limit") {
      limit = Number(args[++index]);
    } else if (argument === "--program") {
      program = args[++index];
    } else if (argument === "--replace") {
      replace = true;
    } else if (argument === "--wait") {
      waitSeconds = Number(args[++index]);
    } else if (argument === "--write") {
      write = 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("Expected at most one catalog directory");
  }
  if (host.length === 0) {
    throw new Error("--host must not be empty");
  }
  if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
    throw new Error("--limit must be a positive integer");
  }
  if (!Number.isInteger(waitSeconds) || waitSeconds < 1 || waitSeconds > 60) {
    throw new Error("--wait must be an integer between 1 and 60");
  }

  return {
    catalogDirectory: path.resolve(targets[0] ?? "catalog"),
    host,
    importDirectory,
    limit,
    program,
    replace,
    waitSeconds,
    write,
  };
}

async function run(command: string, args: string[], allowFailure = false): Promise<string> {
  return await new Promise((resolve, reject) => {
    const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
    const stdout: Buffer[] = [];
    const stderr: Buffer[] = [];

    child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
    child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
    child.on("error", reject);
    child.on("close", (code) => {
      const output = Buffer.concat(stdout).toString("utf8");
      const error = Buffer.concat(stderr).toString("utf8").trim();
      if (code === 0 || allowFailure) {
        resolve(output);
      } else {
        reject(new Error(`${command} exited with code ${code}${error.length > 0 ? `: ${error}` : ""}`));
      }
    });
  });
}

async function findIndexes(directory: string, result: string[]): Promise<void> {
  for (const entry of await readdir(directory, { withFileTypes: true })) {
    const entryPath = path.join(directory, entry.name);
    if (entry.isFile() && entry.name === "index.md") {
      result.push(entryPath);
    } else if (entry.isDirectory() && entry.name !== "files" && entry.name !== "img") {
      await findIndexes(entryPath, result);
    }
  }
}

function selectArchive(data: ProgramIndex, indexPath: string): string | undefined {
  const versions = data.versions ?? [];
  const orderedVersions = [
    ...versions.filter((entry) => entry.status === "current"),
    ...versions.filter((entry) => entry.status !== "current"),
  ];
  for (const version of orderedVersions) {
    const files = version.files ?? [];
    const file =
      files.find((entry) => entry.platform === "win32" || entry.platform === "win64") ??
      files.find((entry) => entry.platform === "java");
    if (typeof file?.path === "string") {
      return path.resolve(path.dirname(indexPath), file.path);
    }
  }
  return undefined;
}

async function collectPrograms(options: Options): Promise<{ programs: Program[]; failures: Failure[] }> {
  const indexes: string[] = [];
  const programs: Program[] = [];
  const failures: Failure[] = [];
  await findIndexes(options.catalogDirectory, indexes);

  for (const indexPath of indexes.sort()) {
    const { metadata } = await readCatalogIndex<ProgramIndex>(indexPath);
    if (metadata.type !== "program") {
      continue;
    }

    const directory = path.dirname(indexPath);
    const slug = path.basename(directory);
    if (programsWithoutStandaloneGui.has(slug)) {
      continue;
    }
    if (options.program !== undefined && slug !== options.program) {
      continue;
    }
    if (typeof metadata.name !== "string" || metadata.name.length === 0) {
      throw new Error(`${indexPath}: name is missing`);
    }
    const expectedScreenshot = `img/${slug}.png`;
    const screenshots = Array.isArray(metadata.screenshots) ? metadata.screenshots : [];
    if (options.replace) {
      if (!screenshots.includes(expectedScreenshot)) {
        continue;
      }
    } else if (screenshots.length > 0) {
      continue;
    }

    const archive = selectArchive(metadata, indexPath);
    if (archive === undefined) {
      failures.push({
        name: metadata.name,
        path: path.relative(process.cwd(), indexPath),
        reason: "No desktop file",
        details: "The current version has no file with platform: win32, win64, or java",
      });
      continue;
    }

    programs.push({ archive, directory, indexPath, name: metadata.name, slug });
  }

  if (options.limit !== undefined) {
    programs.splice(options.limit);
  }
  return { programs, failures };
}

function parseRemoteResult(output: string): RemoteResult {
  const start = output.indexOf("{");
  const end = output.lastIndexOf("}");
  if (start === -1 || end === -1) {
    throw new Error(`Windows worker returned no JSON: ${output.trim()}`);
  }
  return JSON.parse(output.slice(start, end + 1)) as RemoteResult;
}

function failureReason(result: RemoteResult, program: Program): { reason: string; details: string } | undefined {
  if (result.success !== true) {
    const reasons: Record<string, string> = {
      "no-executable": "No desktop executable",
      "installer-only": "Installer only",
      "process-exited": "Process exited without a window",
      "no-window": "No window appeared",
      error: "Launch error",
    };
    return {
      reason: reasons[result.reason ?? ""] ?? "Unknown error",
      details: result.error ?? result.reason ?? "",
    };
  }
  if (result.installer === true) {
    return { reason: "Installer only", details: result.selected ?? "" };
  }
  const titles = [result.title, ...(result.windows ?? []).map((window) => window.title)].filter(
    (title): title is string => typeof title === "string",
  );
  const failedTitle = titles.find((title) => errorTitle.test(title));
  if (failedTitle !== undefined) {
    return { reason: "Error window", details: failedTitle };
  }
  const setupTitle = program.slug === "java-midlet-installer" ? undefined : titles.find((title) => installerTitle.test(title));
  if (setupTitle !== undefined) {
    return { reason: "Installer opened", details: setupTitle };
  }
  return undefined;
}

async function addScreenshot(program: Program, screenshotPath: string, replace: boolean): Promise<void> {
  const imageDirectory = path.join(program.directory, "img");
  const imagePath = path.join(imageDirectory, `${program.slug}.png`);
  await mkdir(imageDirectory, { recursive: true });
  await run("scp", [`${screenshotPath}`, imagePath]);

  if (replace) {
    return;
  }

  const source = await readFile(program.indexPath, "utf8");
  const replacement = `screenshots:\n  - img/${program.slug}.png`;
  if (!source.includes("screenshots: []")) {
    throw new Error(`${program.indexPath}: expected screenshots: []`);
  }
  await writeFile(program.indexPath, source.replace("screenshots: []", replacement), "utf8");
}

async function importScreenshots(programs: Program[], directory: string): Promise<number> {
  let imported = 0;
  for (const program of programs) {
    const sourcePath = path.join(directory, `${program.slug}.png`);
    try {
      await access(sourcePath);
    } catch {
      continue;
    }

    const imageDirectory = path.join(program.directory, "img");
    const imagePath = path.join(imageDirectory, `${program.slug}.png`);
    await mkdir(imageDirectory, { recursive: true });
    await copyFile(sourcePath, imagePath);

    const source = await readFile(program.indexPath, "utf8");
    if (!source.includes("screenshots: []")) {
      throw new Error(`${program.indexPath}: expected screenshots: []`);
    }
    await writeFile(
      program.indexPath,
      source.replace("screenshots: []", `screenshots:\n  - img/${program.slug}.png`),
      "utf8",
    );
    imported += 1;
    console.log(`Imported: ${program.name}`);
  }
  return imported;
}

function escapeTable(value: string): string {
  return value.replaceAll("|", "\\|").replaceAll("\n", " ").trim();
}

async function main(): Promise<void> {
  const options = parseArguments(process.argv.slice(2));
  const temporaryDirectory = "/tmp/codex/catalog-screenshots";
  await mkdir(temporaryDirectory, { recursive: true });

  const { programs, failures } = await collectPrograms(options);
  if (options.importDirectory !== undefined) {
    const imported = await importScreenshots(programs, options.importDirectory);
    console.log(`Done. Imported: ${imported}`);
    return;
  }
  let captured = 0;
  const tools = ["screenshotApp.ps1", "runScreenshotTask.ps1", "screenshotArchive.ps1"];
  await run("ssh", [
    options.host,
    `powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '${remoteRoot}\\input','${remoteRoot}\\results' | Out-Null"`,
  ]);
  await run("scp", tools.map((file) => path.resolve("tools", file)).concat(`${options.host}:${remoteScpRoot}/`));

  console.log(`Programs to process: ${programs.length}`);
  for (const [index, program] of programs.entries()) {
    const prefix = `[${index + 1}/${programs.length}] ${program.name}`;
    const remoteArchive = `${remoteRoot}\\input\\${program.slug}.zip`;
    try {
      await run("scp", [program.archive, `${options.host}:${remoteScpRoot}/input/${program.slug}.zip`]);
      const remoteCommand = [
        "powershell",
        "-NoProfile",
        "-ExecutionPolicy Bypass",
        `-File ${remoteRoot}\\screenshotArchive.ps1`,
        `-Archive ${remoteArchive}`,
        `-Slug ${program.slug}`,
        `-RootDirectory ${remoteRoot}`,
        `-WaitSeconds ${options.waitSeconds}`,
        ...(initialKeys.has(program.slug) ? [`-InitialKeys ${initialKeys.get(program.slug)}`] : []),
      ].join(" ");
      const output = await run("ssh", [options.host, remoteCommand], true);
      const result = parseRemoteResult(output);
      const failure = failureReason(result, program);
      if (failure !== undefined) {
        failures.push({
          name: program.name,
          path: path.relative(process.cwd(), program.indexPath),
          ...failure,
        });
        console.log(`${prefix}: failed (${failure.reason})`);
        continue;
      }

      if (options.write) {
        await addScreenshot(program, `${options.host}:${remoteScpRoot}/results/${program.slug}.png`, options.replace);
      }
      captured += 1;
      console.log(`${prefix}: ${options.write ? "saved" : "captured"} (${result.title ?? result.selected ?? "window"})`);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      failures.push({
        name: program.name,
        path: path.relative(process.cwd(), program.indexPath),
        reason: "Tool error",
        details: message,
      });
      console.log(`${prefix}: failed (${message})`);
    }
  }

  const table = [
    "| Program | Index | Reason | Details |",
    "|---|---|---|---|",
    ...failures.map(
      (failure) =>
        `| ${escapeTable(failure.name)} | ${escapeTable(failure.path)} | ${escapeTable(failure.reason)} | ${escapeTable(failure.details)} |`,
    ),
    "",
  ].join("\n");
  const reportPath = path.join(temporaryDirectory, "failures.md");
  await writeFile(reportPath, table, "utf8");
  console.log(`Done. Captured: ${captured}; failures: ${failures.length}`);
  console.log(`Failure report: ${reportPath}`);
}

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