#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
compare_update.py

Two directory trees are compared recursively to extract files that should be
included in an update.

Rules:
  - A file exists only in root_dir1:
      created (new file ...)
  - A file exists in both directories and root_dir1 is newer:
      updated (...)
  - A file exists only in root_dir2:
      deleted (old file ...)
  - Otherwise:
      not reported

filemask can contain multiple wildcard masks separated by ';'
(e.g. "*.py;*.pl;*.txt").

Example:
    python compare_update.py new_dir old_dir
    python compare_update.py new_dir old_dir --filemask "*.py;*.pl"
    python compare_update.py new_dir old_dir --log update.log
    python compare_update.py new_dir old_dir --progress 2
    python compare_update.py new_dir old_dir --diff-context 2
    python compare_update.py new_dir old_dir --no-diff
"""

import argparse
import difflib
import fnmatch
import os
import sys
import time
from datetime import datetime
from pathlib import Path


VERSION = "2026.09.06-4"


def parse_masks(filemask):
    """Split a semicolon-separated mask string."""
    masks = [m.strip() for m in filemask.split(";") if m.strip()]
    return masks or ["*.py"]


def matches_mask(path, root, masks):
    """
    Match both the basename and relative POSIX-style path.

    This allows simple masks such as '*.py' as well as masks such as
    'tools/*.py'.
    """
    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):
    """
    Return {relative_path: absolute_path} for matching files.

    During scanning, progress is printed periodically so that slow local/network
    directory trees do not appear to have stalled.
    """
    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):
    """Format timestamp as YYYY/M/D."""
    dt = datetime.fromtimestamp(timestamp)
    return f"{dt.year}/{dt.month}/{dt.day}"


def fmt_datetime(timestamp):
    """Format timestamp as YYYY/M/D H:MM:SS."""
    dt = datetime.fromtimestamp(timestamp)
    return f"{dt.year}/{dt.month}/{dt.day} {dt.hour}:{dt.minute:02d}:{dt.second:02d}"


def latest_mtime(files):
    """Return latest mtime among files, or None if no files."""
    if not files:
        return None
    return max(path.stat().st_mtime for path in files.values())



def read_text_lines(path):
    """
    Read a source/text file robustly and return a list of lines.

    UTF-8 is preferred. cp932 is also tried because it is common for
    Japanese source files on Windows. If decoding still fails, UTF-8 with
    replacement characters is used so that diff generation can continue.
    """
    encodings = ("utf-8-sig", "utf-8", "cp932")

    for enc in encodings:
        try:
            with open(path, "r", encoding=enc, newline="") as f:
                return f.readlines(), enc
        except UnicodeDecodeError:
            pass

    with open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
        return f.readlines(), "utf-8(replace)"


def make_compact_diff(old_path, new_path, context=2):
    """
    Generate a compact text diff for console/log output.

    Only actual changed lines (+/-) and a small amount of nearby context are
    retained. Unified-diff management lines such as ---, +++, and @@ are not
    included. Separate change hunks are divided by "...".
    """
    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,
):
    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 two directory trees and report files that "
            "are newer in root_dir1, newly created, or deleted."
        )
    )

    parser.add_argument(
        "root_dir1",
        help="Current/new directory tree"
    )
    parser.add_argument(
        "root_dir2",
        help="Old/reference 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 unified 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()

    root_dir1 = Path(args.root_dir1).expanduser().resolve()
    root_dir2 = Path(args.root_dir2).expanduser().resolve()
    log_file = Path(args.log).expanduser()

    if not root_dir1.is_dir():
        parser.error(f"root_dir1 is not a directory: {root_dir1}")

    if not root_dir2.is_dir():
        parser.error(f"root_dir2 is not a directory: {root_dir2}")

    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"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()
