import Config from "../config.mjs";
import path from "path";
import fs from "fs";
import chalk from "chalk";
import { compilePack, extractPack, TYPE_COLLECTION_MAP } from "../lib/package.mjs";

/**
 * @typedef {"Module"|"System"|"World"} PackageType
 */

/**
 * @typedef {object} CLIArgs
 * @property {"workon"|"clear"|"unpack"|"pack"} action  The action to perform.
 * @property {string} value                             The action value.
 * @property {string} [id]                              Optionally provide the package ID if we are using explicit
 *                                                      paths.
 * @property {PackageType} type                         The package type.
 * @property {string} [compendiumName]                  The compendium name for pack-based actions.
 * @property {DocumentType} [compendiumType]            The type of Documents that the compendium houses. Only required
 *                                                      for NeDB operations.
 * @property {string} [inputDirectory]                  An explicit input directory for pack-based actions.
 * @property {string} [outputDirectory]                 An explicit output directory for pack-based actions.
 * @property {boolean} [yaml]                           Whether to use YAML instead of JSON for serialization.
 * @property {boolean} [verbose]                        Whether to output verbose logging.
 * @property {boolean} [nedb]                           Use NeDB instead of ClassicLevel for database operations.
 * @property {boolean} [recursive]                      When packing, recurse down through all directories in the input
 *                                                      directory to find source files.
 * @property {boolean} [clean]                          When unpacking, delete the destination directory first.
 * @property {boolean} [folders]                        When unpacking, create a directory structure that matches the
 *                                                      compendium folders.
 * @property {boolean} [expandAdventures]               When unpacking, extract adventure documents into a folder with
 *                                                      each contained document as its own entry in a folder.
 * @property {boolean} [omitVolatile]                   When unpacking, diff the candidate entry against an existing one
 *                                                      and only write it if non-volatile fields have changed.
 *                                                      Currently, _stats.createdTime, _stats.modifiedTime,
 *                                                      _stats.lastModifiedBy, _stats.systemVersion, and
 *                                                      _stats.coreVersion are considered volatile.
 */

/**
 * The working package ID.
 * @type {string|null}
 */
let currentPackageId = null;

/**
 * The working package type.
 * @type {PackageType|null}
 */
let currentPackageType = null;

/**
 * Get the command object for the package command
 * @returns {CommandModule}
 */
export function getCommand() {
  return {
    command: "package [action] [value]",
    describe: "Manage packages",
    builder: yargs => {
      yargs.positional("action", {
        describe: "The action to perform",
        type: "string",
        choices: ["workon", "clear", "unpack", "pack"]
      });

      yargs.positional("value", {
        describe: "The value to use for the action",
        type: "string"
      });

      // currentPackageId is only needed if the data path has to be built with it.
      yargs.option("id", {
        describe: "The package ID",
        type: "string",
      });

      yargs.option("type", {
        describe: "The package type",
        type: "string",
        choices: ["Module", "System", "World"]
      });

      yargs.option("compendiumName", {
        alias: "n",
        describe: "The Compendium name, for Compendium Pack based Actions.",
        type: "string"
      });

      yargs.option("compendiumType", {
        alias: "t",
        describe: "The type of document that the compendium pack stores. Only necessary for NeDB operations.",
        type: "string",
        choices: Object.keys(TYPE_COLLECTION_MAP)
      });

      yargs.option("inputDirectory", {
        alias: "in",
        describe: "The directory to read from, for Pack based Actions.",
        type: "string"
      });

      yargs.option("outputDirectory", {
        alias: "out",
        describe: "The directory to write to, for Pack based Actions.",
        type: "string"
      });

      yargs.option("yaml", {
        describe: "Whether to use YAML instead of JSON for serialization.",
        type: "boolean"
      });

      yargs.option("verbose", {
        alias: "v",
        describe: "Whether to output verbose logging.",
        type: "boolean"
      });

      yargs.option("nedb", {
        describe: "Whether to use NeDB instead of ClassicLevel for database operations.",
        type: "boolean"
      });

      yargs.option("recursive", {
        alias: "r",
        describe: "When packing, recurse down through all directories in the input directory to find source files.",
        type: "boolean"
      });

      yargs.option("clean", {
        alias: "c",
        describe: "When unpacking, delete the destination directory first.",
        type: "boolean"
      });

      yargs.option("folders", {
        describe: "When unpacking, create a directory structure that matches the compendium folders.",
        type: "boolean"
      });

      yargs.option("expandAdventures", {
        describe: "When unpacking, extract documents embedded inside Adventures to their own files. If supplied alongside the --folders option, the Adventure is treated like a folder.",
        type: "boolean"
      });

      yargs.option("omitVolatile", {
        describe: "When unpacking, diff the candidate entry against an existing one and only write it if non-volatile fields have changed. Currently, _stats.createdTime, _stats.modifiedTime, _stats.lastModifiedBy, _stats.systemVersion, and _stats.coreVersion are considered volatile.",
        type: "boolean"
      });

      return yargs;
    },
    handler: async argv => {
      currentPackageId = Config.instance.get("currentPackageId");
      currentPackageType = Config.instance.get("currentPackageType");
      if ( argv.id ) currentPackageId = argv.id;
      if ( argv.type ) currentPackageType = argv.type;

      // Handle actions
      switch ( argv.action ) {
        case "workon": handleWorkon(argv); break;
        case "clear": handleClear(); break;
        case "unpack": await handleUnpack(argv); break;
        case "pack": await handlePack(argv); break;

        default:
          if ( !currentPackageId ) {
            console.error(chalk.red("No package ID is currently set. Use `package workon <id>` to set it."));
            return;
          }

          console.log(`Currently in ${chalk.magenta(currentPackageType)} ${chalk.cyan(currentPackageId)}`);
          break;
      }
    }
  }
}

/* -------------------------------------------- */
/*  Helpers                                     */
/* -------------------------------------------- */

/**
 * Read all the package manifests for a given package type, and return a Map of them, keyed by ID.
 * @param {string} dataPath                  The root data path.
 * @param {PackageType} type                 The package type.
 * @param {object} [options]
 * @param {boolean} [options.verbose=false]  Log errors verbosely.
 * @returns {Map<string, object>}
 */
function readPackageManifests(dataPath, type, { verbose=false }={}) {
  const typeLC = type.toLowerCase();
  const typePl = `${typeLC}s`;
  const dir = `${dataPath}/Data/${typePl}`;
  const map = new Map();

  for ( const file of fs.readdirSync(dir, { withFileTypes: true }) ) {
    const manifestPath = path.normalize(`${dir}/${file.name}/${typeLC}.json`);
    try {
      const data = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
      data.type = type;
      data.path = manifestPath;
      map.set(data.id ?? data.name, data);
    } catch ( err ) {
      if ( verbose ) console.error(chalk.red(`Error reading ${typeLC}.json for ${chalk.blue(file.name)}: ${err}`));
    }
  }

  return map;
}

/* -------------------------------------------- */

/**
 * @typedef {object} DiscoveredPackages
 * @property {Map<string, object>} modules  A map of module manifest data by ID.
 * @property {Map<string, object>} systems  A map of system manifest data by ID.
 * @property {Map<string, object>} worlds   A map of world manifest data by ID.
 * @property {object[]} packages            A list of all packages in the data path.
 */

/**
 * Discover the list of all Packages in the dataPath
 * @param {CLIArgs} argv  The command line arguments
 * @returns {DiscoveredPackages|void}
 */
function discoverPackageDirectory(argv) {
  const dataPath = Config.instance.get("dataPath");
  if ( !dataPath ) {
    console.error(chalk.red(`No dataPath configured. Call ${chalk.yellow("`configure set dataPath <path>`")} first.`));
    return;
  }
  const modules = readPackageManifests(dataPath, "Module", { verbose: argv.verbose });
  const systems = readPackageManifests(dataPath, "System", { verbose: argv.verbose });
  const worlds = readPackageManifests(dataPath, "World", { verbose: argv.verbose });
  return { modules, systems, worlds, packages: [...modules.values(), ...systems.values(), ...worlds.values()] };
}

/* -------------------------------------------- */

/**
 * Determine the document type of an NeDB database from the command-line arguments, if specified, or from the database's
 * containing package.
 * @param {string} packFile  The path to the NeDB database.
 * @param {CLIArgs} argv     The command-line arguments.
 * @returns {string|void}    The document type of the NeDB database if it could be determined.
 */
function determineDocumentType(packFile, argv) {
  // Case 1 - The document type has been explicitly provided.
  if ( argv.compendiumType ) return argv.compendiumType;

  // Case 2 - The type can be inferred from the pack name.
  const packName = path.basename(packFile, ".db");
  for ( const [type, collection] of Object.entries(TYPE_COLLECTION_MAP) ) {
    if ( packName === collection ) return type;
  }

  // Case 3 - Attempt to locate this pack's metadata in the manifest of the package that owns it.
  const game = discoverPackageDirectory(argv);
  const pkg = game.packages.find(p => packFile.startsWith(path.dirname(p.path)));
  const pack = pkg?.packs.find(pack => path.resolve(path.dirname(pkg.path), pack.path) === packFile);
  if ( !pack ) {
    console.error(`Unable to determine document type for NeDB compendium at '${packFile}'. `
      + "Set this manually with -t <type>.");
    return;
  }
  return pack.type ?? pack.entity;
}

/* -------------------------------------------- */

/**
 * Determines whether a file is locked by another process
 * @param {string} filepath  The file path to test.
 * @returns {boolean}
 */
function isFileLocked(filepath) {
  try {
    // Try to open the file with the 'w' flag, which requests write access
    const fd = fs.openSync(filepath, 'w');

    // If the file was successfully opened, it is not locked
    fs.closeSync(fd);
    return false;
  } catch ( err ) {
    if ( err.code === "EBUSY" ) return true;        // If the file could not be opened, it is locked
    else if ( err.code === "ENOENT" ) return false; // If the file can't be found it's not locked
    throw err;
  }
}

/* -------------------------------------------- */

/**
 * @typedef {object} OperationPaths
 * @property {string} source  The source data files.
 * @property {string} pack    The path to the compendium pack.
 */

/**
 * Determine compendium pack and source data paths based on the current configuration or command-line arguments.
 * @param {CLIArgs} argv               The command-line arguments.
 * @param {"pack"|"unpack"} operation  The operation.
 * @returns {OperationPaths|{}}        The paths required for the operation, or nothing if they could not be determined.
 */
function determinePaths(argv, operation) {
  const usingDefault = !argv.outputDirectory || !argv.inputDirectory;
  if ( usingDefault && (!currentPackageId || !currentPackageType) ) {
    console.error("Package ID or type could not be determined. Use `package workon <id>` to set it.");
    return {};
  }

  const dataPath = Config.instance.get("dataPath");
  if ( usingDefault && !dataPath ) {
    console.error("No dataPath configured. Use `configure set dataPath <path>` to set it.");
    return {};
  }

  const compendiumName = argv.compendiumName ?? argv.value;
  if ( !compendiumName ) {
    console.error("No compendium name provided. Use `-n <name>` to supply it.");
    return {};
  }

  let pack = operation === "pack" ? argv.outputDirectory : argv.inputDirectory;
  let source = operation === "pack" ? argv.inputDirectory : argv.outputDirectory;
  if ( pack ) pack = path.join(pack, compendiumName);
  else {
    const typeDir = `${currentPackageType.toLowerCase()}s`;
    pack = path.join(dataPath, "Data", typeDir, currentPackageId, "packs", compendiumName);
  }
  source ??= path.join(pack, "_source");
  if ( argv.nedb ) pack += ".db";
  return { source: path.resolve(path.normalize(source)), pack: path.resolve(path.normalize(pack)) };
}

/* -------------------------------------------- */
/*  Workon                                      */
/* -------------------------------------------- */

/**
 * Set the current package ID and type
 * @param {CLIArgs} argv  The command line arguments
 */
function handleWorkon(argv) {
  if ( argv.value ) currentPackageId = argv.value;
  Config.instance.set("currentPackageId", currentPackageId);

  // Attempt to automatically determine the package type.
  if ( !argv.type ) {
    const game = discoverPackageDirectory(argv);
    const pkgCount = game.packages.filter(p => p.id === currentPackageId).length;
    if ( pkgCount > 1 ) {
      console.error(chalk.red(`Multiple packages with ID ${chalk.cyan(currentPackageId)} found. `
        + `Please specify the package type with ${chalk.yellow("--type")}`));
      process.exitCode = 1;
      return;
    }
    const pkg = game.worlds.get(currentPackageId)
      ?? game.systems.get(currentPackageId)
      ?? game.modules.get(currentPackageId);
    if ( !pkg ) {
      console.error(chalk.red(`No package with ID ${chalk.cyan(currentPackageId)} found.`));
      process.exitCode = 1;
      return;
    }
    currentPackageType = pkg.type;
  }

  Config.instance.set("currentPackageType", currentPackageType);
  console.log(`Swapped to ${chalk.magenta(currentPackageType)} ${chalk.cyan(currentPackageId)}`);
}

/* -------------------------------------------- */

/**
 * Clear the current package ID and type
 */
function handleClear() {
  currentPackageId = null;
  currentPackageType = null;
  Config.instance.set("currentPackageId", currentPackageId);
  Config.instance.set("currentPackageType", currentPackageType);
  console.log("Cleared current Package");
}

/* -------------------------------------------- */
/*  Unpacking                                   */
/* -------------------------------------------- */

/**
 * Load a compendium pack and serialize the DB entries, each to their own file
 * @param {CLIArgs} argv  The command line arguments
 * @returns {Promise<void>}
 */
async function handleUnpack(argv) {
  const { source, pack } = determinePaths(argv, "unpack");
  if ( !source || !pack ) {
    process.exitCode = 1;
    return;
  }

  let documentType;
  const { nedb, yaml, clean, folders, expandAdventures, omitVolatile } = argv;
  if ( nedb ) {
    documentType = determineDocumentType(pack, argv);
    if ( !documentType ) {
      process.exitCode = 1;
      return;
    }
  }

  if ( !nedb && isFileLocked(path.join(pack, "LOCK")) ) {
    console.error(chalk.red(`The pack "${chalk.blue(pack)}" is currently in use by Foundry VTT. `
      + "Please close Foundry VTT and try again."));
    process.exitCode = 1;
    return;
  }

  const dbMode = nedb ? "nedb" : "classic-level";
  console.log(`[${dbMode}] Unpacking "${chalk.blue(pack)}" to "${chalk.blue(source)}"`);

  try {
    await extractPack(pack, source, {
      nedb, yaml, documentType, clean, folders, expandAdventures, omitVolatile, log: true
    });
  } catch ( err ) {
    console.error(err);
    process.exitCode = 1;
  }
}

/* -------------------------------------------- */
/*  Packing                                     */
/* -------------------------------------------- */

/**
 * Read serialized files from a directory and write them to a compendium pack.
 * @param {CLIArgs} argv  The command line arguments
 * @returns {Promise<void>}
 * @private
 */
async function handlePack(argv) {
  const { source, pack } = determinePaths(argv, "pack");
  if ( !source || !pack ) {
    process.exitCode = 1;
    return;
  }

  const { nedb, yaml, recursive } = argv;
  if ( !nedb && isFileLocked(path.join(pack, "LOCK")) ) {
    console.error(chalk.red(`The pack "${chalk.blue(pack)}" is currently in use by Foundry VTT. `
      + "Please close Foundry VTT and try again."));
    process.exitCode = 1;
    return;
  }

  const dbMode = nedb ? "nedb" : "classic-level";
  console.log(`[${dbMode}] Packing "${chalk.blue(source)}" into "${chalk.blue(pack)}"`);

  try {
    await compilePack(source, pack, { nedb, yaml, recursive, log: true });
  } catch ( err ) {
    console.error(err);
    process.exitCode = 1;
  }
}
