#!/usr/bin/env python3
"""区間自動選定（配布版・ポータブル・多ジャンル汎用）。

文字起こし(audio.json・word timestamp付き) を入力に、
  claude CLI採点 → 足切り → dedup → 境界スナップ → clips(区間+kicker/title)
を返す。朝倉様ビジネス軸に依存しないジャンル非依存プロンプト。絶対パス無し。
"""
import json, subprocess, re, sys

CFG = dict(
    target_n=30, min_dur=18.0, max_dur=62.0,
    pre_pad=0.10, post_pad=0.35, overlap_thresh=0.5, min_score=55,
)

GENERIC_SCORING_PROMPT = """あなたはYouTube切り抜き編集のプロです。{channel_line}
以下はある長尺動画のタイムスタンプ付き文字起こしです（各行 [開始秒] テキスト）。
この長尺から、YouTubeショート(縦型)として単体で伸びる区間を最大 {n} 個抽出してください。

【採点軸】各0-100:
- hook: 冒頭2秒で続きを見たくなるか
- self_contained: 前後の文脈なしで意味が成立するか
- single_topic: 1つのメッセージに絞られているか
- payoff: 結論・実用価値・具体に早く到達するか
- emotion: 感情の起伏や引用したくなる一言があるか
virality = round(hook*0.3 + self_contained*0.2 + single_topic*0.15 + payoff*0.2 + emotion*0.15)

【区間ルール】
- 長さ20〜60秒（理想30〜45秒）。短すぎ/長すぎは減点。
- start_sec/end_secは必ず「文の切れ目・話題の変わり目」に合わせる（文の途中で切らない）。
- 区間どうしを大きく重複させない。被るなら強い方だけ残す。

【上部フック文字（各区間に必須）】この動画で実際に語られる中身だけを根拠にする:
- title: 常識破壊 × 言い切り を5〜9文字（例「時短は逆効果」「順番が9割」）。この動画に無い話を作らない。
- kicker: 牽引性(具体数値) or 視聴者定義 or 驚愕 を6〜12文字（例「3日で変わる」「初心者ほど損」）。
- 数値は具体で。そのジャンルの視聴者に自然な言葉で。
- NG=選民意識/煽りすぎ/努力否定/スピ・情弱ビジネス臭/誇大（動画内で実際に語られない内容にしない）。

【出力】JSON配列のみ。前後に説明文もコードフェンスも一切付けない。各要素は厳密に:
{{"start_sec":float,"end_sec":float,"scores":{{"hook":int,"self_contained":int,"single_topic":int,"payoff":int,"emotion":int}},"virality":int,"kicker":"...","title":"...","topic":"...","reason":"..."}}
virality降順で並べて返すこと。"""


def build_transcript(segments):
    lines = []
    for s in segments:
        txt = s["text"].strip()
        if txt:
            lines.append(f"[{s['start']:.1f}] {txt}")
    return "\n".join(lines)


def flatten_words(segments):
    ws = [w for s in segments for w in s.get("words", []) if "start" in w and "end" in w]
    ws.sort(key=lambda w: w["start"])
    return ws


def score_with_claude(transcript, n, channel_desc="", timeout=1800):
    channel_line = (f"このチャンネルのテーマ: {channel_desc}。テーマに沿って、視聴者が思わず続きを見たくなる区間を選んでください。"
                    if channel_desc else
                    "この動画のジャンル・話し手の専門性を文字起こしから読み取り、その視聴者に刺さる区間を選んでください。")
    tpl = GENERIC_SCORING_PROMPT.replace("{channel_line}", channel_line)
    payload = tpl.format(n=n) + "\n\n=== 文字起こし ===\n" + transcript
    r = subprocess.run(
        ["claude", "-p", "上のタスク指示に厳密に従い、JSON配列のみを出力せよ。", "--output-format", "text"],
        input=payload, capture_output=True, text=True, timeout=timeout,
    )
    if r.returncode != 0:
        raise RuntimeError(f"claude採点失敗 (exit {r.returncode})。Claude Codeにログインしているか確認してください。\n{r.stderr[-500:]}")
    return r.stdout


def parse_json_array(text):
    t = text.strip()
    t = re.sub(r"^```(?:json)?\s*", "", t)
    t = re.sub(r"\s*```$", "", t)
    i, j = t.find("["), t.rfind("]")
    if i == -1 or j == -1:
        raise RuntimeError(f"AI採点の出力からJSONを取り出せませんでした。先頭:\n{text[:400]}")
    return json.loads(t[i:j + 1])


def dedup(cands, thresh):
    cands = sorted(cands, key=lambda c: -c.get("virality", 0))
    kept = []
    for c in cands:
        a0, a1 = c["start_sec"], c["end_sec"]
        clash = False
        for k in kept:
            b0, b1 = k["start_sec"], k["end_sec"]
            inter = max(0.0, min(a1, b1) - max(a0, b0))
            shorter = min(a1 - a0, b1 - b0)
            if shorter > 0 and inter / shorter >= thresh:
                clash = True
                break
        if not clash:
            kept.append(c)
    return kept


def snap(cands, words, cfg):
    starts = [w["start"] for w in words]
    ends = [w["end"] for w in words]
    out = []
    for c in cands:
        s = min(starts, key=lambda x: abs(x - c["start_sec"])) - cfg["pre_pad"]
        e = min(ends, key=lambda x: abs(x - c["end_sec"])) + cfg["post_pad"]
        s = max(0.0, round(s, 2))
        e = round(e, 2)
        dur = round(e - s, 2)
        if dur < cfg["min_dur"] or dur > cfg["max_dur"]:
            c["_skip"] = f"dur {dur}s 範囲外"
        c["start"], c["end"], c["dur"] = s, e, dur
        out.append(c)
    return out


def select(audio_path, n=30, channel_desc="", min_score=55, reuse_raw=None):
    """audio.json から clips（idx/start/end/dur/kicker/title/topic）を返す。"""
    segments = json.load(open(audio_path))["segments"]
    words = flatten_words(segments)
    transcript = build_transcript(segments)
    if reuse_raw:
        raw = json.load(open(reuse_raw))
    else:
        raw = parse_json_array(score_with_claude(transcript, n, channel_desc))
    raw = [c for c in raw if c.get("virality", 0) >= min_score]
    kept = dedup(raw, CFG["overlap_thresh"])
    snapped = snap(kept, words, CFG)
    final = [c for c in snapped if "_skip" not in c]
    clips = []
    for i, c in enumerate(sorted(final, key=lambda x: x["start"]), 1):
        clips.append({"idx": i, "start": c["start"], "end": c["end"], "dur": c["dur"],
                      "virality": c.get("virality"), "kicker": c.get("kicker"),
                      "title": c.get("title"), "topic": c.get("topic")})
    return clips
