from __future__ import annotations

import base64
import json
import os
import time
from dataclasses import dataclass
from http.client import RemoteDisconnected
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen

from packages.pet_package_schema import DEFAULT_FRAMES_PER_ACTION


class APIMartConfigError(RuntimeError):
    pass


class APIMartRequestError(RuntimeError):
    pass


@dataclass
class APIMartConfig:
    api_key: str
    base_url: str
    model: str
    resolution: str = "1k"
    poll_interval_seconds: float = 5.0
    timeout_seconds: float = 900.0
    request_timeout_seconds: float = 90.0

    @property
    def masked_api_key(self) -> str:
        return mask_secret(self.api_key)

    def __repr__(self) -> str:
        return (
            "APIMartConfig("
            f"api_key={self.masked_api_key!r}, "
            f"base_url={self.base_url!r}, "
            f"model={self.model!r}, "
            f"resolution={self.resolution!r})"
        )


@dataclass
class APIMartReviewConfig:
    api_key: str
    base_url: str
    model: str
    request_timeout_seconds: float = 60.0

    @property
    def masked_api_key(self) -> str:
        return mask_secret(self.api_key)

    def __repr__(self) -> str:
        return (
            "APIMartReviewConfig("
            f"api_key={self.masked_api_key!r}, "
            f"base_url={self.base_url!r}, "
            f"model={self.model!r})"
        )


def mask_secret(value: str) -> str:
    if len(value) < 12:
        return "***"
    return f"{value[:4]}...{value[-4:]}"


def load_apimart_config(env_path: str | Path = ".env.local") -> APIMartConfig:
    env_values = _read_env_file(Path(env_path))
    merged = {
        **env_values,
        **{
            key: value
            for key, value in os.environ.items()
            if key.startswith("APIMART_")
        },
    }
    api_key = merged.get("APIMART_API_KEY", "").strip()
    base_url = merged.get("APIMART_BASE_URL", "https://api.apimart.ai/v1").strip().rstrip("/")
    model = merged.get("APIMART_MODEL_IMAGE", "gpt-image-2").strip()
    if not api_key or not base_url or not model:
        raise APIMartConfigError(
            "APIMart config is incomplete. Set APIMART_API_KEY, APIMART_BASE_URL, and APIMART_MODEL_IMAGE."
        )
    return APIMartConfig(
        api_key=api_key,
        base_url=base_url,
        model=model,
        resolution=merged.get("APIMART_IMAGE_RESOLUTION", "1k").strip() or "1k",
        poll_interval_seconds=_env_float(merged, "APIMART_IMAGE_POLL_INTERVAL_SECONDS", 5.0),
        timeout_seconds=_env_float(merged, "APIMART_IMAGE_TIMEOUT_SECONDS", 900.0),
        request_timeout_seconds=_env_float(merged, "APIMART_IMAGE_REQUEST_TIMEOUT_SECONDS", 90.0),
    )


def load_apimart_review_config(env_path: str | Path = ".env.local") -> APIMartReviewConfig:
    env_values = _read_env_file(Path(env_path))
    merged = {
        **env_values,
        **{
            key: value
            for key, value in os.environ.items()
            if key.startswith("APIMART_")
        },
    }
    api_key = merged.get("APIMART_API_KEY", "").strip()
    base_url = merged.get("APIMART_BASE_URL", "https://api.apimart.ai/v1").strip().rstrip("/")
    model = merged.get("APIMART_MODEL_REVIEW", "").strip()
    if not api_key or not base_url or not model:
        raise APIMartConfigError(
            "APIMart review config is incomplete. Set APIMART_API_KEY, APIMART_BASE_URL, and APIMART_MODEL_REVIEW."
        )
    return APIMartReviewConfig(
        api_key=api_key,
        base_url=base_url,
        model=model,
        request_timeout_seconds=_env_float(merged, "APIMART_REVIEW_REQUEST_TIMEOUT_SECONDS", 60.0),
    )


class APIMartImageGenerationClient:
    provider = "apimart"

    def __init__(
        self,
        *,
        api_key: str,
        base_url: str,
        model: str = "gpt-image-2",
        resolution: str = "1k",
        opener: Callable[..., Any] = urlopen,
        sleep: Callable[[float], Any] = time.sleep,
        clock: Callable[[], float] = time.monotonic,
        poll_interval_seconds: float = 5.0,
        timeout_seconds: float = 900.0,
        request_timeout_seconds: float = 90.0,
        reference_limit: int = 3,
        max_poll_retries: int = 3,
    ) -> None:
        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.model = model
        self.resolution = resolution
        self.opener = opener
        self.sleep = sleep
        self.clock = clock
        self.poll_interval_seconds = max(0.0, float(poll_interval_seconds))
        self.timeout_seconds = max(10.0, float(timeout_seconds))
        self.request_timeout_seconds = max(5.0, float(request_timeout_seconds))
        self.reference_limit = max(1, min(int(reference_limit), 16))
        self.max_poll_retries = max(0, int(max_poll_retries))

    @classmethod
    def from_env(
        cls,
        env_path: str | Path = ".env.local",
        *,
        opener: Callable[..., Any] = urlopen,
    ) -> "APIMartImageGenerationClient":
        config = load_apimart_config(env_path)
        return cls(
            api_key=config.api_key,
            base_url=config.base_url,
            model=config.model,
            resolution=config.resolution,
            opener=opener,
            poll_interval_seconds=config.poll_interval_seconds,
            timeout_seconds=config.timeout_seconds,
            request_timeout_seconds=config.request_timeout_seconds,
        )

    def generate_canonical_pet(
        self,
        *,
        pet_name: str,
        notes: str,
        analysis: dict[str, Any],
        images: list[dict[str, Any]],
        size: str = "1024*1024",
    ) -> dict[str, Any]:
        if not images:
            raise ValueError("at least one image is required for APIMart image generation")
        prompt = _build_canonical_pet_prompt(pet_name=pet_name, notes=notes, analysis=analysis)
        image_urls = [
            _to_data_url(image["bytes"], image["mime"])
            for image in images[: self.reference_limit]
        ]
        return self._generate_image(
            prompt=prompt,
            image_urls=image_urls,
            size=_normalize_size(size),
        )

    def generate_action_frame(
        self,
        *,
        action: str,
        frame_index: int,
        candidate_index: int = 0,
        pet_name: str,
        notes: str,
        analysis: dict[str, Any],
        images: list[dict[str, Any]],
        canonical_image_bytes: bytes,
        previous_frame_image_bytes: bytes | None = None,
        size: str = "1024*1024",
    ) -> dict[str, Any]:
        if not canonical_image_bytes:
            raise ValueError("canonical image is required for APIMart action generation")
        if frame_index < 0 or frame_index >= DEFAULT_FRAMES_PER_ACTION:
            raise ValueError(f"frame_index must be between 0 and {DEFAULT_FRAMES_PER_ACTION - 1}")
        attach_previous_frame = bool(previous_frame_image_bytes) and action != "walk"
        prompt = _build_action_frame_prompt(
            action=action,
            frame_index=frame_index,
            candidate_index=candidate_index,
            pet_name=pet_name,
            notes=notes,
            analysis=analysis,
            has_previous_frame=attach_previous_frame,
        )
        image_urls = [_to_data_url(canonical_image_bytes, "image/png")]
        if attach_previous_frame:
            image_urls.append(_to_data_url(previous_frame_image_bytes, "image/png"))
        return self._generate_image(
            prompt=prompt,
            image_urls=image_urls,
            size=_normalize_size(size),
        )

    def generate_action_frame_minimal(
        self,
        *,
        action: str,
        frame_index: int,
        candidate_index: int = 0,
        pet_name: str,
        notes: str,
        analysis: dict[str, Any],
        canonical_image_bytes: bytes | None = None,
        size: str = "1024*1024",
    ) -> dict[str, Any]:
        if frame_index < 0 or frame_index >= DEFAULT_FRAMES_PER_ACTION:
            raise ValueError(f"frame_index must be between 0 and {DEFAULT_FRAMES_PER_ACTION - 1}")
        prompt = _build_action_frame_prompt(
            action=action,
            frame_index=frame_index,
            candidate_index=candidate_index,
            pet_name=pet_name,
            notes=notes,
            analysis=analysis,
            has_previous_frame=False,
        )
        image_urls = []
        if canonical_image_bytes:
            image_urls.append(_to_data_url(canonical_image_bytes, "image/png"))
        return self._generate_image(
            prompt=prompt,
            image_urls=image_urls,
            size=_normalize_size(size),
        )

    def generate_action_strip(
        self,
        *,
        action: str,
        candidate_index: int = 0,
        pet_name: str,
        notes: str,
        analysis: dict[str, Any],
        images: list[dict[str, Any]],
        canonical_image_bytes: bytes,
        layout_guide_image_bytes: bytes,
        size: str = "1280*720",
    ) -> dict[str, Any]:
        if not canonical_image_bytes:
            raise ValueError("canonical image is required for APIMart action strip generation")
        if not layout_guide_image_bytes:
            raise ValueError("layout guide image is required for APIMart action strip generation")
        prompt = _build_action_strip_prompt(
            action=action,
            candidate_index=candidate_index,
            pet_name=pet_name,
            notes=notes,
            analysis=analysis,
        )
        image_urls = [
            _to_data_url(layout_guide_image_bytes, "image/png"),
            _to_data_url(canonical_image_bytes, "image/png"),
        ]
        return self._generate_image(
            prompt=prompt,
            image_urls=image_urls,
            size=_normalize_size(size),
        )

    def _generate_image(
        self,
        *,
        prompt: str,
        image_urls: list[str],
        size: str,
    ) -> dict[str, Any]:
        started_at = self.clock()
        payload: dict[str, Any] = {
            "model": self.model,
            "prompt": prompt,
            "n": 1,
            "size": size,
            "resolution": self.resolution,
        }
        if image_urls:
            payload["image_urls"] = image_urls
        submitted = self._post_json(f"{self.base_url}/images/generations", payload)
        task_id = _extract_task_id(submitted)
        completed = self._poll_task(task_id)
        image_url = _extract_completed_image_url(completed)
        image_bytes = self._download_image(image_url)
        observed_duration_seconds = round(max(0.0, self.clock() - started_at), 3)
        data = completed.get("data", {}) if isinstance(completed, dict) else {}
        usage = _safe_usage(data)
        usage["observed_duration_seconds"] = observed_duration_seconds
        return {
            "status": "ok",
            "provider": "apimart",
            "model": self.model,
            "request_id": task_id,
            "image_bytes": image_bytes,
            "image_mime": "image/png",
            "usage": usage,
            "prompt_sha256": _sha256_text(prompt),
        }

    def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
        body = json.dumps(payload).encode("utf-8")
        for attempt in range(self.max_poll_retries + 1):
            request = Request(
                url,
                data=body,
                method="POST",
                headers={
                    "Authorization": f"Bearer {self.api_key}",
                    "Content-Type": "application/json",
                },
            )
            try:
                with self.opener(request, timeout=self.request_timeout_seconds) as response:
                    return json.loads(response.read().decode("utf-8"))
            except HTTPError as exc:
                raise APIMartRequestError(f"APIMart image request failed with HTTP {exc.code}") from exc
            except URLError as exc:
                if attempt < self.max_poll_retries:
                    if self.poll_interval_seconds:
                        self.sleep(self.poll_interval_seconds)
                    continue
                raise APIMartRequestError("APIMart image request failed before receiving a response") from exc
            except json.JSONDecodeError as exc:
                raise APIMartRequestError("APIMart image response was not valid JSON") from exc
        raise APIMartRequestError("APIMart image request failed before receiving a response")

    def _poll_task(self, task_id: str) -> dict[str, Any]:
        deadline = time.monotonic() + self.timeout_seconds
        task_url = f"{self.base_url}/tasks/{quote(task_id)}?language=zh"
        transient_errors = 0
        while True:
            request = Request(
                task_url,
                method="GET",
                headers={"Authorization": f"Bearer {self.api_key}"},
            )
            try:
                with self.opener(request, timeout=self.request_timeout_seconds) as response:
                    payload = json.loads(response.read().decode("utf-8"))
            except RemoteDisconnected as exc:
                transient_errors += 1
                if transient_errors <= self.max_poll_retries and time.monotonic() < deadline:
                    if self.poll_interval_seconds:
                        self.sleep(self.poll_interval_seconds)
                    continue
                raise APIMartRequestError("APIMart task polling connection was closed before receiving a response") from exc
            except HTTPError as exc:
                raise APIMartRequestError(f"APIMart task polling failed with HTTP {exc.code}") from exc
            except URLError as exc:
                transient_errors += 1
                if transient_errors <= self.max_poll_retries and time.monotonic() < deadline:
                    if self.poll_interval_seconds:
                        self.sleep(self.poll_interval_seconds)
                    continue
                raise APIMartRequestError("APIMart task polling failed before receiving a response") from exc
            except json.JSONDecodeError as exc:
                raise APIMartRequestError("APIMart task response was not valid JSON") from exc

            data = payload.get("data", {}) if isinstance(payload, dict) else {}
            status = data.get("status") if isinstance(data, dict) else None
            if status == "completed":
                return payload
            if status in {"failed", "cancelled"}:
                message = ""
                error = data.get("error") if isinstance(data, dict) else {}
                if isinstance(error, dict):
                    message = str(error.get("message") or "")
                raise APIMartRequestError(f"APIMart image task {status}: {message[:220]}")
            if time.monotonic() >= deadline:
                raise APIMartRequestError("APIMart image task timed out")
            if self.poll_interval_seconds:
                self.sleep(self.poll_interval_seconds)

    def _download_image(self, image_url: str) -> bytes:
        image_bytes = b""
        for attempt in range(self.max_poll_retries + 1):
            try:
                with self.opener(image_url, timeout=self.request_timeout_seconds) as response:
                    image_bytes = response.read()
                    break
            except HTTPError as exc:
                raise APIMartRequestError(f"APIMart generated image download failed with HTTP {exc.code}") from exc
            except URLError as exc:
                if attempt < self.max_poll_retries:
                    if self.poll_interval_seconds:
                        self.sleep(self.poll_interval_seconds)
                    continue
                raise APIMartRequestError("APIMart generated image download failed before receiving a response") from exc
        if not image_bytes:
            raise APIMartRequestError("APIMart generated image download was empty")
        return image_bytes


class APIMartVisualReviewClient:
    provider = "apimart"

    def __init__(
        self,
        *,
        api_key: str,
        base_url: str,
        model: str,
        opener: Callable[..., Any] = urlopen,
        request_timeout_seconds: float = 60.0,
    ) -> None:
        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.model = model
        self.opener = opener
        self.request_timeout_seconds = max(5.0, float(request_timeout_seconds))

    @classmethod
    def from_env(
        cls,
        env_path: str | Path = ".env.local",
        *,
        opener: Callable[..., Any] = urlopen,
    ) -> "APIMartVisualReviewClient":
        config = load_apimart_review_config(env_path)
        return cls(
            api_key=config.api_key,
            base_url=config.base_url,
            model=config.model,
            opener=opener,
            request_timeout_seconds=config.request_timeout_seconds,
        )

    def review_action_frames(
        self,
        *,
        pet_name: str,
        action: str,
        image_bytes: bytes,
        image_mime: str,
    ) -> dict[str, Any]:
        data_url = _to_data_url(image_bytes, image_mime)
        payload = {
            "model": self.model,
            "messages": [
                {
                    "role": "system",
                    "content": (
                        "You review one generated action row for a memorial desktop pet. Return compact JSON only. "
                        "Check same pet identity, full-body visibility, clean background removal readiness, and action semantics. "
                        "Reject wrong species, unrelated simplified animals, cropped or broken bodies, or unclear requested action."
                    ),
                },
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "text",
                            "text": (
                                "Please review one generated action row. "
                                f"Pet display name: {pet_name}. "
                                f"Action to verify: {action}. "
                                f"The {DEFAULT_FRAMES_PER_ACTION} frames must look like the same pet identity and visibly match the action semantics. "
                                f"{_action_visual_review_requirements(action)} "
                                "Return JSON with keys score (0-1), passed (boolean), action_ok (boolean), identity_ok (boolean), "
                                f"notes (short), risks (array). For walk, also return front_paw_sequence ({DEFAULT_FRAMES_PER_ACTION} short strings), "
                                f"hind_paw_sequence ({DEFAULT_FRAMES_PER_ACTION} short strings), gait_cycle_ok (boolean), two_frame_loop_ok (boolean), "
                                "and bad_frames (array of 1-based frame numbers)."
                            ),
                        },
                        {"type": "image_url", "image_url": {"url": data_url}},
                    ],
                },
            ],
            "temperature": 0,
        }
        raw = self._post_chat_completions(payload)
        content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
        return {
            "status": "ok",
            "provider": "apimart",
            "model": self.model,
            "response_id": raw.get("id"),
            "review": _parse_review_content(content),
        }

    def review_contact_sheet(
        self,
        *,
        pet_name: str,
        image_bytes: bytes,
        image_mime: str,
    ) -> dict[str, Any]:
        data_url = _to_data_url(image_bytes, image_mime)
        payload = {
            "model": self.model,
            "messages": [
                {
                    "role": "system",
                    "content": (
                        "You review the final 6-row contact sheet for a memorial desktop pet. Return compact JSON only. "
                        "Reject packages with wrong-facing frames or action rows that do not match hard required labels. "
                        "Tail_wag may be a subtle low-disturbance row; do not reject solely because tail_wag resembles idle or look."
                    ),
                },
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "text",
                            "text": (
                                "Please review the final 6-row contact sheet before installation. "
                                f"Pet display name: {pet_name}. "
                                "Rows are fixed: Row 1 idle, Row 2 sleep, Row 3 walk, Row 4 look, Row 5 sit, Row 6 tail_wag. "
                                f"Row 2 sleep: all {DEFAULT_FRAMES_PER_ACTION} frames must keep the same sleeping direction/orientation; reject any flipped, "
                                "opposite-facing, upright, or non-sleeping frame. "
                                "Row 3 walk: this project intentionally uses a two-frame walk loop. "
                                f"All {DEFAULT_FRAMES_PER_ACTION} frames must face and walk right in side view; reject any left-facing, front-facing, sitting, "
                                "or loafing frame. Row 3 frames 1 and 2 must be two distinct walking key poses; "
                                f"Row 3 frames 3 through {DEFAULT_FRAMES_PER_ACTION} intentionally repeat Row 3 frames 1 and 2 for looping. "
                                "Reject if frames 1 and 2 are the same paw stance or only body translation. "
                                "Row 4 look should use a stable body with head/eye direction changes. "
                                "Row 6 tail_wag may be subtle; a small visible tail or rear-body variation is acceptable for a calm desktop pet. "
                                "Do not reject solely because tail_wag resembles idle or look when identity, full-body visibility, "
                                "walk direction, and sleep direction are acceptable. "
                                "Also check same pet identity, full-body visibility, calm low-disturbance tone, and no text/scenery. "
                                "Return JSON with keys score (0-1), passed (boolean), identity_ok (boolean), "
                                "row_semantics_ok (boolean), direction_ok (boolean), row_distinct_ok (boolean), "
                                "gait_cycle_ok (boolean for Row 3 walk), two_frame_loop_ok (boolean for Row 3 walk), "
                                "repair_actions (array of action names needing regeneration), notes (short), risks (array)."
                            ),
                        },
                        {"type": "image_url", "image_url": {"url": data_url}},
                    ],
                },
            ],
            "temperature": 0,
        }
        raw = self._post_chat_completions(payload)
        content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
        return {
            "status": "ok",
            "provider": "apimart",
            "model": self.model,
            "response_id": raw.get("id"),
            "review": _parse_review_content(content),
        }

    def _post_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
        body = json.dumps(payload).encode("utf-8")
        request = Request(
            _chat_completions_url(self.base_url),
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {self.api_key}",
                "Content-Type": "application/json",
            },
        )
        try:
            with self.opener(request, timeout=self.request_timeout_seconds) as response:
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as exc:
            raise APIMartRequestError(f"APIMart review request failed with HTTP {exc.code}") from exc
        except URLError as exc:
            raise APIMartRequestError("APIMart review request failed before receiving a response") from exc
        except json.JSONDecodeError as exc:
            raise APIMartRequestError("APIMart review response was not valid JSON") from exc


def _read_env_file(path: Path) -> dict[str, str]:
    if not path.exists():
        return {}
    values: dict[str, str] = {}
    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        values[key.strip()] = value.strip().strip('"').strip("'")
    return values


def _env_float(values: dict[str, str], key: str, default: float) -> float:
    try:
        return float(str(values.get(key, default)).strip())
    except (TypeError, ValueError):
        return default


def _to_data_url(image_bytes: bytes, mime: str) -> str:
    return f"data:{mime};base64,{base64.b64encode(image_bytes).decode('ascii')}"


def _normalize_size(size: str) -> str:
    clean = str(size or "").strip().lower()
    if not clean:
        return "1:1"
    if ":" in clean or clean == "auto":
        return clean
    if "*" in clean:
        clean = clean.replace("*", "x")
    if "x" in clean:
        width_text, height_text = clean.split("x", 1)
        try:
            width = int(width_text)
            height = int(height_text)
        except ValueError:
            return clean
        if width == height:
            return "1:1"
        ratio = _gcd(width, height)
        return f"{width // ratio}:{height // ratio}"
    return clean


def _gcd(left: int, right: int) -> int:
    while right:
        left, right = right, left % right
    return max(abs(left), 1)


def _extract_task_id(payload: dict[str, Any]) -> str:
    if payload.get("code") not in (None, 200):
        raise APIMartRequestError(f"APIMart image request returned code {payload.get('code')}")
    data = payload.get("data")
    if isinstance(data, list) and data:
        task_id = data[0].get("task_id") if isinstance(data[0], dict) else None
    elif isinstance(data, dict):
        task_id = data.get("task_id") or data.get("id")
    else:
        task_id = None
    if not task_id:
        raise APIMartRequestError("APIMart image request did not return task_id")
    return str(task_id)


def _extract_completed_image_url(payload: dict[str, Any]) -> str:
    data = payload.get("data", {}) if isinstance(payload, dict) else {}
    result = data.get("result", {}) if isinstance(data, dict) else {}
    images = result.get("images", []) if isinstance(result, dict) else []
    if isinstance(images, list):
        for image in images:
            urls = image.get("url") if isinstance(image, dict) else None
            if isinstance(urls, list) and urls:
                return str(urls[0])
            if isinstance(urls, str):
                return urls
    raise APIMartRequestError("APIMart completed task did not include an image URL")


def _safe_usage(data: dict[str, Any]) -> dict[str, Any]:
    return {
        "cost": data.get("cost"),
        "credits_cost": data.get("credits_cost"),
        "actual_time": data.get("actual_time"),
        "estimated_time": data.get("estimated_time"),
        "progress": data.get("progress"),
    }


def _sha256_text(text: str) -> str:
    import hashlib

    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def _chat_completions_url(base_url: str) -> str:
    clean = base_url.rstrip("/")
    if clean.endswith("/chat/completions"):
        return clean
    return f"{clean}/chat/completions"


def _action_visual_review_requirements(action: str) -> str:
    requirements = {
        "sleep": (
            f"Hard rules for sleep: all {DEFAULT_FRAMES_PER_ACTION} frames must be low lying, curled, or clearly sleeping; "
            "reject upright sitting, standing, walking, or any flipped opposite-facing sleep direction."
        ),
        "walk": (
            "Hard rules for walk: this project intentionally uses a two-frame walk loop. "
            f"All {DEFAULT_FRAMES_PER_ACTION} frames must face and walk right in side view; "
            f"frames 1 and 2 must be two distinct walking key poses, and frames 3 through {DEFAULT_FRAMES_PER_ACTION} intentionally repeat frames 1 and 2. "
            "Reject any left-facing, front-facing, sitting, or loafing frame; "
            "reject if frames 1 and 2 are the same paw stance or only body translation. "
            f"Set two_frame_loop_ok=true when frames 1 and 2 form a readable two-pose walk cycle and frames 3 through {DEFAULT_FRAMES_PER_ACTION} correctly copy them. "
            "Set gait_cycle_ok=true when the two-frame loop is acceptable for a small desktop pet."
        ),
        "look": (
            f"Hard rules for look: keep one stable body orientation across all {DEFAULT_FRAMES_PER_ACTION} frames; "
            "only the head or eyes may move gently; reject full-body turns or body-direction changes within the row."
        ),
        "sit": (
            f"Hard rules for sit: all {DEFAULT_FRAMES_PER_ACTION} frames must remain seated with rear and hind legs on the ground; "
            "reject standing, walking, or four-paw support poses."
        ),
        "tail_wag": (
            "Hard rules for tail_wag: tail motion may be subtle, but the tail or rear-body variation must be visible; "
            "do not reject solely because the row is calm or low-disturbance."
        ),
    }
    return requirements.get(action, "Hard rules: preserve the same pet identity and reject wrong action semantics.")


def _parse_review_content(content: str) -> dict[str, Any]:
    json_text = _extract_json_text(content)
    try:
        parsed = json.loads(json_text)
    except json.JSONDecodeError:
        parsed = {"score": None, "passed": None, "notes": json_text[:240], "risks": ["non_json_review"]}
    if not isinstance(parsed, dict):
        parsed = {"score": None, "passed": None, "notes": str(parsed)[:240], "risks": ["unexpected_review_shape"]}
    parsed.setdefault("risks", [])
    return parsed


def _extract_json_text(content: str) -> str:
    stripped = str(content or "").strip()
    if stripped.startswith("```"):
        lines = stripped.splitlines()
        if lines and lines[0].startswith("```"):
            lines = lines[1:]
        if lines and lines[-1].strip().startswith("```"):
            lines = lines[:-1]
        stripped = "\n".join(lines).strip()
    if "{" in stripped and "}" in stripped:
        start = stripped.find("{")
        end = stripped.rfind("}") + 1
        return stripped[start:end]
    return stripped


def _build_canonical_pet_prompt(*, pet_name: str, notes: str, analysis: dict[str, Any]) -> str:
    safe_notes = " ".join(notes.strip().split())[:160]
    return (
        "Create one high-fidelity 2D memorial desktop pet from the authorized reference images. "
        "Generate exactly one complete full-body pet on a plain white background, centered with safe padding. "
        "Preserve the same pet identity, face, coat texture, fur markings, body proportions, eye color, and calm familiar presence. "
        "This is not pixel art, not a sticker, not a toy, and not a mascot redesign. "
        "Do not add text, logos, humans, room scenery, speech bubbles, tears, halos, therapy claims, resurrection framing, chatbot framing, or productivity elements. "
        f"Pet display name: {pet_name[:80]}. "
        f"Visual traits: species={analysis.get('species', 'pet')}, base_color={analysis.get('base_color', 'unknown')}, "
        f"accent_color={analysis.get('accent_color', 'unknown')}, eye_color={analysis.get('eye_color', 'unknown')}, "
        f"body_shape={analysis.get('body_shape', 'unknown')}. "
        f"Owner action cues only, not text to render: {safe_notes}."
    )


def _build_action_frame_prompt(
    *,
    action: str,
    frame_index: int,
    candidate_index: int,
    pet_name: str,
    notes: str,
    analysis: dict[str, Any],
    has_previous_frame: bool,
) -> str:
    action_guidance = {
        "idle": "post-drag temporary idle: standing on all four paws, back mostly horizontal, hips off the ground, not a seated pose",
        "sleep": (
            "same-direction calm sleeping pose, very low disturbance; all frames must keep the same left-to-right "
            "sleeping orientation, with the head on the same side and the body/tail on the same side"
        ),
        "walk": (
            "right-facing side-view gentle walking pose; this pipeline generates only frames 1 and 2, "
            f"then locally copies them as frames 3 through {DEFAULT_FRAMES_PER_ACTION}. Frames 1 and 2 must be visibly different paw phases, "
            "not just a shifted standing pose"
        ),
        "look": (
            f"quiet looking-around frame; keep the same body orientation across all {DEFAULT_FRAMES_PER_ACTION} look frames; "
            "only the head and eyes change slightly"
        ),
        "sit": "stable seated pose with tiny posture variation",
        "tail_wag": (
            "The tail is the main action. Use the same side-view or rear-three-quarter body pose, "
            "keep the body almost still, and make the tail clearly visible in every frame"
        ),
    }.get(action, "low-disturbance action pose")
    frame_guidance = _action_frame_guidance(action, frame_index)
    if has_previous_frame and action == "walk" and frame_index == 2:
        previous_frame_line = "Use the second reference image only for visual continuity with the previous action frame. "
    elif has_previous_frame and action == "sleep":
        previous_frame_line = (
            "Use the second reference image as a strict direction lock for sleep: keep the same head side, body side, "
            "baseline, and lying orientation; do not mirror, flip, rotate, sit up, or change to the opposite side. "
        )
    elif has_previous_frame:
        previous_frame_line = "Use the second reference image only for visual continuity with the previous action frame. "
    else:
        previous_frame_line = ""
    if candidate_index > 0 and action == "walk":
        repair_line = (
            "This is a repair candidate after gait QA failure: previous images looked like the same paw stance. "
            "Make the current frame's front-leg and hind-leg phase unmistakably different, with one lifted paw visibly off the ground. "
        )
    elif candidate_index > 0 and action == "sleep":
        repair_line = (
            "This is a sleep-direction repair candidate after final QA found a flipped sleep frame. "
            "Every sleep frame must stay in the exact same sleeping direction: head remains on the right side of the body, "
            "body and tail remain on the left side, and the pet stays low/lying. Do not create any mirrored or opposite-facing frame. "
        )
    elif candidate_index > 0:
        repair_line = "This is a repair candidate; make the requested action clearer. "
    else:
        repair_line = ""
    return (
        f"Generate one complete 2D memorial desktop pet action frame for action '{action}', frame {frame_index + 1} of {DEFAULT_FRAMES_PER_ACTION}. "
        f"Action meaning: {action_guidance}. "
        f"{frame_guidance}"
        f"{repair_line}"
        f"{previous_frame_line}"
        "Keep the same pet identity as the reference image: same face, coat markings, body proportions, eye color, and calm tone. "
        "Keep the same camera distance, same apparent body size, and similar safe padding as the reference image and the other action frames. "
        "Use a flat pure blue #0000FF background for local chroma-key removal. Do not create transparent pixels. "
        "Only one full-body pet should appear, centered with safe padding. No grid, no text, no labels, no shadows, no scenery, no speech bubble. "
        "Do not imply the pet is alive, conscious, revived, therapeutic, chatty, or productivity-related. "
        f"Pet display name: {pet_name[:80]}. "
        f"Visual traits: species={analysis.get('species', 'pet')}, base_color={analysis.get('base_color', 'unknown')}. "
        f"Owner action cues only, not text to render: {' '.join(notes.strip().split())[:120]}."
    )


def _build_action_strip_prompt(
    *,
    action: str,
    candidate_index: int = 0,
    pet_name: str,
    notes: str,
    analysis: dict[str, Any],
) -> str:
    action_prompt, requirements = _action_strip_requirements(action)
    requirement_lines = " ".join(f"- {line}" for line in requirements)
    repair_line = ""
    if candidate_index > 0:
        repair_line = (
            "This is a repair candidate after local action QA rejected the previous strip. "
            "Make the requested action visibly clearer while preserving the same identity and stable scale. "
        )
        if action == "walk":
            repair_line += (
                "For walk, frames 1 and 2 must show clearly different paw phases and a side-oriented walking body, "
                "not only a translated standing pose. "
            )
    if action == "walk":
        repair_line += (
            "Walk strip hard rule: create a two-pose walk loop. "
            "Slots 1 and 3 must show the same first key pose: right-facing side view, front paw reaching forward, hind paw stretching back. "
            "Slots 2 and 4 must show the same second key pose: same right-facing side view, clearly opposite paw phases, one front paw lifted under the chest and one hind paw passing under the body. "
            "Keep body size, head, markings, and baseline stable; change paw placement enough to read at desktop size. "
            "Do not show four near-identical standing poses, do not merely translate the same silhouette, and do not change direction. "
        )
    return (
        f"Create one horizontal animation strip for memorial desktop pet '{pet_name[:80]}', state '{action}'. "
        f"{repair_line}"
        f"Use the first attached image only as the layout guide for {DEFAULT_FRAMES_PER_ACTION} equal slots, spacing, centering, and safe padding. "
        "Use the second attached image as the only pet identity reference: face, coat markings, body proportions, eye color, "
        "tail visibility, and familiar calm expression must stay consistent. "
        "Do not copy layout guide pixels, colors, borders, center marks, labels, or background into the result. "
        f"Output exactly {DEFAULT_FRAMES_PER_ACTION} separate full-body versions of the same pet in one left-to-right horizontal animation strip. "
        f"Treat the row as {DEFAULT_FRAMES_PER_ACTION} invisible equal-width slots: one centered complete pose per slot, evenly spaced, no overlap, "
        "no clipping, no empty slots, no labels, no borders, and no merged bodies. "
        f"Use a zoomed-out sprite-sheet camera so all {DEFAULT_FRAMES_PER_ACTION} complete bodies fit; never fill the canvas with one large animal. "
        "Use a flat pure blue #0000FF background for local chroma-key removal. Do not create transparent pixels. "
        "Identity must stay the same in every frame: preserve face, coat texture, fur markings, body proportions, eye color, "
        "calm memorial tone, and high-fidelity 2D look from the canonical base. "
        "Animation continuity: keep apparent pet scale stable within the row and change pose within each slot. "
        f"State action: {action_prompt}. "
        f"State requirements: {requirement_lines} "
        "Clean extraction: crisp opaque pet edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, "
        "motion blur, speed lines, dust, detached effects, stray pixels, halos, speech bubbles, therapy claims, resurrection framing, "
        "chatbot framing, productivity-assistant framing, pixel art, blocky pixels, or 8-bit style. "
        f"Visual traits from analysis: species={analysis.get('species', 'pet')}, base_color={analysis.get('base_color', 'unknown')}, "
        f"accent_color={analysis.get('accent_color', 'unknown')}, eye_color={analysis.get('eye_color', 'unknown')}. "
        f"Owner action cues only, not text to render: {' '.join(notes.strip().split())[:120]}."
    )


def _action_strip_requirements(action: str) -> tuple[str, list[str]]:
    requirements = {
        "idle": (
            "Calm low-distraction resting loop with subtle breathing, tiny blink, and slight head or body bob",
            [
                f"Keep the pet essentially in the same calm baseline pose across all {DEFAULT_FRAMES_PER_ACTION} frames.",
                "Do not show walking, running, jumping, talking, or emotional drama.",
            ],
        ),
        "sleep": (
            "Quiet sleeping loop with lying, curled, or clearly low sleeping body posture",
            [
                "Every frame must read as sleep: lying, curled, or low relaxed body, not sitting upright.",
                f"All {DEFAULT_FRAMES_PER_ACTION} frames must keep the same sleeping direction: head on the right side, body/tail on the left side.",
                "Do not mirror, flip, rotate, or switch the side the pet is lying on between slots.",
                "Eyes should be closed or drowsy and the body should stay low with tiny breathing or ear/paw variation.",
            ],
        ),
        "walk": (
            "Gentle walking loop in side-oriented body posture",
            [
                "Every frame must read as walking, with side-oriented body and alternating paw positions.",
                "Do not reuse the canonical front-facing sitting or standing pose.",
                "No speed lines, dust, ground shadows, or scenery.",
            ],
        ),
        "look": (
            "Gentle looking-around loop with stable body direction and head or eye angle changes",
            [
                "Keep the same body orientation in every frame; only the head and eyes may shift gently.",
                "Do not turn the whole body around or change body direction within the row.",
                "Do not make the pet talk, emote dramatically, or add symbols.",
            ],
        ),
        "sit": (
            "Settled seated loop with small posture and head variation",
            [
                "A seated pose is allowed here, but frames must still vary gently.",
                "Preserve the same body proportions and markings.",
            ],
        ),
        "tail_wag": (
            "Visible tail small-motion loop, turning the body if needed so the tail can be seen",
            [
                "The tail must be visible in most frames unless the real animal has no visible tail.",
                "Show left, center, right, and return tail positions through pose only.",
                "Do not add motion marks or detached effects.",
            ],
        ),
    }
    return requirements.get(
        action,
        (
            "Low-disturbance memorial desktop pet action loop",
            [f"Preserve identity and generate {DEFAULT_FRAMES_PER_ACTION} clearly separated full-body frames."],
        ),
    )


def _frame_phase(phases: tuple[str, ...], frame_index: int) -> str:
    phase_index = frame_index % len(phases)
    phase = phases[phase_index]
    legacy_prefix = f"Frame {phase_index + 1} of {len(phases)}"
    if phase.startswith(legacy_prefix):
        return f"Frame {frame_index + 1} of {DEFAULT_FRAMES_PER_ACTION}" + phase[len(legacy_prefix) :]
    return f"Frame {frame_index + 1} of {DEFAULT_FRAMES_PER_ACTION}: {phase}"


def _action_frame_guidance(action: str, frame_index: int) -> str:
    if action == "idle":
        phases = (
            "standing on all four paws right after dragging stops; back mostly horizontal, hips off the ground, not sitting. ",
            "still standing on all four paws, with only tiny breathing, blink, or tail-tip variation; not walking and not seated, hips off the ground. ",
            "standing on all four paws with a small relaxed body variation; back remains mostly horizontal, hips off the ground, do not sit down. ",
            "returns near frame 1's all-fours standing rest so the loop can hold before switching to sit later; not a seated pose. ",
        )
        return (
            _frame_phase(phases, frame_index)
            + "This idle row is the post-drag temporary state before sit; it must look clearly different from the seated sit row. "
            + f"Across all {DEFAULT_FRAMES_PER_ACTION} idle frames, keep the same body facing direction, same baseline, same silhouette width and height, same camera distance, and same safe padding; only tiny breathing, blink, ear, or tail-tip motion should change. Do not switch between side view and front view. "
        )
    if action == "tail_wag":
        phases = (
            "tail is clearly visible and curves to one side; body and head stay calm and nearly still. ",
            "tail moves toward the center or upward; body pose stays the same as frame 1. ",
            "tail clearly curves to the opposite side; body pose stays the same as frame 1. ",
            "tail returns toward the first-frame side so the loop can continue; body pose stays the same. ",
        )
        return _frame_phase(phases, frame_index) + "Do not replace the tail wag with head movement or body movement. "
    if action == "sleep":
        phases = (
            "low lying or curled sleeping pose, head on the right side, body and tail on the left side. ",
            "same sleeping direction as frame 1 with only tiny breathing, ear, or paw variation; head still right, body/tail still left. ",
            "same sleeping direction as frame 1 with a subtle breathing or drowsy variation; do not mirror or rotate. ",
            "return near frame 1 so the sleep loop is calm; head still right, body/tail still left. ",
        )
        return (
            _frame_phase(phases, frame_index)
            + "Hard rule: never switch sleep direction between frames, never place the head on the opposite side, never sit upright, and never use a front-facing pose. "
        )
    if action == "look":
        phases = (
            "calm neutral look; keep the same body facing direction as the reference. ",
            "head and eyes look slightly upward while the body orientation stays unchanged. ",
            "head and eyes look slightly forward or downward while the body orientation stays unchanged. ",
            "head returns near the neutral look while the body orientation stays unchanged. ",
        )
        return _frame_phase(phases, frame_index) + "Do not turn the whole body around or change the body facing direction between frames. "
    if action == "walk":
        phases = (
            "right-facing side view; the visible front paw on the right reaches far forward; the opposite/front support paw is vertical under the shoulder; the visible hind paw on the left stretches backward; the other hind paw supports under the hip. ",
            "right-facing side view; the forward front paw from frame 1 is now vertical and planted under the shoulder; the other front paw is visibly lifted off the ground under the chest, moving forward; hind paws pass under the body with one hind paw lifted. ",
            "created locally by copying frame 1; this frame should not be requested from the model. ",
            "created locally by copying frame 2; this frame should not be requested from the model. ",
        )
        return (
            _frame_phase(phases, frame_index)
            + "Only frames 1 and 2 are model-generated key poses. Make frames 1 and 2 clearly different so the local two-frame loop reads as walking. Exaggerate the paw spacing slightly for small desktop-sprite readability while keeping the same realistic pet identity. Do not keep both generated frames in the same standing pose; this must be a readable two-pose walk loop, not just a shifted standing pose. "
        )
    return ""
