[Deno/TypeScript] コマンドラインスクリプトのテンプレート(サブコマンド対応)

・コマンドライン スクリプトのテンプレート
・単一の.tsファイル
・サブコマンド対応
・環境
 deno 2.7.10 (stable, release, x86_64-pc-windows-msvc)
  v8 14.7.173.7-rusty
  typescript 5.9.2
・ダウンロード
#!/usr/bin/env -S deno run -RWNSE --allow-ffi --allow-run=magick,ffmpeg,ffprobe
/**
 * 汎用コマンドラインツールのテンプレート
 *
 * ### 提供コマンド:
 * - `renumber`     : 指定したディレクトリ内の画像を連番で一括リネームする
 * - `downloadfiles`: ファイルをダウンロードする(連番対応)
 * - `resizeimages` : 画像を伸縮する(ImageMagickが必要)
 * - `resizevideo`  : 動画を伸縮する(ffmpegが必要)
 * - `filecompress` : 指定パスを圧縮ファイル化する
 * - `dirtotext`    : ディレクトリ構造とソースファイル群を単一テキスト化。
 *                    AIにプログラムの構造を理解させる手助けにもなるはず。
 * - `metronome`    : WebViewでメトロノームを表示する。
 *
 * ### 使用例:
 * ```bash
 * deno run -A utils.ts renumber --prefix "vacation_" --start 1
 * ```
 *
 * ### 開発・動作環境:
 * - Deno 2.7.14 (V8 14.7) / TypeScript 5.9 準拠
 * - ImageMagick 7.1
 * - FFmpeg 8.1-full_build
 *
 * ### タグ:
 * - NOTE: なぜこのような実装にしたのか、設計の背景や理由
 * - TODO: 未実装の機能、後で追加予定の処理
 * - HACK: 綺麗ではないが動かすために一時的に書いた暫定コード
 * - FIXME: バグがある、または正しく動くがコードが汚い場所
 *
 * ### 備忘録: Java/Python と似ているけど TypeScript の違う点
 * 1. OSやファイルシステムなどのI/Oはランタイム固有APIを使う必要がある。
 *    DenoはWeb標準APIを実装しているので、ある程度はWebブラウザと共通した
 *    コードが書ける。
 * 2. 単一スレッド・単一イベントループで動作し、2つのキューを捌く。
 *    コールスタックが空になる度に最優先でマイクロタスク(Promise等)を全件消化
 *    し、その後マクロタスク(setTimeout/IO等)を1件のみ実行する直列の時間割。
 *    マイクロタスク内の無限ループはマクロタスクや描画を完全にフリーズさせるた
 *    め注意。
 * 3. 型の判定基準が「構造的型(ダック・タイピング)」である。
 *    Javaのように extends や implements による明示的な関係性(名前)を持たな
 *    くても、オブジェクトの見た目の「形(構造)」さえ一致していれば自動的に互
 *    換性あり(合格)と判定されて代入が可能になる。
 *
 *    このため、Record<string, unknown> や  [key: string]: unknown のような
 *    大雑把な共通の「器(型)」を定義した場合、明示的に継承関係を書いていない
 *    無関係なオブジェクトまですり抜けて合格判定となり、コンパイルエラーや警告
 *    が出なくなるので注意。
 * 4. 関数式(アロー関数やIIFEも含む)は、言語仕様上は生成時にスコープ内の全て
 *    の変数をキャプチャしてヒープメモリ上にクロージャを生成する。しかし、現代
 *    の主要ランタイムは静的解析により、関数式内部で実際に参照している変数
 *    (または this)のみをクロージャに保持(隔離)する。
 *
 *    ただし、クロージャの実体はスコープにつき1つしか生成されないため、同一
 *    スコープ内で関数式を複数生成した場合は、それらが参照している変数の総和
 *    (最大数)が単一のクロージャに共有・保持され、特定の変数の寿命が伸びてGC
 *    に回収されにくくなるリスクがある。
 *
 *    したがって、関数式の生存期間と、スコープの厳密な分離を意識すること。
 * 5. try-catchのerrorはError|unknownであり、型が不確定。
 * 6. メソッドチェーン(.then/.catch/.pipe / .map/.filter)
 *    高負荷時:ループ内では関数オブジェクト生成を避け、
 *              try-catch / for-await / for...of を使う。
 *    一般領域:可読性優先ならよいが、裏でメモリ確保が走るコストを意識する。
 * 7. スプレッド演算子([...])による隠れた O(N) コピーがあるので多用は無用。
 *    大量データ時は push() 等の破壊的変更(In-place)による最適化を選択する。
 * 8. オブジェクトの構造は生成時に固定し、後からのプロパティ追加は禁止。
 *    - 生成後にプロパティを追加するとランタイムの最適化(Hidden Class)から
 *      外れる。
 *    - オプションプロパティ(?:)を使ってその都度異なるキー構成でオブジェクト
 *      を生成(省略)すると、形状(Shape)の不一致により関数のインライン
 *      キャッシュなどの最適化が効かなくなり、実行効率が悪くなる可能性がある。
 *    - 最高のパフォーマンスが必要な領域では、初期値を undefined で固定する。
 *
 * ### 備忘録: 設定しておくと便利なオプション
 * deno.jsonc
 * ```jsonc
 * {
 *   "compilerOptions": {
 *     "strict": true,                      // 厳格モード
 *     "exactOptionalPropertyTypes" : true, // 正確なプロパティタイプに限定
 *     "noImplicitOverride": true,          // 暗黙のオーバーライドを禁止
 *     "noImplicitReturns": true,           // 暗黙の戻り値を禁止
 *     "noFallthroughCasesInSwitch": true,  // switch文の突き抜け防止
 *     "noImplicitAny": true                // 暗黙のanyを禁止
 *   },
 *   "lint": {
 *     "rules": {
 *       "include": [
 *         "explicit-function-return-type", // 暗黙の戻り値型を禁止
 *         "no-explicit-any",               // any 型を禁止 (unknown等への移行)
 *         "no-var",                        // var を禁止 (let/const を強制)
 *         "prefer-const",                  // 再代入しない変数の let を禁止
 *         "eqeqeq",                        // == ではなく === を強制
 *         "no-irregular-whitespace",       // 全角スペース等の混入を排除
 *         "no-throw-literal"               // Errorオブジェクト以外のThrow禁止
 *                                          // ※この指定をしても
 *                                          //   catch内部で型チェックは必須
 *       ]
 *     }
 *   },
 *   "fmt": {
 *     "nextControlFlowPosition": "nextLine", // elseとcatchの位置を次行に
 *     "operatorPosition": "nextLine",        // 演算子の位置を次行に
 *     "newLineKind": "lf"                    // 改行コードを LF に
 *   }
 * },
 * // -A, --allow-all                        制限なし
 * // -R, --allow-read[=<PATH>...]     読み込み可能なパスやファイルの指定も可能
 * // -W, --allow-write[=<PATH>...]    書き込み可能なパスやファイルの指定も可能
 * // -N, --allow-net[=<IP_OR_HOSTNAME>...]  アクセス可能なホストの指定も可能
 * // -E, --allow-env[=<VARIABLE_NAME>...]   アクセス可能な環境変数の指定も可能
 * // -S, --allow-sys[=<API_NAME>...]        システム情報の個別指定も可能
 * // --allow-run[=<PROGRAM_NAME>...]      実行可能なプログラムの個別指定も可能
 * "tasks": {
 *   "mod:check": "deno outdated",
 *   "mod:update": "deno outdated --update",
 *   "fmt": "deno fmt --check --watch src/ scripts/ utils.ts",
 *   "lint": "deno lint --ignore=playground/,dist/",
 *   "build": "deno run -A scripts/build.ts utils.ts myutils",
 *   "backup": "deno run -A scripts/backup.ts"
 * }
 * ```
 * @module
 */

// ===================================================================
// ランタイムに依存しない外部ライブラリ
// ===================================================================
// deno-lint-ignore-file no-external-import no-import-prefix
import { parseArgs } from "jsr:@std/cli@^1.0.28/parse-args";
import * as path from "jsr:@std/path@^1.1.4";
import * as posix from "jsr:@std/path@^1.1.4/posix";
import { delay } from "jsr:@std/async@^1.2.0";
import { walk } from "jsr:@std/fs@^1.0.23";
import * as zip_js from "jsr:@zip-js/zip-js@^2.8.26";

const PROG_VERSION = "2026.04.30";

// ===================================================================
// 共通関数
// ===================================================================

/**
 * 不明なエラーオブジェクトを Error インスタンスに正規化する。
 *
 * 言語仕様上の必然性:
 * JavaScript の `throw` は任意の型を許容する仕様(ECMAScript 準拠)であり、
 * TypeScript 5.9 においても `catch(error)` は `unknown` 型として定義される。
 * 本関数は、この不確実な入力を後続処理で安全に扱うための「型ガード」および
 * 「正規化」として機能する。
 *
 * @param error 判定対象のエラー。ランタイムから流入する不確実な型。
 * @returns 判定済み、または正規化された Error インスタンス。
 */
function ensureError(error: unknown): Error {
  return error instanceof Error ? error : new Error(String(error));
}

/**
 * テンプレート文字列。
 *
 * Javaなどの `String.format` に相当する簡易動的テンプレート。
 * ロケール毎のテキストに対応したり、予めメッセージを別で定義しておく場合に使用
 * する。
 *
 * @param template テンプレート文字列
 * @param args 注入する値(可変長引数)
 * @returns 構築済み文字列
 *
 * @remarks 補足
 * - 置換対象の書式は {0}, {1}, ...{n} であり、{数字}で記述する。
 * - 置換対象の並び順も個数(重複可)も自由。
 *
 * @example
 * ```typescript
 * // "ようこそ、{1}さん。{0}".format("Now Loading...", "Tarou")相当の使用例
 * const msg1 = t(MSG[lang].welcome, "Now Loading...", "Tarou");
 * // => "ようこそ、Tarouさん。Now Loading..."
 *
 * // 複数の値を注入する例
 * const msg2 = t("{0}のエラーが発生しました (Code: {1})", "システム", 500);
 * // => "システムのエラーが発生しました (Code: 500)"
 *
 * // インデックスが引数の数を超過した場合
 * // (元のマッチ文字列を維持し情報の欠損を防ぐ)
 * const msg3 = t("{0}と{1}の同期", "データ");
 * // => "データと{1}の同期"
 */
// deno-lint-ignore no-unused-vars
const t = (() => {
  const RE_TEMPLATE: RegExp = /{(\d+)}/g;

  // NOTE: スタックトレースが出来るように名前付き関数式にする。
  //       ここではアロー関数式にするメリットは特にない
  return function t(template: string, ...args: unknown[]): string {
    return template.replace(RE_TEMPLATE, (match, p1) => {
      // NOTE: Numberと同じ動作をする+演算子の方が変換コストが低い
      //       可読性が犠牲にならない箇所のみ使用可
      // const i = Number(p1);
      const i = +p1;
      return i < args.length ? String(args[i]) : match;
    });
  };
})();

/**
 * 数値を適切な接頭辞(2進、SI、または bps)付きの文字列に変換する。
 *
 * @param src - 変換対象の数値。
 * @param kind - 単位の体系を指定する。初期値は "bin"。
 *   - "bin": 1024単位の2進接頭辞(KiB, MiB 等)を使用する。
 *   - "SI": 1000単位のSI接頭辞(KB, MB 等)を使用する。
 *   - "bps": 1000単位のビットレート単位(Kbps, Mbps 等)を使用する。
 * @returns 小数点以下2桁固定の数値と単位を連結した文字列。
 *
 * @example
 * ```typescript
 * formatBinNum(1024);              // "1.00 KiB" (default: bin)
 * formatBinNum(1572864);           // "1.50 MiB" (default: bin)
 * formatBinNum(1000, "SI");        // "1.00 KB"
 * formatBinNum(1000000, "bps");    // "1.00 Mbps"
 * formatBinNum(0);                 // "0 B"
 * formatBinNum(0, "SI");           // "0 B"
 * formatBinNum(0, "bps");          // "0 bps"
 *  ```
 */
const formatBinNum = (() => {
  type KIND = "bin" | "SI" | "bps";

  const UNITS: Readonly<Record<KIND, readonly string[]>> = {
    "bin": ["B", "KiB", "MiB", "GiB", "TiB", "PiB"],
    "SI": ["B", "KB", "MB", "GB", "TB", "PB"],
    "bps": ["bps", "Kbps", "Mbps", "Gbps", "Tbps", "Pbps"],
  };

  function calc(src: number, divisor: number, unitLen: number): {
    value: number;
    i: number;
  } {
    let value = src;
    let i = 0;
    while (value >= divisor && i < unitLen) {
      value /= divisor;
      i++;
    }

    return { value, i };
  }

  // NOTE: スタックトレースが出来るように名前付き関数式にする。
  //       ここではアロー関数式にするメリットは特にない
  return function formatBinNum(
    src: number,
    kind: KIND = "bin",
  ): string {
    if (src <= 0) return kind === "bps" ? "0 bps" : "0 B";

    const units = UNITS[kind];
    const divisor = kind === "bin" ? 1024 : 1000;

    const res = calc(src, divisor, units.length - 1);
    const value = res.value.toFixed(2);
    const suffix = units[res.i];

    return `${value} ${suffix}`;
  };
})();

/**
 * 指定した区切り文字で文字列を分割し、各トークンに対して任意の接頭辞・接尾辞を
 * 付与した配列を生成する。
 *
 * @remarks 空文字列のトークンは自動的に除外されます。
 *
 * @param value - 分割対象のベース文字列。
 * @param options - トークン処理のオプションオブジェクト。デフォルトは `{}`。
 * @param options.separator - 文字列を分割するための区切り文字(初期値 `":"`)
 * @param options.prefix - 各トークンの先頭に付与する文字列(初期値 空文字)
 * @param options.suffix - 各トークンの末尾に付与する文字列(初期値 空文字)
 * @returns 処理されたトークン(空文字を除く)の配列。
 *
 * @example
 * ```typescript
 * splitTokens(":txt:png:webp");
 * // => ["txt", "png", "webp"]
 *
 * splitTokens(":txt:png:webp", { prefix: "." });
 * // => [".txt", ".png", ".webp"]
 *
 * * splitTokens("//txt/png/webp", { separator: "/" });
 * // => ["txt", "png", "webp"]
 * ```
 */
function splitTokens(
  value: string,
  {
    separator = ":",
    prefix = "",
    suffix = "",
  }: {
    separator?: string;
    prefix?: string;
    suffix?: string;
  } = {},
): string[] {
  const result: string[] = [];
  const rawData = value.split(separator);

  for (let i = 0; i < rawData.length; i++) {
    if (rawData[i] === "") continue; // 空は無視する
    result.push(`${prefix}${rawData[i]}${suffix}`);
  }

  return result;
}

/**
 * 網羅性チェックエラー。
 *
 * 網羅性チェックでの抜けでスローさせる例外。
 * TypeScriptのコンパイラ、JavaScriptの実行時にも対応。
 *
 * @example
 * ```typescript
 * type Action = "create" | "update" | "delete";
 * function handle(action: Action) {
 *   switch (action) {
 *     case "create":
 *       return "created";
 *     case "update":
 *       return "updated";
 *     default:
 *       // action がすべて網羅されていないのでコンパイルエラー
 *       throw new ExhaustiveError(action);
 *   }
 * }
 *
 * // 実行時にExhaustiveErrorがスローされる
 * handle("delete");
 * ```
 */
class ExhaustiveError extends Error {
  constructor(value: never, message = `Unsupported identifiers: ${value}`) {
    super(message);
  }
}

// ===================================================================
// ランタイム固有処理の抽象化
//
// NOTE: テスト用モックや異なるランタイムに切り替えやすくするため
// ===================================================================

/**
 * ファイル情報を抽象化した型。
 *
 * {@link Deno.FileInfo}を元にした構造であり現時点で必要な項目だけを
 * 記述している。
 */
interface RuntimeFileInfo {
  size: number;
}

/**
 * ディレクトリエントリ。
 *
 * {@link Deno.DirEntry}を元にした構造。
 */
interface RuntimeDirEntry {
  /** ファイル名・ディレクトリ名。 */
  name: string;

  /** ファイルなら true。 */
  isFile: boolean;

  /** ディレクトリなら true。 */
  isDirectory: boolean;

  /** シンボリックリンクなら true。 */
  isSymlink: boolean;
}

/**
 * 読み込み用ストリームと解放処理をセットにしたリソース。
 *
 * [Symbol.asyncDispose] により、await using での自動解放に対応する。
 */
interface ReadableResource extends AsyncDisposable {
  readonly stream: ReadableStream<Uint8Array>;
}

/**
 * OpenSyncの戻り値型。
 */
interface OpenSyncResult {
  writeSync(p: Uint8Array): number;
  close(): void;
}

/**
 * 外部プロセス(同期)の実行結果を格納する構造体。
 *
 * 終了ステータスおよび標準入出力のバイナリデータを保持する。
 * {@link Deno.CommandOutput}を元にした構造。
 */
interface SubProcessResult {
  /**
   * プロセスの終了コード。
   *
   * 一般に 0 は成功、それ以外はエラーまたは特定の状態を示す。
   */
  code: number;

  /**
   * プロセスから出力された標準出力(stdout)の生データ。
   *
   * テキストとして扱う場合は、必要に応じて TextDecoder 等でデコードする。
   */
  stdout: Uint8Array;

  /**
   * プロセスから出力された標準エラー出力(stderr)の生データ。
   *
   * 主にエラーメッセージや診断情報が格納される。
   */
  stderr: Uint8Array;
}

/**
 * 外部プロセス(非同期)の実行結果を格納する構造体。
 *
 * {@link Deno.CommandStatus}を元にした構造。
 */
interface SubProcessStatus {
  success: boolean;
  code: number;
  // signal: Signal | null;
}

/**
 * 実行環境(ランタイム)固有の操作を抽象化するインターフェース。
 *
 * @remarks 補足
 * - 異なるランタイム環境への適応を多少容易にするための抽象レイヤー。
 * - ランタイム固有機能はこの一箇所に纏める。
 * @internal
 */
interface Runtime {
  /** 指定した終了コードでプロセスを終了する。 */
  exit(code: number): void;

  /** コマンドライン引数を取得する。 */
  getArgs(): string[];

  /** ディレクトリを作成する。 */
  mkdir(
    path: string,
    options?: {
      recursive?: boolean;
      mode?: number;
    },
  ): Promise<void>;

  /** ファイルをコピーする。 */
  copyFile(src: string, dest: string): Promise<void>;

  /** ディレクトリ内のエントリを走査する。 */
  readDir(path: string): AsyncIterable<RuntimeDirEntry>;

  /** ファイルまたはディレクトリをリネーム(移動)する。 */
  rename(oldPath: string, newPath: string): Promise<void>;

  /** ファイルのメタデータを取得する。 */
  stat(path: string): Promise<RuntimeFileInfo>;

  /** パスが存在するか確認する。 */
  exists(path: string): Promise<boolean>;

  /** ストリームからデータを読み取り、指定パスへ書き込む。 */
  writeFile(
    path: string,
    stream: ReadableStream<Uint8Array> | null,
  ): Promise<boolean>;

  /** 指定パスのファイルをリソースとして取得する。 */
  getReadableResource(path: string): Promise<ReadableResource>;

  /**
   * 指定されたパスのファイルを同期的に開く。
   *
   * @param path ファイルパス
   * @param options {@link Deno.OpenOptions} に準拠したオプション
   * @returns {@link Deno.openSync}に準拠したファイル操作オブジェクト
   */
  openSync(path: string, options: {
    write?: boolean;
    create?: boolean;
    truncate?: boolean;
    read?: boolean;
  }): OpenSyncResult;

  /**
   * 外部コマンドを実行する。
   *
   * @param command 実行するコマンド名またはパス
   * @param args コマンドに渡す引数の配列
   */
  execSync(command: string, args: string[]): Promise<SubProcessResult>;

  exec(
    command: string,
    args: string[],
    onOutput: (text: string, source: "stdout" | "stderr") => void,
  ): Promise<SubProcessStatus>;

  /**
   * 改行を付与せず、標準出力に文字列を直接書き込む(同期版)。
   *
   * @param message 出力する文字列
   */
  printSync(message: string): void;

  /** 実行環境情報を出力する。 */
  outputEnv(): void;
}

/**
 * Deno ランタイム標準 API を使用する {@link Runtime} の本番用実装。
 *
 * @remarks ランタイム固有の外部ライブラリを使う場合は、ここの中でインポート
 * する。
 */
const nativeRuntime: Runtime = {
  exit(code: number): void {
    Deno.exit(code);
  },

  getArgs(): string[] {
    return Deno.args;
  },

  mkdir: Deno.mkdir,
  copyFile: Deno.copyFile,
  readDir: Deno.readDir,
  rename: Deno.rename,

  async stat(path: string): Promise<RuntimeFileInfo> {
    return await Deno.stat(path);
  },

  // @std/fs/existsの競合状態回避策
  async exists(path: string): Promise<boolean> {
    try {
      await Deno.lstat(path);
      return true;
    }
    catch {
      return false;
    }
  },

  async writeFile(
    path: string,
    stream: ReadableStream<Uint8Array> | null,
  ): Promise<boolean> {
    try {
      using file = await Deno.open(path, {
        write: true,
        create: true,
        truncate: true,
      });

      if (stream !== null) {
        await stream.pipeTo(file.writable);
      }

      return true;
    }
    catch {
      return false;
    }
  },

  async getReadableResource(path: string): Promise<ReadableResource> {
    const file = await Deno.open(path, { read: true });

    return {
      stream: file.readable,

      [Symbol.asyncDispose](): Promise<void> {
        try {
          file.close();
        }
        catch { /** ignore */ }

        return Promise.resolve();
      },
    };
  },

  openSync(path: string, options: {
    write?: boolean;
    create?: boolean;
    truncate?: boolean;
    read?: boolean;
  }): OpenSyncResult {
    const file = Deno.openSync(path, options);
    return {
      writeSync: (p: Uint8Array): number => file.writeSync(p),
      close: (): void => file.close(),
    };
  },

  async execSync(command: string, args: string[]): Promise<SubProcessResult> {
    const cmd = new Deno.Command(command, { args });
    return await cmd.output();
  },

  async exec(
    command: string,
    args: string[],
    onOutput: (text: string, source: "stdout" | "stderr") => void,
  ): Promise<SubProcessStatus> {
    /**
     * 非同期の読み込み。
     *
     * @remarks 補足
     * - steam: true でマルチバイトの境界を認識させる。
     * - steam: true を指定しているので最終チャンクは手動で処理する。
     */
    const readStream = async (
      stream: ReadableStream<Uint8Array>,
      source: "stdout" | "stderr",
    ): Promise<void> => {
      const decoder = new TextDecoder();
      for await (const chunk of stream) {
        onOutput(decoder.decode(chunk, { stream: true }), source);
      }

      const remaining = decoder.decode();
      if (remaining) {
        onOutput(remaining, source);
      }
    };

    const cmd = new Deno.Command(command, {
      args,
      stdout: "piped",
      stderr: "piped",
    });
    const child = cmd.spawn();
    const [stdoutDone, stderrDone] = [
      readStream(child.stdout, "stdout"),
      readStream(child.stderr, "stderr"),
    ];
    const [status] = await Promise.all([
      child.status,
      stdoutDone,
      stderrDone,
    ]);

    return status;
  },

  printSync(message: string): void {
    const data = new TextEncoder().encode(message);
    Deno.stdout.writeSync(data);
  },

  outputEnv(): void {
    console.log("--- Deno Built-in Properties ---");

    const envInfo = {
      "Deno.hostname": Deno.hostname(),
      "Deno.execPath()": Deno.execPath(),
      "Deno.noColor": Deno.noColor,
      "Deno.version": Deno.inspect(Deno.version),
      "Deno.build": Deno.inspect(Deno.build),
    };
    for (const [key, value] of Object.entries(envInfo)) {
      console.log(`${key}: ${value}`);
    }

    console.log("--- Deno Related Environment Variables ---");

    // deno-fmt-ignore
    const denoRelatedVars = [
      "DENO_DIR",            // キャッシュルート
      "DENO_INSTALL",        // インストール先
      "DENO_AUTH_TOKENS",    // 秘密リポジトリ等の認証情報
      "DENO_CERT",           // TLS証明書のパス
      "DENO_WEBGPU_ADAPTER", // WebGPUの使用アダプタ
      "NO_COLOR",            // コンソール出力の色抑制
      "HTTP_PROXY",          // ネットワークプロキシ
      "HTTPS_PROXY",         // ネットワークプロキシ
      "NO_PROXY",            // プロキシ除外設定
    ];
    for (const name of denoRelatedVars) {
      const value = Deno.env.get(name);
      console.log(`${name}: ${value ?? "(not set)"}`);
    }
  },
};

// ===================================================================
// 画像処理ラッパー
// ===================================================================
// ===================================================================
// 動画処理ラッパー
// ===================================================================
// ===================================================================
// fetchのラッパー
// ===================================================================

/**
 * fetch 実行の結果を分類して表す。
 *
 * 判別共用体 (Discriminated Union)。
 *
 * @remarks 補足
 * - ネットワークの成否だけでなく、HTTP ステータスコードに基づく。
 * - 論理的な成功・失敗も型レベルで区別するために使用する。
 */
type SafeFetchResult =
  | (
    {
      readonly type: "success";
      readonly response: Response;
      readonly error?: never;
    } & AsyncDisposable
  )
  | (
    {
      readonly type: "http_error";
      readonly response: Response;
      readonly error?: never;
    } & AsyncDisposable
  )
  | (
    {
      readonly type: "network_error";
      readonly response?: never;
      readonly error: Error;
    } & AsyncDisposable
  );

/**
 * fetch を実行し、例外をスローせずに実行結果を型安全なオブジェクトとして返す。
 *
 * @param url - リクエスト対象の URL 文字列
 * @returns 実行結果の状態 (`type`) に応じた {@link SafeFetchResult} を含む
 *
 * @remarks 素のfetchではエラーハンドリングが面倒くさいので例外を全て吸収し
 * 戻り値として返す。
 */
async function safeFetch(
  url: string,
  headers: HeadersInit = {},
): Promise<SafeFetchResult> {
  try {
    const response = await fetch(url, { method: "GET", headers });

    const disposer = {
      async [Symbol.asyncDispose](): Promise<void> {
        if (response.bodyUsed === false && response.body) {
          await response.body.cancel();
        }
      },
    };

    return {
      type: response.ok ? "success" : "http_error",
      response,
      ...disposer,
    };
  }
  catch (error) {
    return {
      type: "network_error",
      error: ensureError(error),
      [Symbol.asyncDispose](): Promise<void> {
        return Promise.resolve();
      },
    };
  }
}

// ===================================================================
// コマンドの定義
// ===================================================================

/**
 * コマンドの戻り値型。
 *
 * @typeParam E - エラーオブジェクトの型。デフォルトは標準の `Error`。
 * @example
 * ### 成功時のパターン
 * ```typescript
 * const result: CommandResult = { success: true, code: 0 };
 * ```
 * ### 警告・中断時(コマンドライン引数が異常など)のパターン
 * ```typescript
 * const result: CommandResult = { success: true, code: 2 };
 * ```
 * ### 失敗(例外)時のパターン
 * ```typescript
 * const result: CommandResult<TypeError> = { success: false, code: 1, error: new TypeError("型が不正です") };
 * ```
 */
type CommandResult<E extends Error = Error> =
  | {
    readonly success: true;
    readonly code: number;
    readonly error?: never;
  }
  | {
    readonly success: false;
    readonly code: number;
    readonly error?: E;
  };

/**
 * {@link CommandResult} を生成するファクトリーオブジェクト。
 *
 * @example
 * ```typescript
 * // 完全な正常終了(Exit Code: 0)
 * return CommandResult.ok();
 *
 * // 例外ではないが、警告・中断による終了(Exit Code: 2)
 * console.error("エラー: 引数が不正です");
 * return CommandResult.warn();
 *
 * // 予期せぬシステム例外による終了(Exit Code: 1)
 * try {
 *   await Deno.readTextFile("config.json");
 * }
 * catch (error) {
 *   // デバッグ用スタックトレースが維持される
 *   return CommandResult.error(ensureError(error));
 * }
 * ```
 */
const CommandResult = {
  /**
   * 完全な正常終了(成功)を表す {@link CommandResult} を生成する。
   *
   * @remarks
   * 終了コード(Exit Code)は常に `0` 固定。
   */
  ok(): CommandResult {
    return { success: true, code: 0 };
  },
  /**
   * 例外(クラッシュ)ではないが、警告やユーザーの操作ミスによる処理の中断を
   * 表す {@link CommandResult} を生成する。
   *
   * @remarks
   * 警告などのメッセージの出力を行ったあと、スタックトレースを吐かずにプロセス
   * を終了させたい場合に使用する。
   * @param code - OSに返却する終了コード。初期値は `2`。
   */
  warn(code: number = 2): CommandResult {
    return { success: true, code };
  },
  /**
   * 予期せぬシステム例外やクラッシュを表す {@link CommandResult} を生成する。
   *
   * @param error - 発生した例外(Errorインスタンス)。
   * @param code - OSに返却する異常終了コード。初期値は `1`。
   */
  error<E extends Error>(error: E, code: number = 1): CommandResult<E> {
    return { success: false, code, error };
  },
};

/**
 * 全てのサブコマンドが共有する解析済みオプションの基底型。
 */
type CommandOptions = {
  /** 詳細な実行ログを出力するかどうか (現在未使用) */
  verbose?: boolean;
};

/**
 * コマンドオプションの解析・検証結果を表す型。
 *
 * 検証結果が `valid` の場合は `options` を、
 * `invalid` の場合は `reason`(理由)を保持する。
 */
type ParseOptionsResult<T extends CommandOptions> =
  | {
    readonly type: "valid";
    readonly options: T;
    readonly reason?: never;
  }
  | {
    readonly type: "invalid";
    readonly options?: never;
    readonly reason: string;
  };

/**
 * サブコマンドの「クラス定義そのもの」が満たすべき共通ルール。
 *
 * @remarks 抽象クラスでは制限できない `static` メンバの実装を、コンパイル時に
 * 強制するための仕組み。
 *
 * これにより、クラスをインスタンス化(new)する前の段階で、
 * 識別用の `static ID` などが正しく定義されているかがチェックされる。
 */
interface SubCommandConstructor {
  /**
   * コマンドの識別名(例: "renumber", "download")。
   *
   * @remarks 具象クラスでは `static readonly ID: string` として定義する。
   */
  readonly ID: string;

  /**
   * ヘルプ画面に表示されるコマンドの簡潔な説明文。
   *
   * @remarks 具象クラスでは `static readonly DESCRIPTION: string` として定義
   * する。
   */
  readonly DESCRIPTION: string;

  /**
   * サブコマンドのインスタンスを生成するためのコンストラクタ。
   *
   * @param runtime ランタイムの実装
   * @returns サブコマンドのインスタンス
   */
  new (runtime?: Runtime): SubCommand<CommandOptions>;
}

/**
 * 全てのサブコマンドの基底となる抽象クラス。
 *
 * 引数の解析(Template Methodパターンの構成)と実行フローを制御する。
 * 継承先では、具体的なオプション型 `T` を定義し、解析と実行のロジックを
 * 実装すること。
 *
 * @template T コマンドが使用する解析済みオプションの型
 */
abstract class SubCommand<T extends CommandOptions> {
  /**
   * コンストラクター。
   *
   * @param runtime - ランタイム固有機能。
   * デフォルトは `nativeRuntime`を使用。
   * 単体テスト時には、ここへモックオブジェクトを注入することで、
   * 実際のファイルシステムを汚さずにロジックを検証できる。
   * @example
   * // 本番用(通常はこちらを使用)
   * const cmd = new ReNumberCommand();
   * @example
   * // テスト用(ディレクトリ内のファイル一覧を偽装する)
   * const mock: Runtime = {
   * readDir: async function* () { yield { name: "file1.txt", isFile: true, isDirectory: false, isSymlink: false }; },
   * exists: async () => false,
   * rename: async (s, d) => console.log(`Moved: ${s} -> ${d}`),
   * };
   * const cmd = new ReNumberCommand(mock);
   */
  constructor(protected runtime: Runtime = nativeRuntime) {}

  /**
   * コマンド固有のヘルプ情報(使用法、オプションの詳細など)を
   * 標準出力に表示する。
   * @protected
   */
  protected abstract showHelp(): void;

  /**
   * コマンドライン引数の配列を解析し、型 `T` のオブジェクトに変換する。
   *
   * @param args サブコマンド名を除いた、解析対象の引数リスト
   * @returns 解析・構造化されたオプションオブジェクト
   * @protected
   */
  protected abstract parseOptions(args: string[]): ParseOptionsResult<T>;

  /**
   * コマンドの具体的な実行ロジックを実装する。
   *
   * @param options `parseOptions` によって解析されたオプションオブジェクト
   * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise
   * @protected
   */
  protected abstract runImpl(options: T): Promise<CommandResult>;

  /**
   * コマンドの実行エントリポイント。
   *
   * 引数の解析 (`parseOptions`) を行い、その結果を元に実行 (`runImpl`) を
   * 呼び出す。
   *
   * @param args コマンドラインから渡された未解析の引数リスト
   * @returns コマンドの実行結果を保持するオブジェクト
   */
  async run(args: string[]): Promise<CommandResult> {
    const subArgs = parseArgs(args, {
      boolean: ["help"],
      alias: { h: "help" },
    });
    if (subArgs.help) {
      this.showHelp();
      return CommandResult.ok();
    }

    const resOption = this.parseOptions(args);
    if (resOption.type === "invalid") {
      console.error(resOption.reason);
      return CommandResult.warn();
    }

    return await this.runImpl(resOption.options);
  }
}

// ===================================================================
// SubCommand: renumber (連番リネーム)
// ===================================================================
// ===================================================================
// SubCommand: downloadfiles (ファイルダウンロード)
// ===================================================================
// ===================================================================
// SubCommand: resizeimages (画像ファイルサイズ変換)
// ===================================================================
// ===================================================================
// SubCommand: resizevideo (動画のサイズ変換)
// ===================================================================
// ===================================================================
// SubCommand: filecompress (圧縮ファイル化)
// ===================================================================
// ===================================================================
// SubCommand:  metronome (WebView: メトロノーム)
// ===================================================================
// ===================================================================
// SubCommand:  dirtotext (構成内容一括出力)
// ===================================================================

/**
 * `dirtotext` コマンドで使用するパース済みオプション。
 */
type DirToTextOptions = CommandOptions & {
  rootPath: string;
  isTreeOnly: boolean;
  outputPath?: string | undefined;
  targetExt?: string | undefined;
};

/**
 * 内部用の書き込みリソース抽象化型。
 *
 * @remarks `using` 宣言に対応する。
 *
 * @example
 * ```typescript
 * {
 * using write = createDirToTextWriter("output.txt");
 * write("some content");
 * // スコープを抜ける際に Symbol.dispose が自動的に close() を呼び出す
 * }
 * ```
 */
type DirToTextWriter = {
  (content: string): void;
  close: () => void;
  [Symbol.dispose](): void;
};

/**
 * 開発プロジェクト内部のディレクトリ構造を単一のテキストに変換し、ソース
 * ファイルに相当する内容を結合して1つにする。
 */
class DirToTextCommand extends SubCommand<DirToTextOptions> {
  static readonly ID = "dirtotext";
  static readonly DESCRIPTION =
    "プロジェクトディレクトリ構造とソースファイルを1つのテキストに変換";

  private static readonly IGNORE_DIRS: ReadonlySet<string> = new Set([
    "node_modules",
    "vendor",
    ".git",
    ".github",
    ".gitlab",
    "dist",
    "build",
    "out",
    "coverage",
    ".venv",
    "__pycache__",
    ".ipynb_checkpoints",
    "playground",
    // ".vscode",
  ]);

  /**
   * 処理対象とする拡張子のリスト。
   * 全て小文字、ドット付きで定義。
   */
  // deno-fmt-ignore
  private static readonly TARGET_EXTS: ReadonlySet<string> = new Set([
    // Web / Script
    ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".d.ts", ".sh",
    // Python
    ".py", ".pyi",
    // Java
    ".java", ".jsp", ".properties", ".kt", ".kts",
    // C / C++
    ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".c",
    // SQL
    ".sql",
    // Configs
    ".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".conf", ".cfg",
    ".gitignore", ".code-workspace",
  ]);

  /** 日本語環境に最適化された自然順ソート用の比較器。 */
  // deno-fmt-ignore
  private static readonly I18N_COLLATOR: Readonly<Intl.Collator> =
    new Intl.Collator("ja", { numeric: true, sensitivity: "variant" });

  /**
   * 処理対象とする拡張子のリスト(実処理)
   */
  private targetExts: ReadonlySet<string>;

  /**
   * コンストラクタ。
   */
  constructor(runtime?: Runtime) {
    super(runtime);
    this.targetExts = DirToTextCommand.TARGET_EXTS;
  }

  /**
   * ヘルプ情報を標準出力。
   */
  protected override showHelp(): void {
    console.log(`
Description:
  ${DirToTextCommand.DESCRIPTION}

Usage:
  deno run -A utils.ts ${DirToTextCommand.ID} [Options]

Options:
  -p, --rootPath  出力対象のルートディレクトリ
  -t, --tree      ディレクトリ構造のみ出力
  -o, --output    出力ファイルのパス (未指定時は標準出力)
  --ext           独自指定拡張子(ファイル名の末尾とマッチさせる)
  -h, --help      このヘルプを表示

例 独自の拡張子を指定する方法
  --ext=".test.ts"       // 1つでも
  --ext=".txt:.md:.log"  // 複数でも指定可能`);
  }

  /**
   * 引数を `DirToTextOptions` 型にパースする。
   *
   * @param rawArgs コマンドライン引数の配列
   * @returns パース済みのオプションオブジェクト
   */
  protected override parseOptions(
    rawArgs: string[],
  ): ParseOptionsResult<DirToTextOptions> {
    const args = parseArgs(rawArgs, {
      string: ["rootPath", "output", "ext"],
      boolean: ["tree"],
      default: {
        rootPath: "",
        tree: false,
      },
      alias: {
        p: "rootPath",
        t: "tree",
        o: "output",
      },
    });

    if (args.rootPath === "") {
      return {
        type: "invalid",
        reason: "-p --rootPath を指定してください。",
      };
    }

    return {
      type: "valid",
      options: {
        rootPath: args.rootPath,
        isTreeOnly: args.tree,
        ...(args.output ? { outputPath: args.output } : {}),
        ...(args.ext ? { targetExt: args.ext } : {}),
      },
    };
  }

  /**
   * コマンドのメインロジックを実行する。
   *
   * @param options `parseOptions` によって解析されたオプションオブジェク
   *
   * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  protected override async runImpl(
    options: Readonly<DirToTextOptions>,
  ): Promise<CommandResult> {
    const absoluteRoot = path.resolve(options.rootPath);
    const treeLines: string[] = [];
    const files: string[] = [];

    if (options.targetExt) {
      this.targetExts = new Set(
        splitTokens(options.targetExt),
      );
    }

    await this.scanDirectory(absoluteRoot, treeLines, files);

    using writer = this.createWriter(options.outputPath);

    writer("=".repeat(40));
    writer("PROJECT STRUCTURE:");
    writer(treeLines.join("\n"));

    if (options.isTreeOnly) {
      return CommandResult.ok();
    }

    writer("\n" + "=".repeat(40));
    writer("FILE CONTENTS:");

    for (const file of files) {
      const relativePath = path.relative(absoluteRoot, file);
      writer(`\n---- ${relativePath} ----`);

      await using res = await this.runtime.getReadableResource(file);
      const text = await new Response(res.stream).text();
      writer(text);
    }

    return CommandResult.ok();
  }

  /**
   * ディレクトリ構造を再帰的に走査し、ツリー表示用の文字列配列とファイル情報の
   * 配列を構築する。
   *
   * 各階層のエントリは、まず「ディレクトリ > ファイル」の優先順位で分類し、
   * 同一タイプ内ではロケールを考慮した辞書順でソートされた上で走査する。
   *
   * @param currentPath - 現在走査中、および起点となるディレクトリの絶対パス。
   * @param treeLines - ツリー形式の文字列を格納する`出力用`配列。
   * @param files - 抽出されたファイル情報を格納する`出力用`配列。
   * @param prefix - ツリー表示の階層を示すためのインデント文字列。
   * @returns 非同期処理の完了を示す Promise。
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  async scanDirectory(
    currentPath: string,
    treeLines: string[],
    files: string[],
    prefix = "",
  ): Promise<void> {
    const entries: RuntimeDirEntry[] = [];

    for await (const entry of this.runtime.readDir(currentPath)) {
      if (await this.isValidEntry(currentPath, entry)) {
        entries.push(entry);
      }
    }

    entries.sort((a, b) => {
      if (a.isDirectory !== b.isDirectory) {
        return a.isDirectory ? -1 : 1;
      }

      return DirToTextCommand.I18N_COLLATOR.compare(a.name, b.name);
    });

    for (let i = 0; i < entries.length; i++) {
      const entry = entries[i];
      const isLast = i === entries.length - 1;
      const fullPath = path.join(currentPath, entry.name);
      const line = `${prefix}${isLast ? "└── " : "├── "}${entry.name}`;
      treeLines.push(line);

      if (entry.isDirectory) {
        await this.scanDirectory(
          fullPath,
          treeLines,
          files,
          prefix + (isLast ? "    " : "│   "),
        );
      }
      else {
        files.push(fullPath);
      }
    }
  }

  /**
   * 処理対象エントリ判定。
   *
   * @remarks
   * 判定基準:
   * - `IGNORE_DIRS` に該当しないディレクトリであり、その配下に1つ以上の
   *   処理対象ファイルが存在する。
   * - `TARGET_EXTS` に該当するファイル。
   * - シンボリックリンクは処理対象外。
   *
   * @param currentPath - 現在走査しているディレクトリの絶対パス。
   * @param entry - エントリ情報。
   * @returns 処理対象である場合は `true`、それ以外は `false`。
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  private async isValidEntry(
    currentPath: string,
    entry: RuntimeDirEntry,
  ): Promise<boolean> {
    if (entry.isDirectory) {
      if (DirToTextCommand.IGNORE_DIRS.has(entry.name)) {
        return false;
      }

      const childPath = path.join(currentPath, entry.name);
      return await this.containsTargetEntries(childPath);
    }
    else if (entry.isFile) {
      // NOTE: コマンドの利便性向上:判定基準を拡張子からファイル名の末尾に
      // const ext = path.extname(entry.name).toLowerCase();
      // return this.targetExts.has(ext);
      return this.isTargetFileName(entry.name);
    }

    // シンボリックリンクは無視する
    return false;
  }

  /**
   * 指定されたファイル名が処理対象であるかを判定する。
   *
   * @remarks
   * 判定基準:
   * - ファイル名の末尾(拡張子を含むファイル名全体)が、`targetExts` に
   * 定義されたいずれかの文字列と一致するかを判定する。
   * - 大文字・小文字は区別しない(小文字に統一して評価)。
   *
   * @param name - 判定対象のファイル名(ベース名)。
   * @returns 処理対象の末尾と一致する場合は `true`、それ以外は `false`。
   */
  private isTargetFileName(name: string): boolean {
    const str = name.toLowerCase();
    let result = false;

    for (const ext of this.targetExts) {
      if (str.endsWith(ext)) {
        result = true;
        break;
      }
    }

    return result;
  }

  /**
   * 指定されたディレクトリが処理対象エントリを含んでいるかを判定する。
   *
   * @param dirPath 走査対象のディレクトリの絶対パス
   * @returns `dirPath`が処理対象エントリを含むディレクトリなら true。
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  async containsTargetEntries(dirPath: string): Promise<boolean> {
    for await (const entry of this.runtime.readDir(dirPath)) {
      if (await this.isValidEntry(dirPath, entry)) {
        return true;
      }
    }

    return false;
  }

  /**
   * 出力先に応じた Writer オブジェクトを生成する。
   *
   * @param outputPath - 出力先ファイルのパス。
   *                     省略した場合は標準出力(console.log)を使用する。
   * @returns `DirToTextWriter` オブジェクト。
   *          `using` 構文による自動リソース解放に対応する。
   * @throws {Error} ファイルシステム操作に失敗した場合
   *
   * @example
   * ```typescript
   * using writer = this.createWriter("output.txt");
   * writer("content");
   * ```
   */
  private createWriter(outputPath?: string): DirToTextWriter {
    if (outputPath) {
      return this.createFileWriter(outputPath);
    }

    return this.createConsoleWriter();
  }

  private createFileWriter(outputPath: string): DirToTextWriter {
    const encoder = new TextEncoder();
    const file = this.runtime.openSync(outputPath, {
      write: true,
      create: true,
      truncate: true,
    });

    const writer = ((content: string): void => {
      file.writeSync(encoder.encode(content + "\n"));
    }) as DirToTextWriter;

    writer.close = (): void => {
      file.close();
    };

    writer[Symbol.dispose] = (): void => {
      writer.close();
    };

    return writer;
  }

  private createConsoleWriter(): DirToTextWriter {
    const writer = ((content: string): void => {
      console.log(content);
    }) as DirToTextWriter;

    writer.close = (): void => {};
    writer[Symbol.dispose] = (): void => {};

    return writer;
  }
}

// ===================================================================
// メイン処理
// ===================================================================

/**
 * ツール全体のライフサイクルとコマンド実行を管理するメインクラス。
 *
 * 本プログラムはシンプルなコマンドラインプログラムなので、高度な
 * ハンドリングは必要ない。
 */
class Main {
  // NOTE: ここで SubCommandConstructor型 を指定することで
  // サブコマンドにstaticメンバーとコンストラクタを継承(強制)させられる
  // deno-fmt-ignore
  private readonly SUB_COMMANDS: ReadonlyMap<string, SubCommandConstructor> =
    new Map<string, SubCommandConstructor>([
      // [ReNumberCommand.ID, ReNumberCommand],
      // [DownloadFilesCommand.ID, DownloadFilesCommand],
      // [ReSizeImagesCommand.ID, ReSizeImagesCommand],
      // [ReSizeVideoCommand.ID, ReSizeVideoCommand],
      // [FileCompressorCommand.ID, FileCompressorCommand],
      [DirToTextCommand.ID, DirToTextCommand],
      // [MetronomeCommand.ID, MetronomeCommand],
  ]);

  /**
   * ヘルプ情報を標準出力。
   *
   * @returns 常に0(正常終了コード)
   */
  private showHelp(): void {
    console.log(`Usage: deno run -A utils.ts [Options] [SubCommands]

Options:
  -h, --help      Show help
  -v, --version   Show version
  -e, --env       Show environment info

SubCommands:`);

    // deno-fmt-ignore
    const maxDigits = [...this.SUB_COMMANDS.keys()].reduce(
      (max, key) => Math.max(max, key.length)
      , 0
    ) + 2;

    for (const cmd of this.SUB_COMMANDS.values()) {
      console.log(`  ${cmd.ID.padEnd(maxDigits)} : ${cmd.DESCRIPTION}`);
    }
  }

  /**
   * コマンドライン引数をパースする。
   *
   * @param args コマンドライン引数の配列
   * @returns パース済みのオプションオブジェクト
   */
  private parseOptions(args: string[]): {
    help: boolean;
    version: boolean;
    env: boolean;
    _: string[]; // 上記の残りの引数がここに格納される
  } {
    return parseArgs(args, {
      stopEarly: true, // 最初の引数をサブコマンドとして扱う
      boolean: ["help", "version", "env"],
      alias: { h: "help", v: "version", e: "env" },
    });
  }

  /**
   * アプリケーションのメインエントリポイント。
   *
   * コマンドライン引数を解析し、適切なサブコマンドを呼び出す。
   *
   * @returns プロセスの終了コード
   */
  async exec(): Promise<number> {
    const args = this.parseOptions(nativeRuntime.getArgs());

    if (args.version) {
      console.log(`${PROG_VERSION}`);
      return 0;
    }

    if (args.env) {
      nativeRuntime.outputEnv();
      return 0;
    }

    const [subCommandName, ...subCommandArgs] = args._;
    if (args.help || subCommandName === undefined) {
      this.showHelp();
      return 0;
    }

    const subCommand = this.SUB_COMMANDS.get(subCommandName);
    if (subCommand === undefined) {
      console.error(`Unknown command: ${subCommandName}`);
      return 1;
    }

    try {
      const cmd = new subCommand();
      const result = await cmd.run(subCommandArgs);
      if (result.error) {
        console.error(result.error.stack);
      }

      return result.code;
    }
    // 想定外のエラー(権限不足やディスクフルなど)
    catch (error) {
      const e = ensureError(error);
      console.error(e.stack);

      return 1;
    }
  }
}

/**
 * 直接実行された場合のみアプリケーションを起動
 */
if (import.meta.main) {
  const exitCode = await new Main().exec();
  nativeRuntime.exit(exitCode);
}