#!/usr/bin/env python3
# merge_text_files.py
"""
概要:
    指定ディレクトリ内のテキストファイルを検索し、メタデータを付与して1つのファイルに結合します。
詳細説明:
    拡張子やパターンでファイルを絞り込み、指定した階層まで再帰的に検索することが可能です。
    出力ファイルには各ファイルの内容に加えて、パスやファイル種別の情報がヘッダとして記録されます。
関連リンク:
    merge_text_files_usage
"""

from __future__ import annotations

import argparse
import fnmatch
from pathlib import Path


"""
python merge_text_files.py C:\work\tkProg -o launcher_docs_merged.txt -r -d 5 -p "*.txt" "*.md"
python merge_text_files.py C:\work\tkProg -o launcher_docs_merged.txt -r -d 5 -p "*.txt;*.md"

"""


FILE_START = "===== FILE START ====="
CONTENT_START = "===== CONTENT ====="
FILE_END = "===== FILE END ====="


def guess_type(path: Path) -> str:
    """
    概要:
        ファイルパスの拡張子からファイル種別を判定します。
    引数:
        :param path: 判定するファイルのパス。
        :type path: pathlib.Path
    戻り値:
        :returns: ファイル種別を表す文字列。
        :rtype: str
    """
    ext = path.suffix.lower()
    if ext == ".py":
        return "python"
    if ext == ".ini":
        return "ini"
    if ext == ".md":
        return "md"
    if ext == ".txt":
        return "txt"
    return ext.lstrip(".") or "unknown"


def split_patterns(patterns: list[str]) -> list[str]:
    """
    概要:
        セミコロン区切りのパターン文字列を展開し、個別のパターンのリストを作成します。
    引数:
        :param patterns: 分割前のパターン文字列のリスト。
        :type patterns: list[str]
    戻り値:
        :returns: セミコロンで分割された個別のパターン文字列のリスト。
        :rtype: list[str]
    """
    result: list[str] = []
    for item in patterns:
        for part in item.split(";"):
            part = part.strip()
            if part:
                result.append(part)
    return result


def is_within_depth(path: Path, root: Path, max_depth: int) -> bool:
    """
    概要:
        対象ファイルが指定された最大階層の範囲内にあるか判定します。
    詳細説明:
        max_depth がマイナスの場合は常に真を返します。
    引数:
        :param path: 判定対象のファイルパス。
        :type path: pathlib.Path
        :param root: 基準となるルートディレクトリのパス。
        :type root: pathlib.Path
        :param max_depth: 許可される最大階層。
        :type max_depth: int
    戻り値:
        :returns: 範囲内の場合は真、それ以外は偽。
        :rtype: bool
    """
    if max_depth < 0:
        return True

    rel = path.relative_to(root)
    depth = len(rel.parts) - 1
    return depth <= max_depth


def match_any(path: Path, root: Path, patterns: list[str]) -> bool:
    """
    概要:
        ファイルのパスがいずれかのパターンに一致するか判定します。
    詳細説明:
        ファイル名のみ、またはルートディレクトリからの相対パスに対してマッチングを行います。
    引数:
        :param path: 判定対象のファイルパス。
        :type path: pathlib.Path
        :param root: 基準となるルートディレクトリのパス。
        :type root: pathlib.Path
        :param patterns: マッチングに使用するワイルドカードパターンのリスト。
        :type patterns: list[str]
    戻り値:
        :returns: いずれかのパターンに一致した場合は真、それ以外は偽。
        :rtype: bool
    """
    rel_posix = path.relative_to(root).as_posix()
    name = path.name

    return any(
        fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel_posix, pat)
        for pat in patterns
    )


def collect_files(
    root: Path,
    patterns: list[str],
    recursive: bool,
    max_depth: int,
    output_path: Path | None,
) -> list[Path]:
    """
    概要:
        指定された条件に基づき、対象となるファイルを収集します。
    詳細説明:
        ルートディレクトリ以下を検索し、階層やパターンに一致するファイルのリストを返します。
        出力ファイル自身のパスが含まれる場合は除外します。
    引数:
        :param root: 検索を開始するルートディレクトリのパス。
        :type root: pathlib.Path
        :param patterns: 検索対象となるファイル名やパスのパターンリスト。
        :type patterns: list[str]
        :param recursive: サブディレクトリを再帰的に検索するかどうか。
        :type recursive: bool
        :param max_depth: 検索する最大階層。
        :type max_depth: int
        :param output_path: 除外対象となる出力ファイルのパス。
        :type output_path: pathlib.Path | None
    戻り値:
        :returns: 条件に一致したファイルパスのソート済みリスト。
        :rtype: list[pathlib.Path]
    """
    iterator = root.rglob("*") if recursive else root.glob("*")

    files: list[Path] = []
    for path in iterator:
        if not path.is_file():
            continue

        if output_path is not None:
            try:
                if path.resolve() == output_path.resolve():
                    continue
            except FileNotFoundError:
                pass

        if not is_within_depth(path, root, max_depth):
            continue

        if match_any(path, root, patterns):
            files.append(path)

    return sorted(files, key=lambda p: p.relative_to(root).as_posix().lower())


def read_text_file(path: Path, encoding: str) -> str:
    """
    概要:
        指定されたエンコーディングでテキストファイルを読み込みます。
    詳細説明:
        デコードエラーが発生した場合は utf-8-sig で代替文字を使って読み直します。
    引数:
        :param path: 読み込むファイルのパス。
        :type path: pathlib.Path
        :param encoding: 初回に使用するテキストのエンコーディング。
        :type encoding: str
    戻り値:
        :returns: ファイルから読み込んだテキストデータ。
        :rtype: str
    """
    try:
        return path.read_text(encoding=encoding)
    except UnicodeDecodeError:
        return path.read_text(encoding="utf-8-sig", errors="replace")


def merge_files(
    root: Path,
    files: list[Path],
    output_path: Path,
    encoding: str,
    blank_lines: int,
) -> None:
    """
    概要:
        収集したファイルを読み込み、メタデータを付加して1つのファイルに結合します。
    詳細説明:
        各ファイルの内容は開始と終了の区切り文字列で囲まれ、指定した空行数で区切られます。
    引数:
        :param root: ファイルの相対パスを計算するためのルートディレクトリ。
        :type root: pathlib.Path
        :param files: 結合対象となるファイルパスのリスト。
        :type files: list[pathlib.Path]
        :param output_path: 結合結果を書き込む出力ファイルのパス。
        :type output_path: pathlib.Path
        :param encoding: 読み書きに使用するテキストのエンコーディング。
        :type encoding: str
        :param blank_lines: ファイル間に挿入する空行の数。
        :type blank_lines: int
    戻り値:
        :returns: なし。
        :rtype: None
    """
    separator = "\n" * blank_lines

    with output_path.open("w", encoding=encoding, newline="\n") as out:
        for i, path in enumerate(files):
            rel_path = path.relative_to(root).as_posix()
            file_type = guess_type(path)
            text = read_text_file(path, encoding)

            if i > 0:
                out.write(separator)

            out.write(f"{FILE_START}\n")
            out.write(f"path: {rel_path}\n")
            out.write(f"type: {file_type}\n")
            out.write(f"tags: []\n")
            out.write(f"{CONTENT_START}\n")
            out.write(text)

            if text and not text.endswith("\n"):
                out.write("\n")

            out.write(f"{FILE_END}\n")


def main() -> None:
    """
    概要:
        コマンドライン引数を解析し、ファイルの検索と結合処理を実行します。
    詳細説明:
        引数で指定されたルートディレクトリやパターンをもとにファイルを収集し、結合して出力します。
        引数のチェックを行い、条件を満たさない場合は例外を発生させます。
    戻り値:
        :returns: なし。
        :rtype: None
    例外:
        :raises FileNotFoundError: ルートディレクトリが存在しない場合。
        :raises NotADirectoryError: ルートパスがディレクトリではない場合。
        :raises ValueError: blank_lines オプションに5未満が指定された場合。
    """
    parser = argparse.ArgumentParser(
        description="Merge .txt/.md/etc files with path metadata."
    )

    parser.add_argument(
        "root",
        help="Root directory to search.",
    )

    parser.add_argument(
        "-o",
        "--output",
        default="merged_files.txt",
        help="Output merged file path. Default: merged_files.txt",
    )

    parser.add_argument(
        "-p",
        "--patterns",
        nargs="+",
        default=["*.txt", "*.md"],
        help='Wildcard patterns. Example: -p "*.txt" "*.md" or -p "*.txt;*.md"',
    )

    parser.add_argument(
        "-r",
        "--recursive",
        action="store_true",
        help="Search recursively.",
    )

    parser.add_argument(
        "-d",
        "--max-depth",
        type=int,
        default=-1,
        help="Maximum directory depth from root. -1 means unlimited. Default: -1",
    )

    parser.add_argument(
        "--encoding",
        default="utf-8",
        help="Text encoding for input/output. Default: utf-8",
    )

    parser.add_argument(
        "--blank-lines",
        type=int,
        default=5,
        help="Blank lines between FILE END and next FILE START. Default: 5",
    )

    args = parser.parse_args()

    root = Path(args.root).expanduser().resolve()
    output_path = Path(args.output).expanduser().resolve()
    patterns = split_patterns(args.patterns)

    if not root.exists():
        raise FileNotFoundError(f"Root directory not found: {root}")

    if not root.is_dir():
        raise NotADirectoryError(f"Root is not a directory: {root}")

    if args.blank_lines < 5:
        raise ValueError("--blank-lines must be 5 or more.")

    files = collect_files(
        root=root,
        patterns=patterns,
        recursive=args.recursive,
        max_depth=args.max_depth,
        output_path=output_path,
    )

    output_path.parent.mkdir(parents=True, exist_ok=True)

    merge_files(
        root=root,
        files=files,
        output_path=output_path,
        encoding=args.encoding,
        blank_lines=args.blank_lines,
    )

    print(f"Merged {len(files)} files")
    print(f"Output: {output_path}")


if __name__ == "__main__":
    main()
    input("\nPress ENTER to terminate>>\n")