#!/usr/bin/env python3
"""Pixels to Motors データの索引を作り直す。

置き場所（MASTER 保存先テーブル）: iCloud PixelstoMotors/_index/
番号の決まり: data_manifest_rules.md 11節

書き出すもの（_index/ の中だけ。元データは読むだけ）:
  INDEX.md                 入口（知りたいこと → 開く場所）・関係の要約・古くなった説明の一覧
  data/relations.csv       記事 ↔ KIOXIA のデータ（記事の作業ファイル本文に書かれた参照だけ。確定）
  data/candidates.csv      名前の番号だけで結びつく候補（確定ではない）
  data/build_log.json      実行の記録（いつ・どこを見たか・見られなかったもの）

入力: data/published_articles.csv（公開記事の一覧。tools/export_published.py で p2m-site から書き出す）

守ること:
  - 書き込みは _index/ の中だけ。一時ファイルから os.replace で差し替える
  - KIOXIA がつながっていなければ、関係表と候補は前回のものを残し、INDEX.md に「未接続」と書く
  - KIOXIA で見るのは r2r2r-data の中だけ。archive/ は見ない。ファイルの中身は README の文字と台帳の行数・列数だけ読む
  - パスに .. を含む参照は扱わない

使い方（Mac）: python3 build_index.py
  ほかの場所で回すときは --icloud / --kioxia / --scripts でパスを渡す
"""
import argparse, csv, datetime as dt, json, os, re, tempfile
from collections import defaultdict
from pathlib import Path

HOME = Path.home()
DEF_ICLOUD = HOME / 'Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors'
DEF_KIOXIA = Path('/Volumes/KIOXIA/r2r2r-data')
DEF_SCRIPTS = HOME / 'r2r2r-scripts'

# 表示用のパス（Mac での書き方）
SHOW = {'I': '~/…/PixelstoMotors', 'K': '/Volumes/KIOXIA/r2r2r-data', 'C': '~/r2r2r-scripts'}

# KIOXIA の上の階層（保存先テーブルにあるもの）。archive は参照の対象にしない
K_TOPS = ['runs', 'lab', 'datasets', 'checkpoints', 'recordings', 'vision', 'mecharm_calibration',
          'diag', 'service_sim', 'videos', 'fk_tables']
REF_RE = re.compile(r'(?<![A-Za-z0-9_])(?:/Volumes/KIOXIA/r2r2r-data/|r2r2r-data/)?((?:' + '|'.join(K_TOPS) +
                    r')/[A-Za-z0-9_\-./]+)')
ART_FILE_RE = re.compile(r'^(LOG|DAY)(\d+)(?:_|\.|$)')
NAME_NO_RE = re.compile(r'(?:LOG|DAY)(\d{2,3})(?!\d)')
LOGDIR_RE = re.compile(r'^Log(\d+)$')
# シリーズ内の番号でフォルダを切っている場所 → 公開記事のシリーズ名
SERIES_DIRS = {'lab': 'フィジカルAI研究室', 'vision/generated': '画像認識・物体検出',
               'vision/captured': '画像認識・物体検出', 'vision/measurements': '画像認識・物体検出'}


try:
    from zoneinfo import ZoneInfo
    JST = ZoneInfo('Asia/Tokyo')
except Exception:  # zoneinfo がない環境
    JST = dt.timezone(dt.timedelta(hours=9))


def now():
    return dt.datetime.now(JST)


def mdate(p):
    try:
        return dt.datetime.fromtimestamp(p.stat().st_mtime, JST).strftime('%Y-%m-%d')
    except OSError:
        return ''


def write_atomic(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.tmp_')
    with os.fdopen(fd, 'w', encoding='utf-8', newline='') as f:
        f.write(text)
    os.replace(tmp, path)


def write_csv(path, rows, fields):
    import io
    buf = io.StringIO()
    w = csv.DictWriter(buf, fieldnames=fields)
    w.writeheader()
    w.writerows(rows)
    write_atomic(path, buf.getvalue())


def load_published(path):
    arts = {}
    with open(path, encoding='utf-8') as f:
        for r in csv.DictReader(f):
            arts[int(r['article_no'])] = r
    return arts


def article_files(icloud):
    """記事の作業ファイル（LOG{N}*.md / DAY{N}*.md）を記事番号ごとに集める。"""
    root = icloud / 'デジタルツイン学習R2R2R'
    out = defaultdict(list)
    for dirpath, dirnames, files in os.walk(root):
        dirnames[:] = [d for d in dirnames if not d.startswith('.') and not d.endswith('_images')]
        for fn in files:
            m = ART_FILE_RE.match(fn)
            if m and fn.endswith('.md'):
                out[int(m.group(2))].append(Path(dirpath) / fn)
    return out


def resolve_ref(kioxia, ref):
    """本文の参照を KIOXIA の実在するフォルダ/ファイルに当てる。上から3段まで、在る所までを返す。"""
    ref = ref.rstrip('./-_')
    parts = [p for p in ref.split('/') if p]
    if not parts or '..' in parts or parts[0] not in K_TOPS:
        return None, False
    for n in range(min(3, len(parts)), 1, -1):
        if (kioxia / Path(*parts[:n])).exists():
            return '/'.join(parts[:n]), True
    return '/'.join(parts[:3]), False


def build_relations(icloud, kioxia, files_by_no, arts):
    rel = {}
    for no, files in sorted(files_by_no.items()):
        for fp in files:
            try:
                lines = fp.read_text(encoding='utf-8', errors='replace').splitlines()
            except OSError:
                continue
            for i, line in enumerate(lines, 1):
                for m in REF_RE.finditer(line):
                    if line[m.end():m.end() + 1] == '{':  # teach_poses_LOG{NNN} のような書き方の見本
                        continue
                    target, ok = resolve_ref(kioxia, m.group(1))
                    if not target:
                        continue
                    key = (no, target)
                    if key in rel:
                        continue
                    a = arts.get(no, {})
                    rel[key] = {
                        'article_no': f'LOG{no}', 'series': a.get('series', ''),
                        'series_log': a.get('series_log', ''), 'published': 'yes' if a else 'no',
                        'target': f'r2r2r-data/{target}', 'exists': 'yes' if ok else 'no',
                        'status': '確定' if ok else '参照先なし',
                        'relation': '本文で参照',
                        'evidence': f'{fp.relative_to(icloud)}:{i}',
                    }
    return sorted(rel.values(), key=lambda r: (int(r['article_no'][3:]), r['target']))


def build_candidates(kioxia, arts, confirmed):
    """名前の番号だけで結びつく候補。確定の関係にすでにあるものは除く。"""
    done = {(r['article_no'], r['target']) for r in confirmed}
    by_series_log = defaultdict(list)
    for no, a in arts.items():
        if a.get('series_log'):
            by_series_log[(a['series'], int(a['series_log']))].append(no)
    out = []

    def add(no, target, why):
        k = (f'LOG{no}' if no else '', f'r2r2r-data/{target}')
        if k in done:
            return
        done.add(k)
        a = arts.get(no, {}) if no else {}
        out.append({'article_no': k[0], 'series': a.get('series', ''), 'series_log': a.get('series_log', ''),
                    'published': 'yes' if a else ('no' if no else ''), 'target': k[1], 'status': '候補', 'why': why})

    # 1) シリーズ内の番号のフォルダ（lab/Log10 など）× 公開記事のシリーズとLog番号
    for sub, series in SERIES_DIRS.items():
        d = kioxia / sub
        if not d.is_dir():
            continue
        for e in sorted(d.iterdir()):
            m = LOGDIR_RE.match(e.name)
            if not (m and e.is_dir()):
                continue
            nos = by_series_log.get((series, int(m.group(1))), [])
            if len(nos) == 1:
                add(nos[0], f'{sub}/{e.name}', f'シリーズ「{series}」の Log#{int(m.group(1))} と同じ番号')
            else:
                add(None, f'{sub}/{e.name}', f'シリーズ「{series}」の Log#{int(m.group(1))} の公開記事が{"ない" if not nos else "複数ある"}')
    # 2) フォルダ名に記事番号（LOG352・DAY281 など）を含むもの。2段目まで見る
    for top in K_TOPS:
        d = kioxia / top
        if not d.is_dir():
            continue
        for e in sorted(d.iterdir()):
            if e.name.startswith(('.', '._')):
                continue
            m = NAME_NO_RE.search(e.name)
            if m and e.is_dir():
                add(int(m.group(1)), f'{top}/{e.name}', 'フォルダ名の記事番号')
    return sorted(out, key=lambda r: (int(r['article_no'][3:] or 0), r['target']))


def ledger_shape(p):
    try:
        with open(p, encoding='utf-8', newline='') as f:
            rows = list(csv.reader(f))
        return len(rows) - 1, len(rows[0]) if rows else 0
    except (OSError, UnicodeDecodeError):
        return None


def freshness(icloud, kioxia, scripts, k_ok, arts):
    notes = []
    if k_ok:
        readme = kioxia / 'README.md'
        if readme.exists():
            text = readme.read_text(encoding='utf-8', errors='replace')
            dirs = sorted(e.name for e in kioxia.iterdir() if e.is_dir() and not e.name.startswith('.'))
            missing = [d for d in dirs if f'{d}/' not in text]
            if missing:
                notes.append(f'`{SHOW["K"]}/README.md`（更新 {mdate(readme)}）に載っていないフォルダ: ' +
                             '、'.join(f'`{d}/`' for d in missing))
        rr = kioxia / 'runs/README.md'
        sh = ledger_shape(kioxia / 'runs/pick_place_ledger_v3.csv')
        if rr.exists() and sh:
            notes.append(f'`{SHOW["K"]}/runs/README.md`（更新 {mdate(rr)}）の台帳の説明と見比べる: '
                         f'`pick_place_ledger_v3.csv` は今 {sh[0]} 行・{sh[1]} 列（列の正は data_manifest_rules.md）')
    pub_max = max(arts) if arts else 0
    sa = icloud / 'site_archive/README.md'
    if sa.exists():
        notes.append(f'`site_archive/` は {mdate(sa)} 時点の写し（公開記事は今 LOG{pub_max} まで）。現行の記事一覧には `_index/data/published_articles.csv` を使う')
    wa = scripts / 'WORKING_ASSETS.md'
    if wa.exists():
        notes.append(f'`{SHOW["C"]}/WORKING_ASSETS.md` の更新は {mdate(wa)}。それ以降のコードは載っていない')
    return notes


ENTRY = [
    # (知りたいこと, 根, 相対パス, 補足)
    ('置き場所の決まり（種類 → 場所）', 'I', 'マスターMD/MASTER.md', '正は MASTER。台帳の列・番号の決まりは Project の data_manifest_rules.md'),
    ('公開記事の一覧（記事番号・シリーズ・Log番号）', 'I', '_index/data/published_articles.csv', '公開記事（p2m-site）から書き出したもの。シリーズと番号の正'),
    ('記事 ↔ データの関係', 'I', '_index/data/relations.csv', '記事の作業ファイルに書かれた参照だけ。名前だけの候補は candidates.csv'),
    ('記事の作業ファイル', 'I', 'デジタルツイン学習R2R2R', 'シリーズ名のフォルダ。単体記事は 記事/'),
    ('2026-10-12 までの公開サイト一式', 'I', 'site_archive/README.md', '取得した時点の写し'),
    ('調査メモ・論文', 'I', 'research/README.md', ''),
    ('ロボット動画の観察台帳', 'I', 'research/robot_video_ledger.md', ''),
    ('課題地図', 'I', 'physical-ai-todo/README.md', ''),
    ('Claude Code への依頼と報告', 'I', '依頼箱/README.md', ''),
    ('SNS の分析データ', 'I', 'sns_analytics', ''),
    ('データの置き方（KIOXIA）', 'K', 'README.md', '古い。下の「古くなった説明」を見る'),
    ('実機の通し試行（PICK→PLACE）', 'K', 'runs/pick_place_ledger_v3.csv', '1試行1行の台帳。試行ごとの中身は runs/run_pick_place/'),
    ('受け渡しの通し試行', 'K', 'runs/handover_ledger_v1.csv', '試行ごとの中身は runs/run_handover/'),
    ('実機の単体実験', 'K', 'runs/experiments', '実験ごとの索引 CSV がある場合あり'),
    ('学習用データセット', 'K', 'datasets', '各フォルダの index.json（使った試行・used・reason）と README（元と処理）'),
    ('学習済みモデル', 'K', 'checkpoints', 'モデルの中に学習の記録はない。由来は lab/Log{NN}/README.md と関係表で引く'),
    ('学習・推論の記録（フィジカルAI研究室）', 'K', 'lab', 'Log{NN} はシリーズ内の番号'),
    ('画像認識シリーズの画像と測定', 'K', 'vision', 'Log{NN} はシリーズ内の番号（研究室の Log とは別）'),
    ('校正の採用・不採用', 'K', 'mecharm_calibration/README.md', '各フォルダの README・_LEDGER.md・_INDEX'),
    ('録画', 'K', 'recordings', ''),
    ('サービスロボット導入シミュレーション（Unity）', 'K', 'service_sim/unity/unity_ledger_v1.csv', '1回1行の台帳'),
    ('解説動画', 'K', 'videos', ''),
    ('動くコードの一覧', 'C', 'WORKING_ASSETS.md', '古い。下の「古くなった説明」を見る'),
    ('学習前のデータセット点検', 'C', 'pi05/check_dataset.py', '読むだけの点検'),
    ('この索引の検証報告（Codex）', 'I', '_index_review/codex_report_20261101.md', ''),
]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--icloud', default=str(DEF_ICLOUD))
    ap.add_argument('--kioxia', default=str(DEF_KIOXIA))
    ap.add_argument('--scripts', default=str(DEF_SCRIPTS))
    a = ap.parse_args()
    icloud, kioxia, scripts = Path(a.icloud), Path(a.kioxia), Path(a.scripts)
    out = icloud / '_index'
    data = out / 'data'
    started = now()
    log = {'started': started.isoformat(timespec='seconds'), 'roots': {}, 'errors': []}

    if not icloud.is_dir():
        raise SystemExit(f'iCloud のフォルダが見つからない: {icloud}')
    k_ok = (kioxia / 'runs').is_dir()
    roots = {'I': icloud, 'K': kioxia, 'C': scripts}
    log['roots'] = {k: ('ok' if (v.is_dir() if k != 'K' else k_ok) else 'unavailable') for k, v in roots.items()}

    pub = data / 'published_articles.csv'
    arts = load_published(pub) if pub.exists() else {}
    if not arts:
        log['errors'].append('published_articles.csv がない（tools/export_published.py で書き出す）')

    files_by_no = article_files(icloud)
    rel_rows = cand_rows = None
    if k_ok:
        rel_rows = build_relations(icloud, kioxia, files_by_no, arts)
        cand_rows = build_candidates(kioxia, arts, rel_rows)
        write_csv(data / 'relations.csv', rel_rows,
                  ['article_no', 'series', 'series_log', 'published', 'target', 'exists', 'status', 'relation', 'evidence'])
        write_csv(data / 'candidates.csv', cand_rows,
                  ['article_no', 'series', 'series_log', 'published', 'target', 'status', 'why'])
    else:
        log['errors'].append('KIOXIA 未接続：relations.csv と candidates.csv は前回のまま')

    # ---- INDEX.md ----
    L = []
    L.append('# Pixels to Motors データの索引')
    L.append('')
    L.append(f'作り直した日時: {started.strftime("%Y-%m-%d %H:%M")} JST（`_index/tools/build_index.py`。手で書き換えない）')
    L.append('')
    st = {'ok': 'つながっている', 'unavailable': '**未接続（この回は見ていない）**'}
    L.append('見た場所: ' + ' ／ '.join(f'{SHOW[k]} … {st[v]}' for k, v in log['roots'].items()))
    L.append('')
    L.append('番号の読み方: 記事番号は DAY と LOG で同じ通し番号。`Log{NN}` はシリーズ内の番号で、シリーズ名と組で読む（data_manifest_rules.md 11節）。')
    L.append('')
    L.append('## 知りたいこと → 開く場所')
    L.append('')
    L.append('| 知りたいこと | 開く場所 | 更新 | 補足 |')
    L.append('|---|---|---|---|')
    for what, r, rel, note in ENTRY:
        p = roots[r] / rel
        if r == 'K' and not k_ok:
            upd = '未接続'
        elif p.exists():
            upd = mdate(p)
        else:
            upd = '**見つからない**'
        if r == 'I':
            link = rel[len('_index/'):] if rel.startswith('_index/') else f'../{rel}'
            shown = f'[{rel}]({link.replace(" ", "%20")})'
        else:
            shown = f'`{SHOW[r]}/{rel}`'
        L.append(f'| {what} | {shown} | {upd} | {note} |')
    L.append('')
    L.append('## 記事 ↔ データの関係')
    L.append('')
    if rel_rows is None:
        L.append('KIOXIA が未接続のため、この回は作り直していない。`data/relations.csv` は前回のもの。')
    else:
        ok = [r for r in rel_rows if r['status'] == '確定']
        n_art = len({r['article_no'] for r in ok})
        L.append(f'- [data/relations.csv](data/relations.csv)：**確定 {len(ok)} 件（記事 {n_art} 本）**。記事の作業ファイルの本文に書かれた KIOXIA のパスで、実在するもの。`evidence` に根拠のファイルと行')
        L.append(f'- 同じファイルの「参照先なし」{sum(1 for r in rel_rows if r["status"] != "確定")} 件：本文にあるが KIOXIA に見つからないパス（移した・書き間違い・例として書いた、のどれかは見ていない）')
        L.append(f'- [data/candidates.csv](data/candidates.csv)：候補 {len(cand_rows)} 件。フォルダ名の番号だけで結びつくもの。**確定ではない**')
        L.append('- 関係の種類（作った・使った・比べた）はまだ分けていない。すべて「本文で参照」')
        L.append('')
        by_series = defaultdict(set)
        for r in ok:
            by_series[r['series'] or '（公開記事にない番号）'].add(r['article_no'])
        L.append('| シリーズ | データと結びついた記事 | 公開記事の本数 |')
        L.append('|---|---:|---:|')
        cnt = defaultdict(int)
        for x in arts.values():
            cnt[x['series']] += 1
        for s in sorted(set(cnt) | set(by_series), key=lambda s: -len(by_series.get(s, ()))):
            L.append(f'| {s} | {len(by_series.get(s, ()))} | {cnt.get(s, 0)} |')
    L.append('')
    L.append('## 古くなった説明')
    L.append('')
    notes = freshness(icloud, kioxia, scripts, k_ok, arts)
    L.extend(f'- {n}' for n in notes) if notes else L.append('- なし')
    L.append('')
    L.append('## 作り直し方（週1回）')
    L.append('')
    L.append('1. 公開記事の一覧を新しくする（p2m-site の最新を読める場所で）: `python3 tools/export_published.py --p2m-site <p2m-site> --out data/published_articles.csv`')
    L.append('2. Mac で KIOXIA をつないで: `python3 tools/build_index.py`')
    L.append('')
    L.append('元データ（iCloud・KIOXIA・r2r2r-scripts）は読むだけ。書くのは `_index/` の中だけ。KIOXIA で読むのは r2r2r-data の中（archive/ を除く）の名前と、README の文字・台帳の行数と列数だけ。')
    log['finished'] = now().isoformat(timespec='seconds')
    write_atomic(out / 'INDEX.md', '\n'.join(L) + '\n')
    write_atomic(data / 'build_log.json', json.dumps(log, ensure_ascii=False, indent=2) + '\n')
    print(json.dumps(log, ensure_ascii=False))


if __name__ == '__main__':
    main()
