#!/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()
    を使用する。

例:
    python changelog_from_update_log.py update.log
    python changelog_from_update_log.py update.log -o CHANGELOG.md

    python changelog_from_update_log.py update.log \
        --provider openai --model gpt-5.4-mini

    python changelog_from_update_log.py update.log \
        --root-dir1 D:\\git\\tkProg\\tkprog_COE \
        --root-dir2 \\\\server\\share\\tkprog_COE

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

import argparse
import importlib.util
import os
import re
import sys
from dataclasses import dataclass
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 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,
):
    body = "\n\n".join(
        f"FILE: {path}\n{summary}"
        for path, summary in file_summaries
    )

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

        prompt = f"""以下は updated ファイルごとの、旧版と新版の比較結果です。
公開用Markdown ChangeLogを作成してください。

出力形式:

# ChangeLog

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

### Added
- ...

### Changed
- ...

### Fixed
- ...

### Internal
- ...

厳守事項:
- Markdownのみを出力する。
- 先頭は必ず "# ChangeLog"。
- 日付は出力しない。後段で機械的に挿入する。
- 各 `##` 見出しには、必ず1個の相対ファイルパスをバッククォート付きで書く。
- `## Added` や `## Changed` のようなカテゴリ見出しは禁止。
- 複数ファイルを同じ `##` セクションにまとめない。
- 入力された各 updated ファイルについて、必ず独立した `##` セクションを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 a public Markdown changelog from these per-file
old-vs-new source analyses.

Use this structure:

# ChangeLog

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

### Added
- ...

### Changed
- ...

### Fixed
- ...

### Internal
- ...

Requirements:
- Output Markdown only.
- Start with "# ChangeLog".
- Do not output a date; it is inserted mechanically later.
- Every `##` heading must contain exactly one relative file path in backticks.
- Do not use category names such as Added or Changed as `##` headings.
- Never combine multiple files under one `##` section.
- Create exactly one independent `##` section for every updated file in the input.
- Within a file section, use only relevant `### Added`, `### Changed`,
  `### Fixed`, and `### Internal` subsections.
- Prefer user-visible changes.
- Created/deleted entries are appended mechanically later; do not add them.
- Do not add unsupported changes or introductory prose.

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

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


def main():
    parser = argparse.ArgumentParser(
        description=(
            "Read updated file paths from compare_update_diff.py log, "
            "compare old/new source files with generative AI, "
            "and create a Markdown ChangeLog."
        )
    )

    parser.add_argument(
        "logfile",
        help="compare_update_diff.py output log"
    )
    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"
    )
    parser.add_argument(
        "--root-dir2",
        default=None,
        help="Override root_dir2 written in the log"
    )
    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 updated/created source paths without calling AI"
    )

    args = parser.parse_args()

    log_path = Path(args.logfile).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(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)")

    # Resolve all source paths first.
    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,
            )
        )

    # Resolve created source paths (root_dir1 only).
    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()
        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 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()

    # If neither updated nor readable created files need AI, create ChangeLog directly.
    readable_created = [
        item for item in created_resolved
        if item[2]
    ]

    if not entries and not readable_created:
        changelog = append_machine_sections(
            changelog="",
            created_entries=info["created"],
            deleted_entries=info["deleted"],
            created_summaries={},
            root1_last_updated=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_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()

    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 = {}

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

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

    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:
        ai_changelog = 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:
        ai_changelog = ""

    changelog = append_machine_sections(
        changelog=ai_changelog,
        created_entries=info["created"],
        deleted_entries=info["deleted"],
        created_summaries=created_summaries,
        root1_last_updated=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())
