#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
概要:
    2つのディレクトリツリーを再帰的に比較し、更新対象のファイルを抽出します。

詳細説明:
    以下のルールでファイルを判定します。
    ファイルが root_dir1 にのみ存在する場合は created として処理されます。
    ファイルが両方のディレクトリに存在し、root_dir1 の方が新しい場合は updated として処理されます。
    ファイルが root_dir2 にのみ存在する場合は deleted として処理されます。
    それ以外は報告されません。

    filemask 引数には、複数のワイルドカードマスクをセミコロン区切りで指定できます。

    引数の順序は、最初の位置引数が古い基準ディレクトリ、2番目の位置引数が新しい現在のディレクトリとなります。
"""

import argparse
import difflib
import fnmatch
import os
import sys
import time
from datetime import datetime
from pathlib import Path


VERSION = "2026.09.07-1"


def parse_masks(filemask):
    """
    概要:
        セミコロンで区切られたマスク文字列を分割します。

    引数:
        :param filemask: セミコロン区切りのマスク文字列
        :type filemask: str

    戻り値:
        :returns: 分割されたマスク文字列のリスト
        :rtype: list
    """
    masks = [m.strip() for m in filemask.split(";") if m.strip()]
    return masks or ["*.py"]


def matches_mask(path, root, masks):
    """
    概要:
        ベース名と相対POSIXパスの両方でマスクと照合します。

    詳細説明:
        単純なファイル名マスクだけでなく、ディレクトリを含むマスクもサポートします。

    引数:
        :param path: 判定対象のファイルパス
        :type path: pathlib.Path
        :param root: 基準となるルートディレクトリ
        :type root: pathlib.Path
        :param masks: 照合に使用するマスク文字列のリスト
        :type masks: list

    戻り値:
        :returns: いずれかのマスクに一致した場合はTrue、それ以外はFalse
        :rtype: bool
    """
    rel = path.relative_to(root).as_posix()
    name = path.name

    return any(
        fnmatch.fnmatch(name, mask) or fnmatch.fnmatch(rel, mask)
        for mask in masks
    )


def collect_files(root, masks, label="root", progress_interval=1.0):
    """
    概要:
        条件に一致するファイルの相対パスと絶対パスのマッピングを取得します。

    詳細説明:
        スキャン中、遅いローカルやネットワーク環境でも処理が停止しているように見えないよう、
        定期的に進捗状況を出力します。

    引数:
        :param root: 検索を開始するルートディレクトリ
        :type root: str or pathlib.Path
        :param masks: 照合に使用するマスク文字列のリスト
        :type masks: list
        :param label: 進捗出力に用いるラベル文字列
        :type label: str
        :param progress_interval: 進捗を出力する秒間隔
        :type progress_interval: float

    戻り値:
        :returns: 相対パスをキー、絶対パスを値とする辞書
        :rtype: dict
    """
    files = {}
    n_scanned = 0
    last_progress = time.monotonic()

    print(f"Scanning {label}: {root}", flush=True)

    for dirpath, dirnames, filenames in os.walk(root):
        base = Path(dirpath)

        for filename in filenames:
            n_scanned += 1
            path = base / filename

            if matches_mask(path, root, masks):
                rel = path.relative_to(root).as_posix()
                files[rel] = path

        if progress_interval > 0:
            now = time.monotonic()
            if now - last_progress >= progress_interval:
                try:
                    current = base.relative_to(root)
                    current_text = "." if str(current) == "." else str(current)
                except ValueError:
                    current_text = str(base)

                print(
                    f"  {label}: scanned={n_scanned}, matched={len(files)}, "
                    f"current={current_text}",
                    flush=True,
                )
                last_progress = now

    print(
        f"  {label}: done, scanned={n_scanned}, matched={len(files)}",
        flush=True,
    )
    return files


def fmt_date(timestamp):
    """
    概要:
        タイムスタンプを YYYY/M/D 形式でフォーマットします。

    引数:
        :param timestamp: フォーマット対象のタイムスタンプ
        :type timestamp: float

    戻り値:
        :returns: フォーマットされた日付文字列
        :rtype: str
    """
    dt = datetime.fromtimestamp(timestamp)
    return f"{dt.year}/{dt.month}/{dt.day}"


def fmt_datetime(timestamp):
    """
    概要:
        タイムスタンプを YYYY/M/D H:MM:SS 形式でフォーマットします。

    引数:
        :param timestamp: フォーマット対象のタイムスタンプ
        :type timestamp: float

    戻り値:
        :returns: フォーマットされた日時文字列
        :rtype: str
    """
    dt = datetime.fromtimestamp(timestamp)
    return f"{dt.year}/{dt.month}/{dt.day} {dt.hour}:{dt.minute:02d}:{dt.second:02d}"


def latest_mtime(files):
    """
    概要:
        指定されたファイル群の中で最も新しい更新日時を返します。

    引数:
        :param files: パス情報を値として持つ辞書
        :type files: dict

    戻り値:
        :returns: 最新のタイムスタンプ値、ファイルがない場合はNone
        :rtype: float or None
    """
    if not files:
        return None
    return max(path.stat().st_mtime for path in files.values())



def read_text_lines(path):
    """
    概要:
        ソースやテキストファイルを読み込み、行のリストを返します。

    詳細説明:
        UTF-8およびcp932でデコードを試みます。どちらも失敗した場合は、
        差分生成を継続できるように置換文字を使用したUTF-8で読み込みます。

    引数:
        :param path: 読み込むファイルのパス
        :type path: str or pathlib.Path

    戻り値:
        :returns: 読み込んだ行のリストと使用されたエンコーディングのタプル
        :rtype: tuple
    """
    encodings = ("utf-8-sig", "utf-8", "cp932")

    for enc in encodings:
        try:
            # Use universal newline translation (newline=None, the default),
            # so CRLF/LF differences alone are not treated as text changes.
            with open(path, "r", encoding=enc) as f:
                return f.readlines(), enc
        except UnicodeDecodeError:
            pass

    with open(path, "r", encoding="utf-8", errors="replace") as f:
        return f.readlines(), "utf-8(replace)"


def make_compact_diff(old_path, new_path, context=2):
    """
    概要:
        コンソールやログ出力用のコンパクトなテキスト差分を生成します。

    詳細説明:
        変更された行とわずかなコンテキストのみを保持します。
        unified diff の管理行は含まれず、変更箇所は区切り文字で分けられます。

    引数:
        :param old_path: 比較元のファイルパス
        :type old_path: pathlib.Path
        :param new_path: 比較先のファイルパス
        :type new_path: pathlib.Path
        :param context: 保持するコンテキストの行数
        :type context: int

    戻り値:
        :returns: コンパクトにフォーマットされた差分の文字列リスト
        :rtype: list
    """
    old_lines, _ = read_text_lines(old_path)
    new_lines, _ = read_text_lines(new_path)

    diff_lines = list(
        difflib.unified_diff(
            old_lines,
            new_lines,
            fromfile="old",
            tofile="new",
            n=max(0, int(context)),
            lineterm="\n",
        )
    )

    compact = []
    seen_hunk = False

    for line in diff_lines:
        if line.startswith("--- ") or line.startswith("+++ "):
            continue

        if line.startswith("@@ "):
            if seen_hunk and compact and compact[-1] != "...":
                compact.append("...")
            seen_hunk = True
            continue

        if line.startswith("\\ No newline at end of file"):
            continue

        # unified_diff body consists of:
        #   + added line
        #   - deleted line
        #     unchanged context line
        if line.endswith("\n"):
            line = line[:-1]

        compact.append(line)

    # Remove unnecessary leading/trailing separators or blank lines.
    while compact and compact[0] == "...":
        compact.pop(0)
    while compact and compact[-1] == "...":
        compact.pop()

    return compact


def compare_trees(
    root_dir1,
    root_dir2,
    masks,
    progress_interval=1.0,
    show_diff=True,
    diff_context=2,
):
    """
    概要:
        2つのディレクトリツリーを比較し、ファイルの作成、更新、削除の情報を抽出します。

    引数:
        :param root_dir1: 新しいディレクトリツリーのルート
        :type root_dir1: pathlib.Path
        :param root_dir2: 古いディレクトリツリーのルート
        :type root_dir2: pathlib.Path
        :param masks: 照合に使用するマスク文字列のリスト
        :type masks: list
        :param progress_interval: 進捗表示を行う間隔
        :type progress_interval: float
        :param show_diff: 差分を出力するかどうかのフラグ
        :type show_diff: bool
        :param diff_context: 差分出力時に保持するコンテキスト行数
        :type diff_context: int

    戻り値:
        :returns: 出力メッセージのリストと各ツリーのマッチしたファイル数のタプル
        :rtype: tuple
    """
    files1 = collect_files(
        root_dir1, masks, label="root_dir1",
        progress_interval=progress_interval
    )
    files2 = collect_files(
        root_dir2, masks, label="root_dir2",
        progress_interval=progress_interval
    )

    print(f"Comparing {len(set(files1) | set(files2))} relative paths...", flush=True)

    messages = []
    all_relpaths = sorted(set(files1) | set(files2))

    for rel in all_relpaths:
        in1 = rel in files1
        in2 = rel in files2

        if in1 and not in2:
            t1 = files1[rel].stat().st_mtime
            messages.append(
                f"{rel}: created (new file updated on {fmt_date(t1)})"
            )

            # created files are reported only as newly created files.
            # unified diff output is intentionally skipped.

        elif in1 and in2:
            t1 = files1[rel].stat().st_mtime
            t2 = files2[rel].stat().st_mtime

            if t1 > t2:
                messages.append(
                    f"{rel}: updated (updated on {fmt_date(t1)})"
                )

                if show_diff:
                    diff_lines = make_compact_diff(
                        files2[rel],
                        files1[rel],
                        context=diff_context,
                    )

                    if diff_lines:
                        messages.extend(diff_lines)
                    else:
                        messages.append(
                            "(timestamp is newer, but no text difference was found)"
                        )
                    messages.append("")

        elif not in1 and in2:
            t2 = files2[rel].stat().st_mtime
            messages.append(
                f"{rel}: deleted (old file updated on {fmt_date(t2)})"
            )

            # deleted files are reported only as removed files.
            # unified diff output is intentionally skipped.

    latest1 = latest_mtime(files1)
    latest2 = latest_mtime(files2)

    messages.append("---")

    if latest1 is None:
        messages.append("root_dir1 has no matching files")
    else:
        messages.append(
            f"root_dir1 was lastly updated on {fmt_datetime(latest1)}"
        )

    if latest2 is None:
        messages.append("root_dir2 has no matching files")
    else:
        messages.append(
            f"root_dir2 was lastly updated on {fmt_datetime(latest2)}"
        )

    return messages, len(files1), len(files2)

def main():
    """
    概要:
        コマンドライン引数を解析し、ディレクトリの比較処理を実行します。
    """
    # Handle --version before argparse checks required positional arguments.
    if "--version" in sys.argv[1:]:
        print(f"{Path(sys.argv[0]).name} {VERSION}")
        print(f"program: {Path(__file__).resolve()}")
        return

    parser = argparse.ArgumentParser(
        description=(
            "Recursively compare OLD_DIR and NEW_DIR, and report files that "
            "are created, updated, or deleted in NEW_DIR."
        )
    )

    parser.add_argument(
        "old_dir",
        help="Old/reference directory tree"
    )
    parser.add_argument(
        "new_dir",
        help="New/current directory tree"
    )
    parser.add_argument(
        "--filemask", "-m",
        default="*.py",
        help='Wildcard mask(s), separated by ";" (default: "*.py")'
    )
    parser.add_argument(
        "--log", "-l",
        default="update.log",
        help='Output log file (default: "update.log")'
    )
    parser.add_argument(
        "--progress",
        type=float,
        default=1.0,
        help=(
            "Progress display interval in seconds "
            "(default: 1.0; use 0 to disable periodic progress)"
        )
    )
    parser.add_argument(
        "--no-diff",
        action="store_true",
        help="Do not output compact diff details"
    )
    parser.add_argument(
        "--diff-context",
        type=int,
        default=2,
        help=(
            "Number of unchanged context lines around each diff hunk "
            "(default: 2)"
        )
    )

    args = parser.parse_args()

    old_dir = Path(args.old_dir).expanduser().resolve()
    new_dir = Path(args.new_dir).expanduser().resolve()
    log_file = Path(args.log).expanduser()

    if not old_dir.is_dir():
        parser.error(f"old_dir is not a directory: {old_dir}")

    if not new_dir.is_dir():
        parser.error(f"new_dir is not a directory: {new_dir}")

    # Keep the historical log/internal convention for compatibility with
    # changelog_from_update_log.py:
    #   root_dir1 = new/current
    #   root_dir2 = old/reference
    root_dir1 = new_dir
    root_dir2 = old_dir

    masks = parse_masks(args.filemask)

    messages, n1, n2 = compare_trees(
        root_dir1,
        root_dir2,
        masks,
        progress_interval=max(0.0, args.progress),
        show_diff=not args.no_diff,
        diff_context=max(0, args.diff_context),
    )

    header = [
        f"program  : {Path(__file__).resolve()}",
        f"version  : {VERSION}",
        f"old_dir  : {old_dir}",
        f"new_dir  : {new_dir}",
        # Compatibility fields used by changelog_from_update_log.py.
        f"root_dir1: {root_dir1}",
        f"root_dir2: {root_dir2}",
        f"filemask : {';'.join(masks)}",
        f"files    : root_dir1={n1}, root_dir2={n2}",
        f"diff     : {'off' if args.no_diff else f'compact, context={max(0, args.diff_context)}'}",
        "---",
    ]

    output = "\n".join(header + messages) + "\n"

    # Console output
    print(output, end="")

    # Log output
    log_file.parent.mkdir(parents=True, exist_ok=True)
    log_file.write_text(output, encoding="utf-8")

    print(f"\nLog saved to: {log_file.resolve()}")


if __name__ == "__main__":
    main()