#!/opt/homebrew/bin/python3.11
"""
Story Processor V2.0 — Dual Pipeline: Story-based + LLM-based Parent-Child Summarization

Pipeline 1 (Story): Template-based, instant, no LLM cost
  → Parent story summary + Child story summary
  → Embedding (Ollama nomic-embed) → pgvector
  → BM25 (PostgreSQL tsvector) → full-text search

Pipeline 2 (LLM): LLM-based summarization (Gemma4/Qwen when resources allow)
  → Parent LLM summary + Child LLM summary
  → Embedding → pgvector + BM25

Both pipelines store into chunks table with distinct chunk_types:
  story_parent, story_child, llm_parent, llm_child

Usage:
  python parent_chunk_5w1h.py --file-uuid <uuid> --mode story [--embed]
  python parent_chunk_5w1h.py --file-uuid <uuid> --mode llm   [--embed]
"""

import json, os, sys, argparse, time, requests, psycopg2
from collections import defaultdict
from typing import Dict, List, Optional

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

DB_URL = os.getenv("DATABASE_URL", "postgresql://accusys@localhost:5432/momentry")
SCHEMA = os.getenv("DATABASE_SCHEMA", "dev")
OUTPUT_DIR = os.getenv("MOMENTRY_OUTPUT_DIR", "/Users/accusys/momentry/output_dev")
EMBEDDING_URL = os.getenv("EMBEDDING_URL", "http://localhost:11436/v1/embeddings")

def load_speaker_map(file_uuid: str) -> dict:
    """Load speaker→identity mapping from DB (generalized, not hardcoded)"""
    try:
        conn = psycopg2.connect(DB_URL)
        cur = conn.cursor()
        cur.execute("SET search_path TO %s, public", (SCHEMA,))
        cur.execute(
            "SELECT metadata->>'speaker_id', name FROM identities "
            "WHERE metadata->>'speaker_id' IS NOT NULL"
        )
        spk_map = {}
        for spk_id, name in cur.fetchall():
            spk_map[spk_id] = (name, 0.85)  # default confidence from MAR
        cur.close(); conn.close()
        return spk_map if spk_map else DEFAULT_SPEAKER_MAP
    except Exception:
        return DEFAULT_SPEAKER_MAP

# Default fallback (used when DB has no speaker mapping)
DEFAULT_SPEAKER_MAP = {}

CURRENT_VERSIONS = {
    "asr": "faster-whisper/small/v2",
    "asrx": "speechbrain/ecapa-tdnn/v2",
    "cut": "pyscenedetect/default",
    "yolo": "yolov5-coreml/v2",
    "face_detection": "apple-vision/v2.1",
    "face_embedding": "coreml-facenet/v2",
    "mediapipe": "mediapipe-holistic/v1.11",
    "speaker_binding": "mar-lip/v2",
    "identity_clustering": "cosine-threshold/v1",
    "story_agent": "e4b-per-cut/v1.11",
    "embedding_agent": "nomic-embed-768d/v2",
}

LLM_URL = os.getenv("MOMENTRY_LLM_URL", os.getenv("MOMENTRY_LLM_SUMMARY_URL", "http://127.0.0.1:8082/v1/chat/completions"))
LLM_MODEL = os.getenv("MOMENTRY_LLM_SUMMARY_MODEL", "gemma4")

# v1.11 E4B integration
E4B_OUTPUT_DIR = OUTPUT_DIR
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
EMBEDDING_DIM = 768


def load_data(file_uuid: str) -> dict:
    data = {}
    for name in ["asr", "asrx", "cut"]:
        path = os.path.join(OUTPUT_DIR, f"{file_uuid}.{name}.json")
        data[name] = json.load(open(path)) if os.path.exists(path) else None
    return data


def build_child_chunks(data: dict, file_uuid: str) -> List[dict]:
    """Group ASR sentences by CUT scene boundaries → parent/child structure."""
    asr_segs = data["asr"].get("segments", []) if data["asr"] else []
    asrx_segs = data["asrx"].get("segments", []) if data["asrx"] else []
    cut_scenes = data["cut"].get("scenes", []) if data["cut"] else []

    # Dynamically load speaker→identity mapping from DB
    speaker_map = load_speaker_map(file_uuid)

    if not cut_scenes:
        max_t = max(
            (asr_segs[-1].get("end", 0) if asr_segs else 0),
            (asrx_segs[-1].get("end_time", 0) if asrx_segs else 0),
        )
        cut_scenes = [{"start_time": t, "end_time": min(t + 60, max_t)} for t in range(0, int(max_t) + 60, 60)]

    scenes = []
    for cs in cut_scenes:
        s, e = cs["start_time"], cs["end_time"]

        children = []
        for seg_idx, seg in enumerate(asr_segs):
            st, en = seg.get("start", 0), seg.get("end", 0)
            text = seg.get("text", "").strip()
            if st < s or en > e or not text: continue

            spk_id = "unknown"
            for ax in asrx_segs:
                if ax["start_time"] <= st and ax["end_time"] >= en:
                    spk_id = ax.get("speaker_id", "unknown"); break

            spk_info = speaker_map.get(spk_id)
            if spk_info:
                character, spk_conf = spk_info
            else:
                character, spk_conf = spk_id, 0.0

            children.append({
                "start": st, "end": en, "text": text,
                "speaker_id": spk_id, "speaker_name": character,
                "speaker_confidence": spk_conf,
                "chunk_id": f"{file_uuid}_{seg_idx}",
            })

        # Boundary overlap: even empty scenes get partial children
        for seg_idx, seg in enumerate(asr_segs):
            st, en = seg.get("start", 0), seg.get("end", 0)
            text = seg.get("text", "").strip()
            if not text: continue
            if st >= s and en <= e: continue
            if not (st < e and en > s): continue
            
            spk_id = "unknown"
            for ax in asrx_segs:
                if ax["start_time"] <= st and ax["end_time"] >= en:
                    spk_id = ax.get("speaker_id", "unknown"); break
            spk_info = speaker_map.get(spk_id)
            if spk_info:
                character, spk_conf = spk_info
            else:
                character, spk_conf = spk_id, 0.0
            children.append({
                "start": st, "end": en, "text": text,
                "speaker_id": spk_id, "speaker_name": character,
                "speaker_confidence": spk_conf,
                "chunk_id": f"{file_uuid}_{seg_idx}",
                "overlap_type": "partial",
            })

        if children:
            scenes.append({
                "start_time": s, "end_time": e, "duration": e - s,
                "children": children, "child_count": len(children),
            })
    return scenes


# ===== Pipeline 1: Story (Template) Summaries =====

def generate_story_parent_summary(scene: dict) -> str:
    children = scene["children"]
    characters = sorted(set(c["speaker_name"] for c in children))
    total_words = sum(len(c["text"].split()) for c in children)
    by_speaker = defaultdict(list)
    for c in children: by_speaker[c["speaker_name"]].append(c["text"])
    speakers = []
    for char, texts in sorted(by_speaker.items()):
        speakers.append(f"{char} ({len(texts)} lines)")

    return (
        f"[{scene['start_time']:.0f}s-{scene['end_time']:.0f}s, {scene['duration']:.0f}s] "
        f"Cast: {', '.join(characters)}. Total: {len(children)} lines, {total_words} words. "
        f"Speakers: {' | '.join(speakers[:3])}"
    )


def generate_story_child_summary(child: dict, parent_summary: str) -> str:
    return (
        f"[{child['start']:.0f}s-{child['end']:.0f}s] "
        f"{child['speaker_name']}: \"{child['text']}\""
    )


# ===== Pipeline 2: LLM Summaries (requires LLM server) =====

def generate_llm_parent_summary(scene: dict, max_scenes_processed: int) -> Optional[str]:
    """LLM-based parent summary"""
    if not LLM_URL: return None
    children = scene["children"]
    dialogue = "\n".join(
        f"[{c['start']:.0f}s] {c['speaker_name']}: {c['text'][:150]}"
        for c in children[:15]
    )
    prompt = (
        "You are a film analyst. Summarize this scene in one flowing paragraph (60-100 words). "
        "Include: who is present, what they discuss, tone/mood.\n\n"
        f"Scene: {scene['start_time']:.0f}s - {scene['end_time']:.0f}s\n"
        f"Dialogue:\n{dialogue}\n\nSummary:"
    )
    try:
        resp = requests.post(LLM_URL, json={
            "model": LLM_MODEL,
            "messages": [{"role": "user", "content": prompt}],
            "max_tokens": 200, "temperature": 0.3,
        }, timeout=60)
        return resp.json()["choices"][0]["message"]["content"].strip()
    except Exception as e:
        print(f"  ⚠️ LLM parent summary failed: {e}")
        return None


def generate_llm_child_summary(child: dict, parent_summary: str) -> Optional[str]:
    """LLM-based child (sentence) summary"""
    return f"[{child['start']:.0f}s-{child['end']:.0f}s] {child['speaker_name']}: \"{child['text']}\""


# ===== Pipeline 3: E4B per-CUT Integration (v1.11) =====

def load_e4b_results(file_uuid: str) -> dict:
    """Load E4B per-CUT results from JSON"""
    e4b_path = os.path.join(E4B_OUTPUT_DIR, f"{file_uuid}.e4b_per_cut.json")
    if not os.path.exists(e4b_path):
        print(f"  ⚠️ E4B results not found: {e4b_path}")
        return {}
    with open(e4b_path) as f:
        return json.load(f)


def generate_e4b_parent_summary(scene: dict, e4b_scenes: dict) -> str:
    """Build parent summary from E4B-enriched scene data"""
    start_t = scene['start_time']
    # Find matching E4B scene by time proximity
    e4b = None
    for es in e4b_scenes.get("scenes", []):
        if abs(es.get("start_time", 0) - start_t) < 2.0:
            e4b = es
            break
    if not e4b:
        return generate_story_parent_summary(scene)
    summary = e4b.get("summary", "")
    corrected = e4b.get("corrected_text", "")
    scene_type = e4b.get("scene_type", "")
    desc = e4b.get("scene_description", "")
    parts = [f"[{scene['start_time']:.0f}s-{scene['end_time']:.0f}s]"]
    if summary:
        parts.append(summary)
    else:
        parts.append(corrected[:200])
    if scene_type and scene_type != "unknown":
        parts.append(f"[Type: {scene_type}]")
    if desc:
        parts.append(f"[{desc[:150]}]")
    return " ".join(parts)


def store_scenes_to_qdrant(file_uuid: str, scenes: list, e4b_data: dict):
    """Upsert scenes to Qdrant momentry_scenes collection"""
    try:
        scenes_list = e4b_data.get("scenes", [])
        points = []
        for i, scene in enumerate(scenes):
            start_t = scene['start_time']
            e4b = None
            for es in scenes_list:
                if abs(es.get("start_time", 0) - start_t) < 2.0:
                    e4b = es
                    break
            text = e4b.get("corrected_text", "") if e4b else ""
            summary = e4b.get("summary", "") if e4b else ""
            scene_type = e4b.get("scene_type", "") if e4b else ""
            desc = e4b.get("scene_description", "") if e4b else ""

            embed_text_content = (summary or text)[:512]
            emb_resp = requests.post(EMBEDDING_URL, json={"input": [embed_text_content]}, timeout=30)
            if emb_resp.status_code != 200:
                continue
            emb = emb_resp.json()["data"][0]["embedding"]

            faces = []
            if e4b and "face_qa" in e4b:
                for fq in e4b["face_qa"]:
                    faces.append({
                        "trace_id": fq.get("trace_id", 0),
                        "name": fq.get("name", f"Face_{fq.get('trace_id',0)}"),
                    })

            point_id = f"{file_uuid}_{scene['start_time']:.0f}_{scene['end_time']:.0f}"
            points.append({
                "id": point_id,
                "vector": emb,
                "payload": {
                    "file_uuid": file_uuid,
                    "chunk_id": f"scene_{i}",
                    "text_content": text[:2000] if text else "",
                    "summary_text": summary[:1000] if summary else "",
                    "scene_type": scene_type,
                    "scene_description": desc[:500] if desc else "",
                    "key_objects": e4b.get("key_objects", []) if e4b else [],
                    "speakers": list(set(
                        c.get("speaker_name", "") for c in scene.get("children", [])
                    )),
                    "face_traces": faces,
                    "time_range": [scene["start_time"], scene["end_time"]],
                    "child_chunk_ids": [c.get("chunk_id", "") for c in scene.get("children", [])],
                }
            })

        if points:
            resp = requests.post(
                f"{QDRANT_URL}/collections/momentry_scenes/points",
                json={"points": points},
                timeout=60,
            )
            if resp.status_code == 200:
                print(f"  ✅ Qdrant: {len(points)} scenes upserted to momentry_scenes")
            else:
                print(f"  ⚠️ Qdrant upsert failed: {resp.status_code} {resp.text[:200]}")
    except Exception as e:
        print(f"  ⚠️ Qdrant scene store failed: {e}")


# ===== Embedding (Ollama nomic-embed) =====

def embed_text(text: str, max_retries: int = 3) -> Optional[List[float]]:
    """Get embedding via EmbeddingGemma server"""
    for attempt in range(max_retries):
        try:
            resp = requests.post(EMBEDDING_URL, json={
                "input": [text],
            }, timeout=30)
            if resp.status_code == 200:
                data = resp.json()
                items = data.get("data", [])
                if items:
                    return items[0]["embedding"]
        except Exception as e:
            if attempt == max_retries - 1:
                print(f"  ⚠️ Embedding failed: {e}")
                return None
            time.sleep(1)
    return None


# ===== DB Store (chunks table with embedding + BM25) =====

def store_chunks(file_uuid: str, scenes: List[dict], mode: str, do_embed: bool, conn,
                 e4b_data: Optional[dict] = None):
    """Store parent + child summaries into chunks table."""
    cur = conn.cursor()
    parent_type = f"{mode}_parent"
    child_type = f"{mode}_child"

    parent_count = 0
    child_count = 0

    # Get base chunk_index
    cur.execute(
        f"SELECT COALESCE(MAX(chunk_index), 0) FROM {SCHEMA}.chunk WHERE file_uuid = %s",
        (file_uuid,),
    )
    next_index = (cur.fetchone()[0] or 0) + 1

    for scene in scenes:
        if mode == "e4b" and e4b_data:
            parent_text = generate_e4b_parent_summary(scene, e4b_data)
        elif mode == "story":
            parent_text = generate_story_parent_summary(scene)
        else:
            parent_text = generate_llm_parent_summary(scene, parent_count)
        if not parent_text: continue

        parent_id = f"{mode}_parent_{file_uuid}_{scene['start_time']:.0f}_{scene['end_time']:.0f}"

        parent_embedding = embed_text(parent_text) if do_embed else None
        if do_embed and parent_embedding:
            cur.execute(
                f"""
                INSERT INTO {SCHEMA}.chunk (chunk_id, old_chunk_id, file_uuid, chunk_type, chunk_index,
                                             start_time, end_time, content, text_content, parent_chunk_id, embedding)
                VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s::vector)
                ON CONFLICT (file_uuid, old_chunk_id) DO UPDATE
                    SET content = EXCLUDED.content, text_content = EXCLUDED.text_content,
                        embedding = EXCLUDED.embedding
                """,
                (parent_id, parent_id, file_uuid, parent_type, next_index,
                 scene["start_time"], scene["end_time"],
                 json.dumps({"summary": parent_text, "mode": mode, "type": "parent",
                             "source_versions": CURRENT_VERSIONS}),
                 parent_text, None, parent_embedding),
            )
        else:
            cur.execute(
                f"""
                INSERT INTO {SCHEMA}.chunk (chunk_id, old_chunk_id, file_uuid, chunk_type, chunk_index,
                                             start_time, end_time, content, text_content, parent_chunk_id)
                VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s)
                ON CONFLICT (file_uuid, old_chunk_id) DO UPDATE
                    SET content = EXCLUDED.content, text_content = EXCLUDED.text_content
                """,
                (parent_id, parent_id, file_uuid, parent_type, next_index,
                 scene["start_time"], scene["end_time"],
                 json.dumps({"summary": parent_text, "mode": mode, "type": "parent",
                             "source_versions": CURRENT_VERSIONS}),
                 parent_text, None),
            )
        next_index += 1
        parent_count += 1

        for child in scene["children"]:
            child_id = child["chunk_id"]
            child_text = generate_story_child_summary(child, parent_text) if mode == "story" else generate_llm_child_summary(child, parent_text)

            child_embedding = embed_text(child_text) if do_embed else None
            if do_embed and child_embedding:
                cur.execute(
                    f"""
                    INSERT INTO {SCHEMA}.chunk (chunk_id, old_chunk_id, file_uuid, chunk_type, chunk_index,
                                                 start_time, end_time, content, text_content, parent_chunk_id, embedding)
                    VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s::vector)
                    ON CONFLICT (file_uuid, old_chunk_id) DO UPDATE
                        SET content = EXCLUDED.content, text_content = EXCLUDED.text_content,
                            parent_chunk_id = EXCLUDED.parent_chunk_id,
                            embedding = EXCLUDED.embedding
                    """,
                    (child_id, child_id, file_uuid, child_type, next_index,
                     child["start"], child["end"],
                     json.dumps({"speaker": child["speaker_name"], "text": child["text"], "mode": mode,
                                 "speaker_confidence": child.get("speaker_confidence", 0),
                                 "source_versions": CURRENT_VERSIONS}),
                     child_text, parent_id, child_embedding),
                )
            else:
                cur.execute(
                    f"""
                    INSERT INTO {SCHEMA}.chunk (chunk_id, old_chunk_id, file_uuid, chunk_type, chunk_index,
                                                 start_time, end_time, content, text_content, parent_chunk_id)
                    VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s)
                    ON CONFLICT (file_uuid, old_chunk_id) DO UPDATE
                        SET content = EXCLUDED.content, text_content = EXCLUDED.text_content,
                            parent_chunk_id = EXCLUDED.parent_chunk_id
                    """,
                    (child_id, child_id, file_uuid, child_type, next_index,
                     child["start"], child["end"],
                     json.dumps({"speaker": child["speaker_name"], "text": child["text"], "mode": mode,
                                 "speaker_confidence": child.get("speaker_confidence", 0),
                                 "source_versions": CURRENT_VERSIONS}),
                     child_text, parent_id),
                )
            next_index += 1
            child_count += 1

    conn.commit()
    cur.close()
    return parent_count, child_count


def main():
    parser = argparse.ArgumentParser(description="Story Processor V2.0")
    parser.add_argument("--file-uuid", required=True)
    parser.add_argument("--mode", choices=["story", "llm", "e4b"], default="story",
                        help="Pipeline mode: story (template), llm, or e4b (v1.11)")
    parser.add_argument("--max-scenes", type=int, default=99999)
    parser.add_argument("--embed", action="store_true", help="Generate embeddings")
    parser.add_argument("--no-db", action="store_true", help="Skip DB storage")
    parser.add_argument("--qdrant", action="store_true", help="Upsert scenes to Qdrant momentry_scenes")
    args = parser.parse_args()

    file_uuid = args.file_uuid
    print(f"[STORY] Mode: {args.mode}, Embed: {args.embed}, Qdrant: {args.qdrant}")

    data = load_data(file_uuid)
    if not data["asr"]:
        print("[STORY] ❌ No ASR data"); return

    scenes = build_child_chunks(data, file_uuid)[:args.max_scenes]
    total_children = sum(s["child_count"] for s in scenes)
    print(f"[STORY] {len(scenes)} scenes, {total_children} child chunks")

    # Load E4B results if in e4b mode
    e4b_data = {}
    if args.mode == "e4b":
        e4b_data = load_e4b_results(file_uuid)
        if not e4b_data:
            print("[STORY] ⚠️ No E4B data, falling back to story mode")

    if not args.no_db:
        conn = psycopg2.connect(DB_URL)
        try:
            if args.mode == "e4b" and e4b_data:
                pc, cc = store_chunks(file_uuid, scenes, args.mode, args.embed, conn, e4b_data)
                print(f"[STORY] DB: {pc} parent, {cc} child chunks ({args.mode})")
            else:
                pc, cc = store_chunks(file_uuid, scenes, args.mode, args.embed, conn)
                print(f"[STORY] DB: {pc} parent, {cc} child chunks ({args.mode})")
        finally:
            conn.close()

    # Qdrant scene upsert (v1.11)
    if args.qdrant and e4b_data:
        store_scenes_to_qdrant(file_uuid, scenes, e4b_data)

    # Save JSON output
    out_path = os.path.join(OUTPUT_DIR, f"{file_uuid}.story_{args.mode}.json")
    out_data = {"file_uuid": file_uuid, "mode": args.mode, "scenes": scenes, "e4b_used": bool(e4b_data)}
    with open(out_path, "w") as f:
        json.dump(out_data, f, indent=2, ensure_ascii=False, default=str)
    print(f"[STORY] ✅ {out_path}")


if __name__ == "__main__":
    main()
