"""Local Ollama vision adapter for desktop screenshot verification (0.2.2 V-7)."""

from __future__ import annotations

import base64
import json
import os
import re
from dataclasses import dataclass
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from iuve_connect_agents.shared.feature_flags import avatar_desktop_vision_local_enabled

DEFAULT_VISION_MODEL = "llava"
DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434"
DEFAULT_VISION_TIMEOUT_SEC = 120
MAX_IMAGE_BYTES = 2 * 1024 * 1024

_VISION_SYSTEM_PROMPT = (
    "You verify desktop screenshots for an automation agent. "
    "Answer the user question about the image. "
    "Respond with JSON only, no markdown: "
    '{"pass": <bool>, "confidence": <0.0-1.0>, "findings": "<short explanation>"}'
)

PostChatFn = Callable[..., dict[str, Any]]


@dataclass(frozen=True)
class VisionLocalResult:
    passed: bool
    confidence: float
    findings: str
    method: str = "local_vision"
    provider_id: str = "ollama_local"
    model: str = ""
    unavailable_reason: str = ""

    def as_dict(self) -> dict[str, Any]:
        payload: dict[str, Any] = {
            "pass": self.passed,
            "confidence": self.confidence,
            "findings": self.findings,
            "method": self.method,
            "provider_id": self.provider_id,
        }
        if self.model:
            payload["model"] = self.model
        if self.unavailable_reason:
            payload["unavailable_reason"] = self.unavailable_reason
        return payload


def avatar_vision_model_local() -> str:
    raw = os.getenv("AVATAR_VISION_MODEL_LOCAL", DEFAULT_VISION_MODEL).strip()
    return raw or DEFAULT_VISION_MODEL


def ollama_base_url() -> str:
    raw = os.getenv("OLLAMA_BASE_URL", DEFAULT_OLLAMA_BASE_URL).strip()
    return raw.rstrip("/") or DEFAULT_OLLAMA_BASE_URL


def ollama_vision_timeout_sec() -> int:
    raw = os.getenv("OLLAMA_TIMEOUT_VISION_SEC")
    if raw is None:
        return DEFAULT_VISION_TIMEOUT_SEC
    try:
        return max(5, int(raw))
    except ValueError:
        return DEFAULT_VISION_TIMEOUT_SEC


_LOCAL_UNAVAILABLE_FOR_CLOUD = frozenset(
    {
        "ollama_request_failed",
        "ollama_model_missing",
        "local_vision_disabled",
        "unparseable_response",
    }
)


def _cloud_fallback_eligible(*, screen_vision_granted: bool) -> bool:
    from iuve_connect_agents.shared.feature_flags import cloud_vision_verify_available

    return cloud_vision_verify_available(screen_vision_granted=screen_vision_granted)


def _request_cloud_vision(
    *,
    prompt: str,
    image_bytes: bytes,
    screen_vision_granted: bool,
    expected_text: str = "",
    plan_id: str = "",
    step_id: str = "",
    evidence_sha256: str = "",
) -> VisionLocalResult | None:
    if not _cloud_fallback_eligible(screen_vision_granted=screen_vision_granted):
        return None

    from iuve_connect_agents.shared.avatar_vision_cloud import request_cloud_verify_vision

    cloud_payload = request_cloud_verify_vision(
        verify_prompt=prompt,
        image_bytes=image_bytes,
        expected_text=expected_text,
        plan_id=plan_id,
        step_id=step_id,
        evidence_sha256=evidence_sha256,
    )
    if not cloud_payload:
        return None

    return VisionLocalResult(
        passed=bool(cloud_payload.get("pass")),
        confidence=_clamp_confidence(cloud_payload.get("confidence")),
        findings=str(cloud_payload.get("findings") or "").strip() or "cloud vision verify completed",
        method=str(cloud_payload.get("method") or "cloud_vision"),
        provider_id=str(cloud_payload.get("provider_id") or "cloud"),
        unavailable_reason=str(cloud_payload.get("unavailable_reason") or ""),
    )


def analyze_vision_with_fallback(
    *,
    prompt: str,
    image_bytes: bytes,
    screen_vision_granted: bool,
    expected_text: str = "",
    plan_id: str = "",
    step_id: str = "",
    evidence_sha256: str = "",
    post_chat: PostChatFn | None = None,
) -> VisionLocalResult:
    """Cloud orchestra verify by default; local Ollama only when explicitly enabled (dev opt-in)."""
    local_enabled = avatar_desktop_vision_local_enabled()
    cloud_eligible = _cloud_fallback_eligible(screen_vision_granted=screen_vision_granted)

    if cloud_eligible:
        cloud_result = _request_cloud_vision(
            prompt=prompt,
            image_bytes=image_bytes,
            screen_vision_granted=screen_vision_granted,
            expected_text=expected_text,
            plan_id=plan_id,
            step_id=step_id,
            evidence_sha256=evidence_sha256,
        )
        if cloud_result is not None:
            if cloud_result.passed or not local_enabled:
                return cloud_result
        elif not local_enabled:
            return VisionLocalResult(
                passed=False,
                confidence=0.0,
                findings="cloud vision verify unavailable",
                unavailable_reason="cloud_vision_unavailable",
            )

    if not local_enabled:
        return VisionLocalResult(
            passed=False,
            confidence=0.0,
            findings="cloud desktop vision is disabled by feature flags"
            if not cloud_eligible
            else "cloud vision verify unavailable",
            unavailable_reason="cloud_vision_disabled" if not cloud_eligible else "cloud_vision_unavailable",
        )

    local_result = analyze_local_vision(
        prompt=prompt,
        image_bytes=image_bytes,
        screen_vision_granted=screen_vision_granted,
        post_chat=post_chat,
    )
    if local_result.passed or local_result.unavailable_reason not in _LOCAL_UNAVAILABLE_FOR_CLOUD:
        return local_result

    cloud_result = _request_cloud_vision(
        prompt=prompt,
        image_bytes=image_bytes,
        screen_vision_granted=screen_vision_granted,
        expected_text=expected_text,
        plan_id=plan_id,
        step_id=step_id,
        evidence_sha256=evidence_sha256,
    )
    if cloud_result is not None:
        return cloud_result

    return local_result


def analyze_local_vision(
    *,
    prompt: str,
    image_bytes: bytes,
    screen_vision_granted: bool,
    post_chat: PostChatFn | None = None,
) -> VisionLocalResult:
    """Run bounded local vision analysis: prompt + image bytes → pass/confidence/findings."""
    model = avatar_vision_model_local()

    if not screen_vision_granted:
        return VisionLocalResult(
            passed=False,
            confidence=0.0,
            findings="screen_vision grant required for local vision analysis",
            model=model,
            unavailable_reason="screen_vision_grant_required",
        )

    if not avatar_desktop_vision_local_enabled():
        return VisionLocalResult(
            passed=False,
            confidence=0.0,
            findings="local desktop vision is disabled by feature flags",
            model=model,
            unavailable_reason="local_vision_disabled",
        )

    safe_prompt = str(prompt or "").strip()
    if not safe_prompt:
        return VisionLocalResult(
            passed=False,
            confidence=0.0,
            findings="vision prompt is required",
            model=model,
            unavailable_reason="missing_prompt",
        )

    payload = image_bytes or b""
    if not payload:
        return VisionLocalResult(
            passed=False,
            confidence=0.0,
            findings="screenshot image bytes are required",
            model=model,
            unavailable_reason="missing_image",
        )
    if len(payload) > MAX_IMAGE_BYTES:
        return VisionLocalResult(
            passed=False,
            confidence=0.0,
            findings="screenshot exceeds local vision size limit",
            model=model,
            unavailable_reason="image_too_large",
        )

    encoded = base64.b64encode(payload).decode("ascii")
    messages = [
        {"role": "system", "content": _VISION_SYSTEM_PROMPT},
        {
            "role": "user",
            "content": safe_prompt,
            "images": [encoded],
        },
    ]

    caller = post_chat or _post_ollama_chat
    try:
        response = caller(
            base_url=ollama_base_url(),
            model=model,
            messages=messages,
            timeout_sec=ollama_vision_timeout_sec(),
        )
    except (HTTPError, URLError, TimeoutError, ValueError) as exc:
        unavailable_reason = "ollama_request_failed"
        if isinstance(exc, HTTPError) and int(getattr(exc, "code", 0) or 0) == 404:
            unavailable_reason = "ollama_model_missing"
        return VisionLocalResult(
            passed=False,
            confidence=0.0,
            findings=f"ollama vision request failed: {exc}",
            model=model,
            unavailable_reason=unavailable_reason,
        )

    content = _extract_message_content(response)
    parsed = _parse_vision_json(content)
    if parsed is None:
        return VisionLocalResult(
            passed=False,
            confidence=0.0,
            findings=f"could not parse vision model response: {content[:240]}",
            model=model,
            unavailable_reason="unparseable_response",
        )

    return VisionLocalResult(
        passed=bool(parsed.get("pass")),
        confidence=_clamp_confidence(parsed.get("confidence")),
        findings=str(parsed.get("findings") or "").strip() or content.strip(),
        model=model,
    )


def _post_ollama_chat(
    *,
    base_url: str,
    model: str,
    messages: list[dict[str, Any]],
    timeout_sec: int,
) -> dict[str, Any]:
    url = f"{base_url.rstrip('/')}/api/chat"
    body = json.dumps(
        {
            "model": model,
            "messages": messages,
            "stream": False,
        }
    ).encode("utf-8")
    request = Request(
        url,
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urlopen(request, timeout=max(5, int(timeout_sec))) as response:
        raw = response.read().decode("utf-8")
    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise ValueError("ollama_response_not_object")
    return payload


def _extract_message_content(response: dict[str, Any]) -> str:
    message = response.get("message")
    if isinstance(message, dict):
        return str(message.get("content") or "").strip()
    return str(response.get("response") or "").strip()


def _parse_vision_json(content: str) -> dict[str, Any] | None:
    text = str(content or "").strip()
    if not text:
        return None

    candidates = [text]
    fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL | re.IGNORECASE)
    if fence_match:
        candidates.insert(0, fence_match.group(1))
    brace_match = re.search(r"\{.*\}", text, flags=re.DOTALL)
    if brace_match:
        candidates.append(brace_match.group(0))

    for candidate in candidates:
        try:
            parsed = json.loads(candidate)
        except json.JSONDecodeError:
            continue
        if isinstance(parsed, dict) and "pass" in parsed:
            return parsed
    return None


def _clamp_confidence(value: Any) -> float:
    try:
        numeric = float(value)
    except (TypeError, ValueError):
        return 0.0
    if numeric < 0.0:
        return 0.0
    if numeric > 1.0:
        return 1.0
    return numeric
