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

"""
changelog_from_update_log.py

compare_update_diff.py のログから "updated" ファイルの相対パスだけを抽出し、
root_dir1 / root_dir2 にある新版・旧版ソースコードそのものを生成AIへ渡して
変更内容を解析し、Markdown ChangeLog を生成する。

重要:
  - ログ中の diff 本文は一切使用しない。
  - created は新版ソースだけをAIで解析し、機能概要を数行でChangeLogへ掲載する。
  - deleted はAI解析せず、最終ChangeLogへ機械的に掲載する。
  - updated のみ、旧版(root_dir2)と新版(root_dir1)のソースコードを比較する。
  - ソースが大きい場合は、各バージョンをソースコードのまま分割してAIに要約し、
    その要約同士を比較する。
  - AIアクセスには tkai_lib_litellm.py の
      read_ai_config()
      query_ai_compatible()
      extract_text()
    を使用する。

実行モード:
  1) ログモード（位置引数1個）
     compare_update_diff.py のログから updated / created / deleted を処理する。

     python changelog_from_update_log.py update.log

  2) 直接比較モード（位置引数2個）
     OLD_FILE と NEW_FILE のソースコードを直接比較してChangeLogを作る。

     python changelog_from_update_log.py OLD_FILE NEW_FILE

例:
    python changelog_from_update_log.py update.log -o CHANGELOG.md
    python changelog_from_update_log.py old/program.py new/program.py -o CHANGELOG.md

    python changelog_from_update_log.py update.log \
        --provider gemini --model gemini-3.1-pro-preview

AIを呼ばずに確認:
    python changelog_from_update_log.py update.log --dry-run
    python changelog_from_update_log.py old/program.py new/program.py --dry-run
"""

import argparse
import importlib.util
import os
import re
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path


STATUS_RE = re.compile(
    r"^(?P<path>.+?):\s+(?P<status>created|updated|deleted)\s+\((?P<detail>.*)\)\s*$"
)

ROOT1_RE = re.compile(r"^root_dir1:\s*(?P<path>.+?)\s*$")
ROOT2_RE = re.compile(r"^root_dir2:\s*(?P<path>.+?)\s*$")
ROOT1_LAST_RE = re.compile(
    r"^root_dir1 was lastly updated on\s+(?P<date>.+?)\s*$"
)


@dataclass
class UpdateEntry:
    path: str
    detail: str


def read_text_file(path):
    """UTF-8系を優先し、Windows CP932にも対応してテキストを読む。"""
    path = Path(path)

    for enc in ("utf-8-sig", "utf-8", "cp932"):
        try:
            return path.read_text(encoding=enc), enc
        except UnicodeDecodeError:
            pass

    return path.read_text(
        encoding="utf-8",
        errors="replace"
    ), "utf-8(replace)"


def parse_update_log(log_text):
    """
    ログから必要な情報だけを抽出する。

    diff本文は無視する。
    """
    root_dir1 = None
    root_dir2 = None
    root1_last_updated = None
    updated_entries = []
    created_entries = []
    deleted_entries = []

    for line in log_text.splitlines():
        m = ROOT1_RE.match(line)
        if m:
            root_dir1 = m.group("path").strip()
            continue

        m = ROOT2_RE.match(line)
        if m:
            root_dir2 = m.group("path").strip()
            continue

        m = ROOT1_LAST_RE.match(line)
        if m:
            root1_last_updated = m.group("date").strip()
            continue

        m = STATUS_RE.match(line)
        if not m:
            continue

        entry = UpdateEntry(
            path=m.group("path").strip(),
            detail=m.group("detail").strip(),
        )

        status = m.group("status")
        if status == "updated":
            updated_entries.append(entry)
        elif status == "created":
            created_entries.append(entry)
        elif status == "deleted":
            deleted_entries.append(entry)

    return {
        "root_dir1": root_dir1,
        "root_dir2": root_dir2,
        "root1_last_updated": root1_last_updated,
        "updated": updated_entries,
        "created": created_entries,
        "deleted": deleted_entries,
    }


def relative_to_local_path(rel):
    """
    ログの相対パスは '/' 区切りで出力されるため、
    実行OSのPathへ安全に変換する。
    """
    parts = [p for p in rel.replace("\\", "/").split("/") if p]
    return Path(*parts)


def resolve_source_paths(root_dir1, root_dir2, entry):
    rel = relative_to_local_path(entry.path)
    return root_dir1 / rel, root_dir2 / rel


def find_ai_library(explicit_path=None):
    """tkai_lib_litellm.py を探す。"""
    candidates = []

    if explicit_path:
        candidates.append(Path(explicit_path).expanduser())

    script_dir = Path(__file__).resolve().parent
    cwd = Path.cwd()

    for base in (script_dir, cwd):
        candidates.append(base / "tkai_lib_litellm.py")
        candidates.append(base / "tkai_lib_litellm(5).py")

    checked = []
    for path in candidates:
        path = path.resolve()
        checked.append(str(path))
        if path.is_file():
            return path

    raise FileNotFoundError(
        "tkai_lib_litellm.py が見つかりません。\n"
        "--ai-lib PATH で指定してください。\n"
        "searched:\n  " + "\n  ".join(checked)
    )


def load_ai_library(path):
    """任意ファイル名の tkai_lib_litellm を動的に読み込む。"""
    path = Path(path).resolve()

    spec = importlib.util.spec_from_file_location(
        "tkai_lib_litellm_runtime",
        path,
    )
    if spec is None or spec.loader is None:
        raise ImportError(f"AI library を読み込めません: {path}")

    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)

    required = (
        "read_ai_config",
        "query_ai_compatible",
        "extract_text",
    )
    missing = [name for name in required if not hasattr(module, name)]
    if missing:
        raise AttributeError(
            "AI library に必要な関数がありません: "
            + ", ".join(missing)
        )

    return module


def call_ai(
    ai,
    prompt,
    model,
    provider,
    role,
    temperature=None,
    reasoning_effort=None,
):
    response = ai.query_ai_compatible(
        prompt=prompt,
        model=model,
        provider=provider,
        role=role,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )

    text = ai.extract_text(response)
    if not text:
        raise RuntimeError("生成AIからテキスト応答を取得できませんでした")

    return text.strip()


def split_source_text(text, max_chars, overlap_lines=10):
    """
    大きなソースコードを行単位で分割する。

    各chunkには少量の行overlapを持たせ、関数境界付近の文脈を失いにくくする。
    """
    if max_chars <= 0 or len(text) <= max_chars:
        return [text]

    lines = text.splitlines(keepends=True)
    chunks = []
    i = 0

    while i < len(lines):
        current = []
        size = 0
        start_i = i

        while i < len(lines):
            line = lines[i]

            if current and size + len(line) > max_chars:
                break

            current.append(line)
            size += len(line)
            i += 1

        chunks.append("".join(current))

        if i >= len(lines):
            break

        # 少し戻して次chunkとの文脈を重ねる。
        i = max(start_i + 1, i - max(0, overlap_lines))

    return chunks


def summarize_source_chunk(
    ai,
    source_text,
    path,
    version_label,
    chunk_index,
    chunk_count,
    model,
    provider,
    language,
    temperature=None,
    reasoning_effort=None,
):
    if language == "ja":
        role = (
            "あなたはソフトウェアのソースコードを正確に読む技術アナリストです。"
            "コードから確認できる内容だけを要約し、推測しません。"
        )
        prompt = f"""次のソースコードは `{path}` の {version_label} です。
chunk {chunk_index}/{chunk_count} を解析してください。

後で旧版と新版を比較するための中間要約を作ります。
このchunkについて、次を具体的に箇条書きしてください。

- 実装されている主要機能
- CLI引数・入出力・外部API
- 重要な関数・クラスと役割
- エラー処理や重要なアルゴリズム
- 他chunkとの比較に役立つ特徴

単なる一般論は書かず、コードで確認できる内容だけにしてください。

--- source: {version_label} / {path} ---
{source_text}
--- end source ---
"""
    else:
        role = (
            "You are a precise source-code analyst. "
            "Describe only behavior supported by the supplied code."
        )
        prompt = f"""Analyze chunk {chunk_index}/{chunk_count} of the {version_label}
version of `{path}`.

Create an intermediate summary for later old-vs-new comparison.
Include concrete features, CLI/API, I/O, important functions/classes,
error handling, and algorithms visible in this chunk.
Do not speculate.

--- source: {version_label} / {path} ---
{source_text}
--- end source ---
"""

    return call_ai(
        ai,
        prompt,
        model,
        provider,
        role,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )


def summarize_large_source(
    ai,
    text,
    path,
    version_label,
    chunk_chars,
    model,
    provider,
    language,
    temperature=None,
    reasoning_effort=None,
):
    chunks = split_source_text(
        text,
        max_chars=chunk_chars,
        overlap_lines=10,
    )

    summaries = []

    for i, chunk in enumerate(chunks, start=1):
        print(
            f"      {version_label} chunk {i}/{len(chunks)} "
            f"({len(chunk):,} chars)",
            flush=True,
        )

        summaries.append(
            summarize_source_chunk(
                ai=ai,
                source_text=chunk,
                path=path,
                version_label=version_label,
                chunk_index=i,
                chunk_count=len(chunks),
                model=model,
                provider=provider,
                language=language,
                temperature=temperature,
                reasoning_effort=reasoning_effort,
            )
        )

    joined = "\n\n".join(
        f"[chunk {i + 1}]\n{text}"
        for i, text in enumerate(summaries)
    )

    if language == "ja":
        role = (
            "あなたは複数chunkのソースコード要約を統合する技術編集者です。"
            "入力にない内容は追加しません。"
        )
        prompt = f"""`{path}` の {version_label} ソースコードを分割解析した結果です。

重複を除き、後で旧版・新版を比較できるように統合してください。
主要機能、CLI/API、入出力、アルゴリズム、エラー処理などの具体性を残してください。

{joined}
"""
    else:
        role = (
            "You merge chunk-level source summaries without adding unsupported facts."
        )
        prompt = f"""Merge these summaries for the {version_label} version of `{path}`.
Preserve concrete functionality, CLI/API, I/O, algorithms, and error handling
so that this can be compared with another version later.

{joined}
"""

    return call_ai(
        ai,
        prompt,
        model,
        provider,
        role,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )


def compare_full_sources(
    ai,
    entry,
    old_text,
    new_text,
    model,
    provider,
    language,
    temperature=None,
    reasoning_effort=None,
):
    """旧版・新版ソース全文を直接AIへ渡して変更点を解析する。"""
    if language == "ja":
        role = (
            "あなたはソフトウェアの変更履歴を作成する技術アナリストです。"
            "旧版と新版のソースコードを比較し、実際に変わった内容だけを報告します。"
            "コードから確認できない推測は禁止です。"
        )
        prompt = f"""同じファイルの旧版と新版を比較し、意味のある変更だけを解析してください。

対象ファイル: `{entry.path}`
更新情報: {entry.detail}

重要:
- 現在のプログラム全体の機能説明ではなく、旧版から新版への「変更点」を出す
- 行番号や単純な位置移動は変更として扱わない
- 単なる整形、空白、import順序変更は原則無視
- 新機能、動作変更、バグ修正、CLI/API変更、入出力変更を優先
- 内部リファクタリングは利用者や保守に意味がある場合だけ記載
- 旧版と新版の両方から確認できる事実だけを使う
- 2～10個程度の簡潔なMarkdown箇条書きで出力する

--- OLD SOURCE ---
{old_text}
--- END OLD SOURCE ---

--- NEW SOURCE ---
{new_text}
--- END NEW SOURCE ---
"""
    else:
        role = (
            "You are a software change analyst. Compare old and new source code "
            "and report only actual changes supported by both versions."
        )
        prompt = f"""Compare the old and new versions of `{entry.path}`.

Update metadata: {entry.detail}

Report only meaningful changes from old to new.
Ignore line-number shifts, code movement, formatting, whitespace, and import
ordering unless behavior changes. Prioritize features, behavior, bug fixes,
CLI/API, and I/O changes. Return 2-10 concise Markdown bullets.

--- OLD SOURCE ---
{old_text}
--- END OLD SOURCE ---

--- NEW SOURCE ---
{new_text}
--- END NEW SOURCE ---
"""

    return call_ai(
        ai,
        prompt,
        model,
        provider,
        role,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )


def compare_source_summaries(
    ai,
    entry,
    old_summary,
    new_summary,
    model,
    provider,
    language,
    temperature=None,
    reasoning_effort=None,
):
    """非常に大きなソースの場合、各版のソース要約を比較する。"""
    if language == "ja":
        role = (
            "あなたはソフトウェア変更履歴の技術アナリストです。"
            "旧版・新版のソースコード要約を比較し、差分だけを報告します。"
        )
        prompt = f"""`{entry.path}` の旧版・新版ソースコードを分割解析した要約です。

旧版から新版への意味のある変更だけを2～10個程度のMarkdown箇条書きにしてください。

- 共通して存在する機能はChangeLogに書かない
- 新機能、動作変更、バグ修正、CLI/API、入出力変更を優先
- 単なるコード整理は重要な場合だけ
- 要約に根拠のない変更を推測しない

--- OLD SUMMARY ---
{old_summary}
--- END OLD SUMMARY ---

--- NEW SUMMARY ---
{new_summary}
--- END NEW SUMMARY ---
"""
    else:
        role = (
            "You compare old/new source summaries and report only supported changes."
        )
        prompt = f"""Compare these summaries of `{entry.path}` and return 2-10 concise
Markdown bullets describing only meaningful changes from old to new.
Do not report functionality present in both versions.

--- OLD SUMMARY ---
{old_summary}
--- END OLD SUMMARY ---

--- NEW SUMMARY ---
{new_summary}
--- END NEW SUMMARY ---
"""

    return call_ai(
        ai,
        prompt,
        model,
        provider,
        role,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )


def analyze_updated_file(
    ai,
    entry,
    old_text,
    new_text,
    model,
    provider,
    language,
    max_direct_chars,
    source_chunk_chars,
    temperature=None,
    reasoning_effort=None,
):
    total_chars = len(old_text) + len(new_text)

    if total_chars <= max_direct_chars:
        print(
            f"    direct source comparison: "
            f"old={len(old_text):,}, new={len(new_text):,} chars",
            flush=True,
        )
        return compare_full_sources(
            ai=ai,
            entry=entry,
            old_text=old_text,
            new_text=new_text,
            model=model,
            provider=provider,
            language=language,
            temperature=temperature,
            reasoning_effort=reasoning_effort,
        )

    print(
        f"    large source: old={len(old_text):,}, new={len(new_text):,} chars",
        flush=True,
    )
    print("    analyze source chunks first...", flush=True)

    old_summary = summarize_large_source(
        ai=ai,
        text=old_text,
        path=entry.path,
        version_label="OLD",
        chunk_chars=source_chunk_chars,
        model=model,
        provider=provider,
        language=language,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )

    new_summary = summarize_large_source(
        ai=ai,
        text=new_text,
        path=entry.path,
        version_label="NEW",
        chunk_chars=source_chunk_chars,
        model=model,
        provider=provider,
        language=language,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )

    return compare_source_summaries(
        ai=ai,
        entry=entry,
        old_summary=old_summary,
        new_summary=new_summary,
        model=model,
        provider=provider,
        language=language,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )




def summarize_created_source_direct(
    ai,
    entry,
    source_text,
    model,
    provider,
    language,
    temperature=None,
    reasoning_effort=None,
):
    """
    新規作成された1ファイルのソースコードから、公開ChangeLog向けの
    短い機能説明を作る。
    """
    if language == "ja":
        role = (
            "あなたは新規公開プログラムの機能をソースコードから説明する技術編集者です。"
            "コードで確認できる内容だけを使い、推測しません。"
        )
        prompt = f"""新規作成されたプログラム `{entry.path}` のソースコードです。

公開ChangeLogの "Created" 節に載せる機能概要を作ってください。

要件:
- Markdown箇条書きのみ
- 2～4項目程度
- 1項目は1～2文程度
- 最初に、このプログラムが何をするものかを簡潔に説明する
- 主な入力、出力、CLI、主要機能が分かる場合は重要なものだけ含める
- 内部実装の細部より、利用者が何に使えるかを優先する
- コードにない内容を推測しない
- ファイル名や "新規ファイルを追加" だけで終わらせない

--- SOURCE ---
{source_text}
--- END SOURCE ---
"""
    else:
        role = (
            "You are a technical editor describing a newly added public program "
            "strictly from its source code."
        )
        prompt = f"""This is the source code of newly created program `{entry.path}`.

Write a short feature description for the ChangeLog "Created" section.

Requirements:
- Markdown bullets only
- About 2-4 bullets
- First explain what the program does
- Mention important inputs, outputs, CLI, or major features when visible
- Prefer user-facing purpose over implementation details
- Do not speculate beyond the source

--- SOURCE ---
{source_text}
--- END SOURCE ---
"""

    return call_ai(
        ai,
        prompt,
        model,
        provider,
        role,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )


def condense_created_summary(
    ai,
    entry,
    source_summary,
    model,
    provider,
    language,
    temperature=None,
    reasoning_effort=None,
):
    """大きな新規ソースの分割解析結果をChangeLog向け2～4項目へ圧縮する。"""
    if language == "ja":
        role = (
            "あなたは新規公開プログラムのChangeLog説明を簡潔に整える技術編集者です。"
            "入力要約にない内容は追加しません。"
        )
        prompt = f"""新規プログラム `{entry.path}` のソース解析要約です。

これを公開ChangeLogの "Created" 節向けに2～4個のMarkdown箇条書きへ圧縮してください。

- 最初の項目でプログラムの目的を説明
- 利用者に重要な入力・出力・CLI・主要機能を優先
- 内部実装の細部は原則省略
- 入力にない内容を推測しない

--- SUMMARY ---
{source_summary}
--- END SUMMARY ---
"""
    else:
        role = (
            "You condense a source-code summary into a short public ChangeLog description."
        )
        prompt = f"""Condense this analysis of newly created `{entry.path}` into
2-4 Markdown bullets for a public ChangeLog.

Start with the program purpose, then important user-facing inputs, outputs,
CLI, or major features. Do not add unsupported details.

--- SUMMARY ---
{source_summary}
--- END SUMMARY ---
"""

    return call_ai(
        ai,
        prompt,
        model,
        provider,
        role,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )


def analyze_created_file(
    ai,
    entry,
    source_text,
    model,
    provider,
    language,
    max_direct_chars,
    source_chunk_chars,
    temperature=None,
    reasoning_effort=None,
):
    """
    createdファイルは旧版がないため、新版ソース単体から機能概要を作る。
    """
    if len(source_text) <= max_direct_chars:
        print(
            f"    created source analysis: {len(source_text):,} chars",
            flush=True,
        )
        return summarize_created_source_direct(
            ai=ai,
            entry=entry,
            source_text=source_text,
            model=model,
            provider=provider,
            language=language,
            temperature=temperature,
            reasoning_effort=reasoning_effort,
        )

    print(
        f"    large created source: {len(source_text):,} chars",
        flush=True,
    )

    source_summary = summarize_large_source(
        ai=ai,
        text=source_text,
        path=entry.path,
        version_label="CREATED",
        chunk_chars=source_chunk_chars,
        model=model,
        provider=provider,
        language=language,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )

    return condense_created_summary(
        ai=ai,
        entry=entry,
        source_summary=source_summary,
        model=model,
        provider=provider,
        language=language,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    )


def changelog_date_heading(root1_last_updated):
    """
    root_dir1 の最終更新日時から YYYY-MM-DD を作る。
    解析できない場合は None。
    """
    if not root1_last_updated:
        return None

    m = re.match(
        r"^\s*(\d{4})/(\d{1,2})/(\d{1,2})",
        root1_last_updated,
    )
    if not m:
        return None

    year, month, day = map(int, m.groups())
    return f"{year:04d}-{month:02d}-{day:02d}"



def file_mtime_date(path):
    """ファイルの更新日時を YYYY-MM-DD で返す。"""
    dt = datetime.fromtimestamp(Path(path).stat().st_mtime)
    return f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d}"


def common_relative_suffix(old_path, new_path):
    """
    旧版・新版パスの末尾で一致する部分を表示用相対パスとして返す。

    例:
      C:/old/VASP/a.py
      D:/new/VASP/a.py
      -> VASP/a.py

    共通部分がファイル名だけならファイル名を返す。
    """
    old_parts = Path(old_path).parts
    new_parts = Path(new_path).parts

    common = []
    i = 1
    while i <= min(len(old_parts), len(new_parts)):
        a = old_parts[-i]
        b = new_parts[-i]

        # Windowsでは大小文字差を無視して比較する方が自然。
        if a.casefold() != b.casefold():
            break

        common.append(b)
        i += 1

    if not common:
        return Path(new_path).name

    common.reverse()
    return Path(*common).as_posix()


def make_created_section(
    created_entries,
    created_summaries=None,
    language="ja",
):
    """添付ChangeLog形式の # Created セクションを作る。"""
    if not created_entries:
        return ""

    created_summaries = created_summaries or {}
    lines = ["# Created"]

    for entry in created_entries:
        lines.extend(["", f"## `{entry.path}`", "", "### Created"])
        summary = created_summaries.get(entry.path, "").strip()

        if summary:
            lines.append(summary)
        elif language == "ja":
            lines.append("- 新規ファイルを追加。")
        else:
            lines.append("- New file added.")

    return "\n".join(lines)


def make_deleted_section(deleted_entries):
    """添付ChangeLog形式の # Deleted セクションを作る。"""
    if not deleted_entries:
        return ""

    lines = ["# Deleted"]
    for entry in deleted_entries:
        lines.append(f"- `{entry.path}`")

    return "\n".join(lines)


def assemble_changelog(
    changed_section="",
    created_entries=None,
    deleted_entries=None,
    created_summaries=None,
    updated_date=None,
    language="ja",
):
    """
    添付例と同じ大枠でChangeLog全体を組み立てる。

    ---
    ---

    # ChangeLog
    Updated: YYYY-MM-DD

    ---

    # Changed
    ...

    ---

    # Created
    ...

    ---

    # Deleted
    ...
    """
    created_entries = created_entries or []
    deleted_entries = deleted_entries or []

    parts = [
        "---",
        "---",
        "",
        "# ChangeLog",
    ]

    if updated_date:
        parts.append(f"Updated: {updated_date}")

    sections = []

    if changed_section and changed_section.strip():
        sections.append(changed_section.strip())

    created_section = make_created_section(
        created_entries,
        created_summaries=created_summaries,
        language=language,
    )
    if created_section:
        sections.append(created_section)

    deleted_section = make_deleted_section(deleted_entries)
    if deleted_section:
        sections.append(deleted_section)

    for section in sections:
        parts.extend(["", "---", "", section])

    return "\n".join(parts).rstrip() + "\n"


def initialize_ai(args):
    """AIライブラリ・設定・provider/modelを初期化する。"""
    ai_lib_path = find_ai_library(args.ai_lib)
    print(f"AI library: {ai_lib_path}")

    ai = load_ai_library(ai_lib_path)
    ai.read_ai_config(args.config)

    provider = (
        args.provider
        or os.getenv("AI_PROVIDER")
        or os.getenv("provider")
        or "openai"
    )
    model = (
        args.model
        or os.getenv("AI_MODEL")
        or os.getenv("model")
        or "gpt-4o-mini"
    )

    print(f"provider: {provider}")
    print(f"model   : {model}")
    print()

    return ai, provider, model


def make_machine_sections(
    created_entries,
    deleted_entries,
    created_summaries=None,
    language="ja",
):
    """
    created / deleted をファイル単位でMarkdown化する。

    created:
      AI解析済みの機能概要があれば掲載する。
    deleted:
      AI解析せず、削除したことだけを機械的に掲載する。
    """
    created_summaries = created_summaries or {}
    sections = []

    for entry in created_entries:
        summary = created_summaries.get(entry.path, "").strip()

        if summary:
            body = f"### Created\n{summary}"
        else:
            body = (
                "### Created\n- 新規ファイルを追加。"
                if language == "ja"
                else "### Created\n- New file added."
            )

        sections.append(f"## `{entry.path}`\n\n{body}")

    for entry in deleted_entries:
        body = (
            "### Deleted\n- ファイルを削除。"
            if language == "ja"
            else "### Deleted\n- File removed."
        )
        sections.append(f"## `{entry.path}`\n\n{body}")

    return "\n\n".join(sections)


def append_machine_sections(
    changelog,
    created_entries,
    deleted_entries,
    created_summaries=None,
    root1_last_updated=None,
    language="ja",
):
    """
    AI生成ChangeLogへ created/deleted のファイル単位節を機械的に追加する。

    ## 見出しは相対ファイルパス専用とし、更新日は
    "Updated: YYYY-MM-DD" として # ChangeLog の直下に置く。
    """
    machine = make_machine_sections(
        created_entries,
        deleted_entries,
        created_summaries=created_summaries,
        language=language,
    )

    date_heading = changelog_date_heading(root1_last_updated)

    if changelog and changelog.strip():
        base = changelog.strip()
        if date_heading:
            lines = base.splitlines()
            if lines and lines[0].strip() == "# ChangeLog":
                has_updated = any(
                    line.strip().lower().startswith("updated:")
                    for line in lines[1:5]
                )
                if not has_updated:
                    lines[1:1] = ["", f"Updated: {date_heading}", ""]
                base = "\n".join(lines)
    else:
        lines = ["# ChangeLog"]
        if date_heading:
            lines.extend(["", f"Updated: {date_heading}"])
        base = "\n".join(lines)

    if not machine:
        return base + "\n"

    return base + "\n\n" + machine + "\n"


def make_final_changelog(
    ai,
    file_summaries,
    root1_last_updated,
    model,
    provider,
    language,
    temperature=None,
    reasoning_effort=None,
):
    """
    updatedファイルの解析結果から、添付形式の # Changed セクションだけを作る。
    """
    body = "\n\n".join(
        f"FILE: {path}\n{summary}"
        for path, summary in file_summaries
    )

    if language == "ja":
        role = (
            "あなたは公開ソフトウェアのChangeLogを作成する技術編集者です。"
            "入力された変更解析だけを根拠にし、推測しません。"
        )

        prompt = f"""以下は updated ファイルごとの旧版・新版比較結果です。
添付ChangeLog形式の Changed セクションをMarkdownで作成してください。

出力形式を厳守してください:

# Changed

## `relative/path/to/file.py`
### Added
- ...

### Changed
- ...

### Fixed
- ...

### Internal
- ...

要件:
- 出力は "# Changed" から開始する。
- "# ChangeLog" や日付は出力しない。
- 各 `##` 見出しには必ず1個の相対ファイルパスを書く。
- 複数ファイルを同じ `##` にまとめない。
- 各ファイル内では必要な `### Added`, `### Changed`,
  `### Fixed`, `### Internal` だけを使う。
- 利用者から見た意味のある変更を優先する。
- created / deleted はここには含めない。
- 入力に根拠のない変更を追加しない。
- 前置き説明やコードフェンスは不要。

--- analyses ---
{body}
--- end analyses ---
"""
    else:
        role = (
            "You are a technical editor writing a public software changelog "
            "strictly from supplied old-vs-new source analyses."
        )

        prompt = f"""Create only the Changed section of a public Markdown changelog.

Required format:

# Changed

## `relative/path/to/file.py`
### Added
- ...

### Changed
- ...

### Fixed
- ...

### Internal
- ...

Requirements:
- Start with "# Changed".
- Do not output "# ChangeLog" or a date.
- One relative file path per `##` heading.
- Never combine multiple files under one `##`.
- Use only relevant Added / Changed / Fixed / Internal subsections.
- Do not include created/deleted files or unsupported claims.
- No introductory prose or code fences.

--- analyses ---
{body}
--- end analyses ---
"""

    text = call_ai(
        ai,
        prompt,
        model,
        provider,
        role,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
    ).strip()

    # 軽い保険: Markdownコードフェンスが付いた場合だけ除去。
    if text.startswith("```") and text.endswith("```"):
        lines = text.splitlines()
        if lines:
            lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()

    if not text.startswith("# Changed"):
        text = "# Changed\n\n" + text

    return text


def main():
    parser = argparse.ArgumentParser(
        description=(
            "Create a Markdown ChangeLog either from one compare-update log "
            "or by directly comparing OLD_FILE and NEW_FILE."
        )
    )

    parser.add_argument(
        "inputs",
        nargs="+",
        help=(
            "One argument: compare_update_diff.py log. "
            "Two arguments: OLD_FILE NEW_FILE."
        ),
    )
    parser.add_argument(
        "-o", "--output",
        default="CHANGELOG.md",
        help='Output Markdown file (default: "CHANGELOG.md")'
    )
    parser.add_argument(
        "--root-dir1",
        default=None,
        help="Override root_dir1 written in the log (log mode only)"
    )
    parser.add_argument(
        "--root-dir2",
        default=None,
        help="Override root_dir2 written in the log (log mode only)"
    )
    parser.add_argument(
        "--ai-lib",
        default=None,
        help="Path to tkai_lib_litellm.py"
    )
    parser.add_argument(
        "--config",
        default="translate.env",
        help='AI config env file (default: "translate.env")'
    )
    parser.add_argument(
        "--provider",
        default=None,
        help='AI provider, e.g. "openai" or "gemini"'
    )
    parser.add_argument(
        "--model",
        default=None,
        help="Model name. If omitted, AI_MODEL env or gpt-4o-mini is used"
    )
    parser.add_argument(
        "--language",
        choices=("ja", "en"),
        default="ja",
        help='ChangeLog language (default: "ja")'
    )
    parser.add_argument(
        "--max-direct-chars",
        type=int,
        default=200000,
        help=(
            "Maximum combined old+new source characters for one direct AI "
            "comparison (default: 200000)"
        )
    )
    parser.add_argument(
        "--source-chunk-chars",
        type=int,
        default=40000,
        help=(
            "Approximate source characters per chunk for very large files "
            "(default: 40000)"
        )
    )
    parser.add_argument(
        "--temperature",
        type=float,
        default=None,
        help="Optional AI temperature. Default: do not send"
    )
    parser.add_argument(
        "--reasoning-effort",
        default=None,
        choices=("minimal", "low", "medium", "high"),
        help="Optional reasoning_effort for compatible models"
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show source paths without calling AI"
    )

    args = parser.parse_args()

    if len(args.inputs) not in (1, 2):
        parser.error(
            "Specify either one log file, or two source files: OLD_FILE NEW_FILE"
        )

    # =====================================================================
    # Direct source comparison mode: OLD_FILE NEW_FILE
    # =====================================================================
    if len(args.inputs) == 2:
        old_path = Path(args.inputs[0]).expanduser().resolve()
        new_path = Path(args.inputs[1]).expanduser().resolve()

        if not old_path.is_file():
            parser.error(f"OLD_FILE is not found: {old_path}")
        if not new_path.is_file():
            parser.error(f"NEW_FILE is not found: {new_path}")

        display_path = common_relative_suffix(old_path, new_path)
        updated_date = file_mtime_date(new_path)

        print("mode     : direct source comparison")
        print(f"OLD      : {old_path}")
        print(f"NEW      : {new_path}")
        print(f"path     : {display_path}")
        print(f"Updated  : {updated_date}")

        if args.dry_run:
            return 0

        ai, provider, model = initialize_ai(args)

        old_text, old_enc = read_text_file(old_path)
        new_text, new_enc = read_text_file(new_path)

        print(f"OLD source: {len(old_text):,} chars, {old_enc}")
        print(f"NEW source: {len(new_text):,} chars, {new_enc}")

        entry = UpdateEntry(
            path=display_path,
            detail=f"direct comparison; new file updated on {updated_date}",
        )

        summary = analyze_updated_file(
            ai=ai,
            entry=entry,
            old_text=old_text,
            new_text=new_text,
            model=model,
            provider=provider,
            language=args.language,
            max_direct_chars=max(10000, args.max_direct_chars),
            source_chunk_chars=max(5000, args.source_chunk_chars),
            temperature=args.temperature,
            reasoning_effort=args.reasoning_effort,
        )

        print()
        print(summary)
        print()

        changed_section = make_final_changelog(
            ai=ai,
            file_summaries=[(entry.path, summary)],
            root1_last_updated=None,
            model=model,
            provider=provider,
            language=args.language,
            temperature=args.temperature,
            reasoning_effort=args.reasoning_effort,
        )

        changelog = assemble_changelog(
            changed_section=changed_section,
            updated_date=updated_date,
            language=args.language,
        )

        output_path = Path(args.output).expanduser()
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(changelog, encoding="utf-8")

        print(changelog)
        print(f"Saved: {output_path.resolve()}")
        return 0

    # =====================================================================
    # Log mode: update.log
    # =====================================================================
    log_path = Path(args.inputs[0]).expanduser().resolve()
    if not log_path.is_file():
        parser.error(f"log file is not found: {log_path}")

    log_text, log_encoding = read_text_file(log_path)
    info = parse_update_log(log_text)

    root1_text = args.root_dir1 or info["root_dir1"]
    root2_text = args.root_dir2 or info["root_dir2"]

    if not root1_text:
        parser.error("root_dir1 was not found in the log; use --root-dir1")
    if not root2_text:
        parser.error("root_dir2 was not found in the log; use --root-dir2")

    root_dir1 = Path(root1_text).expanduser()
    root_dir2 = Path(root2_text).expanduser()

    entries = info["updated"]

    print("mode     : update-log")
    print(f"logfile  : {log_path}")
    print(f"encoding : {log_encoding}")
    print(f"root_dir1: {root_dir1}")
    print(f"root_dir2: {root_dir2}")
    print(f"updated  : {len(entries)} files")
    print(f"created  : {len(info['created'])} files (AI feature summary)")
    print(f"deleted  : {len(info['deleted'])} files (AI analysis skipped)")

    resolved = []
    errors = []

    for entry in entries:
        new_path, old_path = resolve_source_paths(
            root_dir1,
            root_dir2,
            entry,
        )

        exists_new = new_path.is_file()
        exists_old = old_path.is_file()

        if not exists_new or not exists_old:
            errors.append(
                (entry, new_path, old_path, exists_new, exists_old)
            )

        resolved.append(
            (entry, new_path, old_path, exists_new, exists_old)
        )

    created_resolved = []
    created_errors = []

    for entry in info["created"]:
        rel = relative_to_local_path(entry.path)
        new_path = root_dir1 / rel
        exists_new = new_path.is_file()

        if not exists_new:
            created_errors.append((entry, new_path))

        created_resolved.append((entry, new_path, exists_new))

    if args.dry_run:
        print()
        if resolved:
            print("Updated files:")
        for i, (entry, new_path, old_path, exists_new, exists_old) in enumerate(
            resolved,
            start=1,
        ):
            print(f"[{i}/{len(resolved)}] {entry.path}")
            print(f"  OLD: {old_path}  [{'OK' if exists_old else 'NOT FOUND'}]")
            print(f"  NEW: {new_path}  [{'OK' if exists_new else 'NOT FOUND'}]")

        if info["created"]:
            print()
            print("Created files:")
            for i, (entry, new_path, exists_new) in enumerate(
                created_resolved,
                start=1,
            ):
                print(f"[{i}/{len(created_resolved)}] {entry.path}")
                print(f"  NEW: {new_path}  [{'OK' if exists_new else 'NOT FOUND'}]")

        if info["deleted"]:
            print()
            print("Deleted files:")
            for entry in info["deleted"]:
                print(f"  {entry.path}")

        if errors:
            print()
            print(f"Warning: {len(errors)} updated file(s) are incomplete.")
        if created_errors:
            print(
                f"Warning: {len(created_errors)} created file(s) "
                "were not found in root_dir1."
            )
        return 0

    if errors:
        print()
        print("Error: updated file(s) are missing:")
        for entry, new_path, old_path, exists_new, exists_old in errors:
            print(f"  {entry.path}")
            if not exists_old:
                print(f"    OLD not found: {old_path}")
            if not exists_new:
                print(f"    NEW not found: {new_path}")
        print()
        print("Use --root-dir1/--root-dir2 if the log paths have moved.")
        return 2

    if created_errors:
        print()
        print(
            "Warning: created source file(s) not found; "
            "generic Created entry will be used:"
        )
        for entry, new_path in created_errors:
            print(f"  {entry.path}: {new_path}")
        print()

    readable_created = [
        item for item in created_resolved if item[2]
    ]

    # AI不要なら機械的にChangeLogだけ作る。
    if not entries and not readable_created:
        changelog = assemble_changelog(
            changed_section="",
            created_entries=info["created"],
            deleted_entries=info["deleted"],
            created_summaries={},
            updated_date=changelog_date_heading(info["root1_last_updated"]),
            language=args.language,
        )

        output_path = Path(args.output).expanduser()
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(changelog, encoding="utf-8")

        print(changelog)
        print(f"Saved: {output_path.resolve()}")
        return 0

    ai, provider, model = initialize_ai(args)

    file_summaries = []

    for i, (entry, new_path, old_path, _, _) in enumerate(resolved, start=1):
        print(f"[{i}/{len(resolved)}] {entry.path}", flush=True)

        old_text, old_enc = read_text_file(old_path)
        new_text, new_enc = read_text_file(new_path)

        print(f"    OLD: {len(old_text):,} chars, {old_enc}", flush=True)
        print(f"    NEW: {len(new_text):,} chars, {new_enc}", flush=True)

        summary = analyze_updated_file(
            ai=ai,
            entry=entry,
            old_text=old_text,
            new_text=new_text,
            model=model,
            provider=provider,
            language=args.language,
            max_direct_chars=max(10000, args.max_direct_chars),
            source_chunk_chars=max(5000, args.source_chunk_chars),
            temperature=args.temperature,
            reasoning_effort=args.reasoning_effort,
        )

        file_summaries.append((entry.path, summary))

        print(summary)
        print()

    created_summaries = {}
    readable_created = [
        (entry, new_path)
        for entry, new_path, exists_new in created_resolved
        if exists_new
    ]

    if readable_created:
        print("Analyzing created files...", flush=True)

    for i, (entry, new_path) in enumerate(readable_created, start=1):
        print(
            f"[created {i}/{len(readable_created)}] {entry.path}",
            flush=True,
        )

        new_text, new_enc = read_text_file(new_path)
        print(f"    NEW: {len(new_text):,} chars, {new_enc}", flush=True)

        summary = analyze_created_file(
            ai=ai,
            entry=entry,
            source_text=new_text,
            model=model,
            provider=provider,
            language=args.language,
            max_direct_chars=max(10000, args.max_direct_chars),
            source_chunk_chars=max(5000, args.source_chunk_chars),
            temperature=args.temperature,
            reasoning_effort=args.reasoning_effort,
        )

        created_summaries[entry.path] = summary

        print(summary)
        print()

    print("Generating final ChangeLog...", flush=True)

    if file_summaries:
        changed_section = make_final_changelog(
            ai=ai,
            file_summaries=file_summaries,
            root1_last_updated=info["root1_last_updated"],
            model=model,
            provider=provider,
            language=args.language,
            temperature=args.temperature,
            reasoning_effort=args.reasoning_effort,
        )
    else:
        changed_section = ""

    changelog = assemble_changelog(
        changed_section=changed_section,
        created_entries=info["created"],
        deleted_entries=info["deleted"],
        created_summaries=created_summaries,
        updated_date=changelog_date_heading(info["root1_last_updated"]),
        language=args.language,
    )

    output_path = Path(args.output).expanduser()
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(changelog, encoding="utf-8")

    print()
    print(changelog)
    print(f"Saved: {output_path.resolve()}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
