"""ReferencePhase3Service — Phase 3 (character + outlook composite) 단독 서비스.

F24.4.4 (2026-04-24): `ReferencePipelineOrchestrator.run()`의 Phase 3 블록
(face + outfit → 전신 composite 이미지 병렬 생성)을 literal lift.

ctx.composite_generated / ctx.composite_skipped를 in-place 업데이트한다.
외부 예외는 원본과 동일하게 내부에서 log + swallow — run()은 None 반환.
"""
from __future__ import annotations

import json
import logging
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List

from sqlalchemy.orm import Session as OrmSession

from app.core.file_paths import to_relative_image_path
from app.services.image_capture.annotate import annotate_generated_asset
from app.core.image_call_budget import bind_current_budget
from app.logging.activity_logger import ActivityLogger
from app.models.project import CharacterOutlook, EntityCanon, ImageAsset
from app.services.reference_pipeline_context import ReferencePipelineContext

logger = logging.getLogger(__name__)


def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _new_id() -> str:
    return str(uuid.uuid4())


class ReferencePhase3Service:
    """Phase 3: face + outlook → 전신 composite reference 이미지 생성."""

    def __init__(
        self,
        db: OrmSession,
        project_id: str,
        actor_id: str,
        activity_logger: ActivityLogger,
    ) -> None:
        self._db = db
        self._project_id = project_id
        self._actor_id = actor_id
        self._logger = activity_logger

    def run(self, ctx: ReferencePipelineContext) -> None:
        from app.modules.pipeline.ref_image_pipeline import generate_and_validate_reference

        # local capture (literal lift)
        episode_id = ctx.episode_id
        _low_freq_skip_ids = ctx.low_freq_skip_ids
        _body_identity_ids = ctx.body_identity_char_ids
        gemini_client = ctx.gemini_client
        reference_dir = ctx.reference_dir
        max_concurrent = ctx.max_concurrent

        try:
            from app.modules.pipeline.ref_image_pipeline import _load_ref_image_prompt as _load_composite_prompt

            # ★이 화의 배정만 (Codex BLOCK 2026-09-04).
            from app.core.entity_identity import episode_outlook_rows

            char_outlooks_all = episode_outlook_rows(
                self._db, self._project_id, episode_id)

            char_outlook_groups: Dict[str, List[str]] = {}
            for co in char_outlooks_all:
                char_outlook_groups.setdefault(co.character_id, []).append(co.outlook_id)

            _o00_ids = {
                e.id for e in self._db.query(EntityCanon).filter(
                    EntityCanon.project_id == self._project_id,
                    EntityCanon.short_id == "O00",
                    EntityCanon.entity_type == "outlook",
                ).all()
            }

            composite_tasks = []
            for char_id, outlook_ids in char_outlook_groups.items():
                if char_id in _low_freq_skip_ids:
                    ctx.composite_skipped += len(outlook_ids)
                    continue
                for outlook_id in outlook_ids:
                    if outlook_id in _o00_ids:
                        ctx.composite_skipped += 1
                        continue

                    # ★기본 참조를 **먼저** 찾는다 — 재사용 판단이 그 신원에
                    #  묶여야 한다. 종전엔 (인물, 아웃룩)만 보고 건너뛰어서,
                    #  기본 몸체를 다시 만들어도 낡은 합성본이 그대로 남았다
                    #  (Codex 2026-09-18).
                    face_asset = (
                        self._db.query(ImageAsset)
                        .filter(
                            ImageAsset.entity_id == char_id,
                            ImageAsset.asset_type == "reference",
                            ImageAsset.is_primary == 1,
                            ~ImageAsset.prompt_used.like("[composite:%"),
                            ~ImageAsset.prompt_used.like("[outlook_id:%"),
                        )
                        .first()
                    )
                    if not face_asset or not Path(face_asset.file_path).exists():
                        logger.warning("Composite SKIPPED: face ref missing for char_id=%s", char_id)
                        continue
                    face_bytes = Path(face_asset.file_path).read_bytes()

                    composite_key = (
                        f"composite:{char_id}:{outlook_id}:{face_asset.id}")
                    existing = (
                        self._db.query(ImageAsset)
                        .filter(
                            ImageAsset.project_id == self._project_id,
                            ImageAsset.prompt_used.like(f"%{composite_key}%"),
                            ImageAsset.asset_type == "reference",
                        )
                        .first()
                    )
                    if existing and Path(existing.file_path).exists():
                        ctx.composite_skipped += 1
                        continue

                    outfit_asset = (
                        self._db.query(ImageAsset)
                        .filter(
                            ImageAsset.entity_id == outlook_id,
                            ImageAsset.asset_type == "reference",
                            ImageAsset.is_primary == 1,
                        )
                        .first()
                    )
                    # ★몸이 곧 신원인 인물은 옷을 **글로** 받으므로 옷 사진이
                    #  없어도 만든다 (2026-09-19, Codex 지적). 종전 조건을
                    #  그대로 두면 사진을 안 쓰기로 해 놓고 **사진이 없다고
                    #  건너뛰는** 모순이 남는다.
                    _is_derive = char_id in _body_identity_ids
                    _has_outfit = bool(
                        outfit_asset and Path(outfit_asset.file_path).exists())
                    if not _has_outfit and not _is_derive:
                        logger.warning("Composite SKIPPED: outfit ref missing for outlook_id=%s", outlook_id)
                        continue
                    outfit_bytes = (
                        Path(outfit_asset.file_path).read_bytes()
                        if _has_outfit else b"")

                    char_ent = self._db.query(EntityCanon).filter(EntityCanon.id == char_id).first()
                    outlook_ent = self._db.query(EntityCanon).filter(EntityCanon.id == outlook_id).first()
                    if not char_ent or not outlook_ent:
                        continue

                    composite_tasks.append({
                        "char_id": char_id,
                        "char_name": char_ent.name,
                        "outlook_id": outlook_id,
                        "outlook_name": outlook_ent.name,
                        "face_bytes": face_bytes,
                        "outfit_bytes_list": (
                            [(outlook_ent.name, outfit_bytes)] if outfit_bytes else []),
                        "composite_key": composite_key,
                        # persist-all Wave 1 — 합성 lineage: 입력 face/outfit asset UUID thread.
                        "face_asset_id": face_asset.id,
                        # ★파생 갈래는 옷 사진을 **안 붙인다** — 안 쓴 입력을
                        #  계보에 남기면 「무엇을 보고 그렸나」가 거짓이 된다.
                        "outfit_asset_id": (
                            outfit_asset.id if (_has_outfit and not _is_derive)
                            else None),
                        "body_identity": char_id in _body_identity_ids,
                        # ★몸이 곧 신원인 인물은 옷을 **글로** 받는다 —
                        #  사진을 주면 그 사진 속 마네킹 몸이 같이 그려진다.
                        "outlook_description": (outlook_ent.description or ""),
                    })

            if composite_tasks:
                logger.info("Generating %d composite images (face + outfit refs)", len(composite_tasks))

                def _gen_composite(task):
                    # ★몸이 곧 신원인 인물은 **몸을 새로 그리지 않는다**
                    #  (2026-09-18). 종전에는 얼굴 + 옷으로 전신을 매번 새로
                    #  그려서, 로봇 주인공의 몸이 아웃룩마다 달라졌다.
                    _derive = task.get("body_identity", False)
                    _base_label = ("Reference image 1 — the character's COMPLETE BODY"
                                   if _derive else "Reference face image")
                    labeled_refs = [(_base_label, task["face_bytes"])]
                    # ★★몸이 곧 신원인 인물에게는 **옷 사진을 안 준다**
                    #  (2026-09-19, 사용자 지적).
                    #
                    #  옷 참조(`character_outlook_ref`)는 **사람 모양 마네킹
                    #  전신 사진**이다. 참조 두 장이 둘 다 몸이면 모델이 둘을
                    #  섞는다 — 실측으로 찰리(고릴라 비율 로봇)가 사람 다리에
                    #  카고바지를 입고 로봇 팔만 붙은 그림이 됐고, 팔다리가
                    #  서너 개씩 겹친 것도 나왔다.
                    #
                    #  글로 「마네킹은 진열대일 뿐」이라고 적어도 안 먹었다.
                    #  **두 번째 몸을 아예 안 보여주는 것**이 답이다. 옷의
                    #  시각 충실도는 떨어지지만 신원은 지켜진다.
                    if not _derive:
                        for outfit_label, outfit_bytes in task["outfit_bytes_list"]:
                            labeled_refs.append(
                                (f"Outfit piece: {outfit_label}", outfit_bytes))

                    composite_prompt = _load_composite_prompt(
                        "composite_derive" if _derive else "composite")
                    if not composite_prompt:
                        composite_prompt = "Full body shot, standing pose, plain neutral background. Dress the character in the outfit shown in the reference images."

                    pipe_result = generate_and_validate_reference(
                        gemini_client=gemini_client,
                        entity_name=f"{task['char_name']} ({task['outlook_name']})",
                        entity_description=f"{task['char_name']} wearing {task['outlook_name']}",
                        # ★타입으로 문안이 정해진다 — 여기서 갈라야 파생
                        #  문안이 실린다. `t2i_prompt` 만 바꾸면 공통 생성기가
                        #  타입 템플릿으로 **덮어쓴다**(Codex 2026-09-18).
                        entity_type=("composite_derive" if _derive
                                     else "composite"),
                        t2i_prompt=composite_prompt,
                        output_dir=reference_dir,
                        extra_references=labeled_refs,
                        # ★파생 문안의 `{outlook_description}` 슬롯을 채운다.
                        #  안 주면 그릴 재료가 없다.
                        outlook_description=task.get("outlook_description") or "",
                        style_context="",
                        # W3 observability — llm_call_log PID/EID NULL 차단.
                        trace_meta={
                            "project_id": self._project_id,
                            "episode_id": episode_id,
                            "operation_type": "ref_image_gen_composite",
                            "entity_id": task["char_id"],
                        },
                    )
                    return task, pipe_result

                with ThreadPoolExecutor(max_workers=min(max_concurrent, len(composite_tasks))) as executor:
                    # W20E5 Codex B2 — propagate parent-thread image-call
                    # budget (_gen_composite → ReferencePipeline → Gemini image gen).
                    _submit_gen_composite = bind_current_budget(_gen_composite)
                    futures = {executor.submit(_submit_gen_composite, t): t for t in composite_tasks}
                    for future in as_completed(futures):
                        task = futures[future]
                        try:
                            _, pipe_result = future.result()
                            _composite_asset = ImageAsset(
                                id=_new_id(),
                                project_id=self._project_id,
                                asset_type="reference",
                                entity_id=task["char_id"],
                                episode_id=episode_id,
                                file_path=to_relative_image_path(pipe_result["file_path"]),
                                prompt_used=f"[{task['composite_key']}] {task['char_name']}+{task['outlook_name']}",
                                generation_model=pipe_result["generation_model"],
                                status="generated",
                                review_notes=json.dumps(pipe_result.get("validation", {}), ensure_ascii=False),
                                validation_score=pipe_result.get("validation", {}).get("score"),
                                is_primary=0,
                                created_at=_now(),
                            )
                            self._db.add(_composite_asset)
                            # persist-all Wave 1 — 합성 lineage: 얼굴+아웃룩 → 합성.
                            annotate_generated_asset(
                                _composite_asset,
                                pipeline_role="reference_composite",
                                stage="reference_composite",
                                input_image_ids=[
                                    x for x in (task["face_asset_id"],
                                                task.get("outfit_asset_id"))
                                    if x],
                            )
                            self._db.commit()
                            ctx.composite_generated += 1
                            logger.info("Composite generated: %s + %s", task["char_name"], task["outlook_name"])
                        except Exception as exc:
                            logger.warning("Composite failed for %s + %s: %s",
                                           task["char_name"], task["outlook_name"], exc)

        except Exception as exc:
            logger.warning("Composite generation error: %s", exc)
