"""Generic web operator helpers — any URL from user text (0.2.2.41 MVP)."""

from __future__ import annotations

import json
import logging
import re
import shutil
import subprocess
import time
from typing import Any, Callable
from urllib.parse import urlparse

from .website_resolver import (
    TargetSite,
    extract_target_site,
    extract_target_url as _extract_target_url,
    extract_url_from_prompt,
    prompt_specifies_site,
)

logger = logging.getLogger(__name__)

__all__ = [
    "TargetSite",
    "WEB_DRAFT_MAX_RECOVERY_ATTEMPTS",
    "WEB_DRAFT_SNIPPET_CHARS",
    "WEB_DRAFT_STATUSES",
    "build_composer_focus_payload",
    "build_composer_locate_prompt",
    "build_login_detect_prompt",
    "build_publish_context_verify_prompt",
    "build_web_draft_verify_prompt",
    "classify_web_draft_state",
    "default_page_wait_sec",
    "draft_verify_snippet",
    "extract_target_url",
    "extract_url_from_prompt",
    "honest_composer_limitation",
    "parse_draft_vision_fields",
    "prompt_specifies_site",
    "publish_stop_zone_payload",
    "recover_composer_and_retry",
    "resolve_target_site_for_prompt",
    "open_web_composer",
    "focus_mytape_composer",
    "is_mytape_site",
    "parse_composer_locate_fields",
    "verify_typing_focus",
    "should_stop_before_publish",
    "step_requests_publish_submit",
    "url_host_label",
    "user_expects_logged_in",
    "user_requests_explicit_publish",
    "verify_web_draft",
]

WEB_DRAFT_STATUSES = frozenset(
    {
        "composer_found",
        "composer_not_found",
        "draft_inserted",
        "draft_verified",
        "draft_created",
        "draft_not_created",
        "draft_content_mismatch",
        "typed_wrong_control",
        "typing_failed",
        "publish_requested",
        "publish_confirmation_required",
        "publish_clicked",
        "publish_verified",
        "publish_failed",
        "publish_button_not_found",
        "pending_verification",
    }
)
PUBLISH_BUTTON_LABELS = (
    "Опубликовать",
    "Publish",
    "Post",
    "Tweet",
    "Send",
    "Отправить",
)
WEB_DRAFT_MAX_RECOVERY_ATTEMPTS = 2
WEB_DRAFT_SNIPPET_CHARS = 48

VisionAnalyzeFn = Callable[..., Any]
TypeTextFn = Callable[[dict[str, Any]], dict[str, Any]]
VerifyDraftFn = Callable[[dict[str, Any]], dict[str, Any]]
FocusComposerFn = Callable[[dict[str, Any]], tuple[bool, str]]

_LOGIN_HINT_RE = re.compile(
    r"\b(?:авторизован|logged\s+in|signed\s+in|уже\s+вош|already\s+auth)\b",
    re.IGNORECASE,
)
_PUBLISH_STOP_RE = re.compile(
    r"\b(?:не\s+опублик|без\s+опублик|не\s+жми|don't\s+publish|do\s+not\s+publish|draft\s+only)\b",
    re.IGNORECASE,
)
_PUBLISH_INTENT_RE = re.compile(
    r"\b(?:опубликуй|и\s+опублик|and\s+publish|publish\s+now|отправь\s+письмо|send\s+email)\b",
    re.IGNORECASE,
)
_EXPLICIT_PUBLISH_CLICK_RE = re.compile(
    r"(?:"
    r"нажми\s+(?:кнопку\s+)?опубликовать|"
    r"нажми\s+опубликовать|"
    r"и\s+(?:потом\s+)?нажми\s+(?:кнопку\s+)?опубликовать|"
    r"\bопубликуй\b|"
    r"\bpublish\b|"
    r"\bsend\b|"
    r"\bsubmit\b|"
    r"click\s+(?:the\s+)?publish|"
    r"press\s+publish|"
    r"publish\s+button"
    r")",
    re.IGNORECASE,
)
_SUBMIT_BUTTON_RE = re.compile(
    r"\b(?:submit\s+button|кнопк[ау]\s+(?:отправ|опублик|publish|submit))\b",
    re.IGNORECASE,
)
_COMPOSER_HINTS = (
    "composer",
    "editor",
    "textarea",
    "post",
    "draft",
    "напиш",
    "write",
)
_JSON_OBJECT_RE = re.compile(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", re.DOTALL)


def extract_target_url(text: str, *, memory_site: str = "") -> str | None:
    """Extract target URL with prompt-first priority (delegates to website_resolver)."""
    return _extract_target_url(text, memory_site=memory_site)


def resolve_target_site_for_prompt(text: str, *, memory_site: str = "") -> TargetSite | None:
    site = extract_target_site(text, memory_site=memory_site)
    if site is not None and getattr(site, "url", ""):
        try:
            from .runtime_context import update_from_web_operator

            update_from_web_operator(url=str(site.url))
        except Exception:
            pass
    return site


def url_host_label(url: str) -> str:
    host = (urlparse(str(url or "")).hostname or "").strip().lower().removeprefix("www.")
    return host or "site"


def default_page_wait_sec(*, logged_in_hint: bool = False) -> float:
    return 4.0 if logged_in_hint else 6.0


def user_expects_logged_in(text: str) -> bool:
    return bool(_LOGIN_HINT_RE.search(str(text or "")))


def user_requests_explicit_publish(text: str) -> bool:
    """True when user explicitly asks to click Publish (requires stop-zone confirm)."""
    raw = str(text or "")
    if not raw.strip():
        return False
    if _PUBLISH_STOP_RE.search(raw):
        return False
    return bool(_EXPLICIT_PUBLISH_CLICK_RE.search(raw))


def should_stop_before_publish(text: str) -> bool:
    """Never auto-click Publish/Submit — user must confirm via stop-zone modal."""
    raw = str(text or "")
    if _PUBLISH_INTENT_RE.search(raw):
        return True
    if _PUBLISH_STOP_RE.search(raw):
        return True
    lower = raw.lower()
    if "опублик" in lower and not any(token in lower for token in ("опубликуй", "и опублик", "and publish")):
        return True
    return True


def step_requests_publish_submit(*, command_type: str, payload: dict[str, Any] | None = None, step_text: str = "") -> bool:
    """Detect publish/send/submit steps for plan policy (stop-zone, not auto-execute)."""
    from iuve_connect_agents.shared.production_policy import (
        is_publish_submit_action,
        step_text_indicates_publish_submit,
    )

    if is_publish_submit_action(command_type, payload):
        return True
    if _SUBMIT_BUTTON_RE.search(step_text):
        return True
    return step_text_indicates_publish_submit(step_text)


def publish_stop_zone_payload() -> dict[str, Any]:
    return {"stop_zone": "publish", "requires_user_confirm": True}


def build_login_detect_prompt(*, url: str) -> str:
    host = url_host_label(url)
    return (
        f"На странице {host} определи: пользователь уже авторизован? "
        "Есть ли поле ввода поста/редактор/composer? Кратко ответь."
    )


def build_composer_focus_payload(
    *,
    url: str,
    delay_sec: float,
    window_name: str = "",
) -> dict[str, Any]:
    host = url_host_label(url)
    return {
        "target_app": "browser",
        "window_name": window_name or host,
        "delay_sec": delay_sec,
        "focus_recovery": True,
        "submit_after": False,
        "composer_mode": True,
        "require_draft_verify": True,
    }


def draft_verify_snippet(draft_text: str, *, max_len: int = WEB_DRAFT_SNIPPET_CHARS) -> str:
    cleaned = re.sub(r"\s+", " ", str(draft_text or "")).strip()
    if not cleaned:
        return "draft"
    if len(cleaned) <= max_len:
        return cleaned
    return cleaned[:max_len]


def honest_composer_limitation() -> str:
    return (
        "MVP: универсальный DOM-composer для всех сайтов недоступен; "
        "используется фокус браузера + ввод текста + vision-проверка черновика."
    )


def build_web_draft_verify_prompt(*, expected_snippet: str, url: str = "") -> str:
    host = url_host_label(url) if url else "web page"
    snippet = str(expected_snippet or "").strip() or "draft"
    return (
        f"Analyze this {host} browser screenshot for a social post draft workflow. "
        "Prefer PUBLIC feed/channel post composer; treat private DM/chat composer as wrong_control_focus. "
        "Answer in JSON only, no markdown:\n"
        "{\n"
        '  "composer_open": <bool — PUBLIC post/feed composer or rich text editor dialog is open>,\n'
        '  "cursor_in_editor": <bool — text input cursor/focus is inside the composer editor>,\n'
        '  "entered_text_visible": <bool — typed draft text appears inside the composer>,\n'
        '  "text_matches_snippet": <bool — visible composer text starts with the expected snippet>,\n'
        '  "is_dm_or_chat": <bool — focus is in private message/DM/chat with another user>,\n'
        '  "wrong_control_focus": <bool — page text is selected/highlighted OR focus is in search/address bar '
        "OR focus is in private DM/chat instead of feed post composer>,\n"
        '  "confidence": <0.0-1.0>,\n'
        '  "findings": "<short explanation>"\n'
        "}\n"
        f"Expected snippet (first chars of generated post): {snippet!r}"
    )


def build_composer_locate_prompt(*, prefer_feed_post: bool = True) -> str:
    prefer = (
        "Prefer the PUBLIC FEED / channel post composer (Create post, Создать пост, New post, "
        "Compose post, Write a post). Do NOT choose private chat, Messages, DM, Send message, "
        "or a 1:1 conversation composer with another user. "
        if prefer_feed_post
        else ""
    )
    return (
        "Find the post composer entry point on this web page. "
        f"{prefer}"
        'Look for buttons or links labeled Create, Создать, New post, Compose, Write, Написать, Post. '
        "Avoid Message / Messages / Chat / DM / Написать сообщение / Личные сообщения. "
        "JSON only:\n"
        '{"found": <bool>, "label": "<visible button text>", '
        '"composer_kind": "feed_post"|"dm_chat"|"unknown", '
        '"focus_hint": "keyboard"|"click", "findings": "<short explanation>"}'
    )


def build_publish_context_verify_prompt(*, expected_snippet: str = "", url: str = "") -> str:
    """Vision prompt: published content must be feed/channel post, not DM/chat."""
    host = url_host_label(url) if url else "web page"
    snippet = str(expected_snippet or "").strip() or "post text"
    return (
        f"Analyze this {host} browser screenshot after a publish/send action. "
        "Decide whether the content was published as a PUBLIC feed/channel post "
        "or only as a PRIVATE chat/DM message to another user. "
        "JSON only:\n"
        "{\n"
        '  "is_public_post": <bool>,\n'
        '  "is_dm_or_chat": <bool>,\n'
        '  "text_visible": <bool>,\n'
        '  "text_matches_snippet": <bool>,\n'
        '  "confidence": <0.0-1.0>,\n'
        '  "findings": "<short>"\n'
        "}\n"
        f"Expected snippet: {snippet!r}"
    )


def parse_draft_vision_fields(findings: str) -> dict[str, Any]:
    """Extract structured draft verifier fields from vision model output."""
    text = str(findings or "").strip()
    if not text:
        return {}
    for candidate in (text, *_JSON_OBJECT_RE.findall(text)):
        try:
            parsed = json.loads(candidate)
        except json.JSONDecodeError:
            continue
        if isinstance(parsed, dict):
            if parsed.get("is_dm_or_chat") and not parsed.get("wrong_control_focus"):
                parsed = {**parsed, "wrong_control_focus": True}
            return parsed
    lowered = text.lower()
    inferred: dict[str, Any] = {"findings": text}
    if "composer" in lowered and ("open" in lowered or "открыт" in lowered):
        inferred.setdefault("composer_open", True)
    if "no composer" in lowered or "composer not" in lowered or "нет composer" in lowered:
        inferred.setdefault("composer_open", False)
    if "wrong" in lowered and "focus" in lowered:
        inferred.setdefault("wrong_control_focus", True)
    if "search bar" in lowered or "address bar" in lowered:
        inferred.setdefault("wrong_control_focus", True)
    if "selected" in lowered or "highlighted" in lowered:
        inferred.setdefault("wrong_control_focus", True)
    if any(
        tok in lowered
        for tok in (
            "dm",
            "direct message",
            "private message",
            "личн",
            "чат с",
            "сообщение пользовател",
        )
    ):
        inferred.setdefault("is_dm_or_chat", True)
        inferred.setdefault("wrong_control_focus", True)
    return inferred


def _snippet_matches(expected_snippet: str, fields: dict[str, Any], *, findings: str = "") -> bool:
    if bool(fields.get("text_matches_snippet")):
        return True
    snippet = re.sub(r"\s+", " ", str(expected_snippet or "")).strip().lower()
    if not snippet:
        return False
    haystack = re.sub(r"\s+", " ", str(findings or fields.get("findings") or "")).strip().lower()
    return snippet[: min(len(snippet), WEB_DRAFT_SNIPPET_CHARS)] in haystack


def classify_web_draft_state(
    fields: dict[str, Any],
    *,
    expected_snippet: str,
    findings: str = "",
    min_confidence: float = 0.55,
) -> tuple[str, bool]:
    """Map vision fields to draft_state and pass/fail."""
    try:
        confidence = float(fields.get("confidence") or 0.0)
    except (TypeError, ValueError):
        confidence = 0.0

    composer_open = bool(fields.get("composer_open"))
    cursor_in_editor = bool(fields.get("cursor_in_editor"))
    entered_text_visible = bool(fields.get("entered_text_visible"))
    wrong_control_focus = bool(fields.get("wrong_control_focus"))
    text_matches = _snippet_matches(expected_snippet, fields, findings=findings)

    if wrong_control_focus and not (composer_open and entered_text_visible and text_matches):
        return "typed_wrong_control", False
    if not composer_open:
        return "composer_not_found", False
    if composer_open and not entered_text_visible and not text_matches:
        return "composer_found", False
    if composer_open and entered_text_visible and not text_matches:
        return "draft_content_mismatch", False
    if composer_open and entered_text_visible and text_matches and confidence >= min_confidence:
        if cursor_in_editor or confidence >= min(min_confidence + 0.1, 0.95):
            return "draft_verified", True
    if composer_open and entered_text_visible and text_matches:
        return "draft_verified", True
    return "draft_not_created", False


def verify_web_draft(
    *,
    expected_text: str,
    image_bytes: bytes,
    screen_vision_granted: bool = True,
    plan_id: str = "",
    step_id: str = "",
    evidence_sha256: str = "",
    url: str = "",
    min_confidence: float = 0.55,
    vision_analyze: VisionAnalyzeFn | None = None,
) -> dict[str, Any]:
    """Vision-verify that a web post draft exists in an open composer."""
    snippet = draft_verify_snippet(expected_text)
    prompt = build_web_draft_verify_prompt(expected_snippet=snippet, url=url)

    if vision_analyze is not None:
        vision_result = vision_analyze(
            prompt=prompt,
            image_bytes=image_bytes,
            screen_vision_granted=screen_vision_granted,
            expected_text=snippet,
            plan_id=plan_id,
            step_id=step_id,
            evidence_sha256=evidence_sha256,
        )
    else:
        from .vision_local_adapter import analyze_vision_with_fallback

        vision_result = analyze_vision_with_fallback(
            prompt=prompt,
            image_bytes=image_bytes,
            screen_vision_granted=screen_vision_granted,
            expected_text=snippet,
            plan_id=plan_id,
            step_id=step_id,
            evidence_sha256=evidence_sha256,
        )

    findings = str(getattr(vision_result, "findings", "") or "")
    fields = parse_draft_vision_fields(findings)
    if not fields.get("findings"):
        fields["findings"] = findings
    draft_state, passed = classify_web_draft_state(
        fields,
        expected_snippet=snippet,
        findings=findings,
        min_confidence=min_confidence,
    )
    reason = draft_state if not passed else ""
    return {
        "pass": passed,
        "draft_state": draft_state,
        "reason": reason,
        "expected_snippet": snippet,
        "composer_open": bool(fields.get("composer_open")),
        "cursor_in_editor": bool(fields.get("cursor_in_editor")),
        "entered_text_visible": bool(fields.get("entered_text_visible")),
        "text_matches_snippet": bool(fields.get("text_matches_snippet")) or _snippet_matches(
            snippet, fields, findings=findings
        ),
        "wrong_control_focus": bool(fields.get("wrong_control_focus")),
        "confidence": float(getattr(vision_result, "confidence", 0.0) or fields.get("confidence") or 0.0),
        "findings": findings,
        "verifier_result": {
            **fields,
            "pass": passed,
            "draft_state": draft_state,
            "findings": findings,
        },
    }


def is_mytape_site(url: str) -> bool:
    host = url_host_label(str(url or ""))
    return "mytape" in host


def parse_composer_locate_fields(findings: str) -> dict[str, Any]:
    """Parse vision JSON for composer locate step."""
    text = str(findings or "").strip()
    if not text:
        return {}
    for candidate in (text, *_JSON_OBJECT_RE.findall(text)):
        try:
            parsed = json.loads(candidate)
        except json.JSONDecodeError:
            continue
        if isinstance(parsed, dict):
            kind = str(parsed.get("composer_kind") or "").strip().lower()
            label = str(parsed.get("label") or "").strip().lower()
            if kind in {"dm", "dm_chat", "chat", "message"} or any(
                tok in label for tok in ("message", "сообщен", "личн", "dm", "chat")
            ):
                # Reject DM/chat composers for web_post plans.
                parsed = {**parsed, "found": False, "composer_kind": "dm_chat", "rejected_dm": True}
            return parsed
    lowered = text.lower()
    inferred: dict[str, Any] = {"findings": text}
    if any(label.lower() in lowered for label in ("создать", "create", "compose", "new post")):
        if not any(tok in lowered for tok in ("message", "сообщен", "личн", "dm")):
            inferred.setdefault("found", True)
            inferred.setdefault("composer_kind", "feed_post")
    return inferred


def mytape_heuristic_composer_click(
    xdotool: str,
    *,
    window_name: str = "",
    prefer_feed_post: bool = True,
) -> tuple[bool, str]:
    """Click approximate mytape.live sidebar Create/Создать (feed post), not Messages/DM."""
    from .window_focus_recovery import recover_window_focus

    focus_ok, focus_detail, _strategy = recover_window_focus(
        xdotool,
        target_app="browser",
        window_name=window_name,
    )
    if not focus_ok:
        return False, str(focus_detail or "browser_focus_failed")

    window_id = ""
    if window_name:
        search = subprocess.run(
            [xdotool, "search", "--name", window_name],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        if search.returncode == 0 and search.stdout.strip():
            window_id = search.stdout.strip().splitlines()[0].strip()

    # Prefer upper-left Create / Создать (feed). Avoid lower/right regions often used by Messages.
    if prefer_feed_post:
        click_targets: list[tuple[int, int]] = [
            (110, 180),
            (120, 220),
            (96, 200),
            (140, 240),
            (120, 260),
        ]
    else:
        click_targets = [(120, 260), (96, 220), (140, 300)]
    for x_offset, y_offset in click_targets:
        if window_id:
            move = subprocess.run(
                [xdotool, "mousemove", "--window", window_id, str(x_offset), str(y_offset)],
                capture_output=True,
                text=True,
                timeout=10,
                check=False,
            )
        else:
            move = subprocess.run(
                [xdotool, "mousemove", str(x_offset), str(y_offset)],
                capture_output=True,
                text=True,
                timeout=10,
                check=False,
            )
        if move.returncode != 0:
            continue
        click = subprocess.run(
            [xdotool, "click", "1"],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        if click.returncode == 0:
            time.sleep(0.6)
            detail = (
                "mytape_feed_composer_heuristic_clicked"
                if prefer_feed_post
                else "mytape_composer_heuristic_clicked"
            )
            return True, detail
    return False, "mytape_composer_heuristic_miss"


def focus_mytape_composer(payload: dict[str, Any]) -> tuple[bool, str]:
    """Open feed/post composer on mytape.live via heuristic click (not DM chat)."""
    url = str(payload.get("active_site") or payload.get("url") or "").strip()
    window_name = str(payload.get("window_name") or url_host_label(url)).strip()
    prefer_feed = bool(payload.get("prefer_feed_composer", True))
    if is_mytape_site(url):
        xdotool = shutil.which("xdotool")
        if xdotool:
            clicked, detail = mytape_heuristic_composer_click(
                xdotool,
                window_name=window_name,
                prefer_feed_post=prefer_feed,
            )
            if clicked:
                return True, detail
    return default_focus_composer(payload)


def verify_typing_focus(
    *,
    image_bytes: bytes,
    vision_analyze: VisionAnalyzeFn,
    url: str = "",
) -> dict[str, Any]:
    """Vision check: ensure focus is not in address bar / bookmarks before typing."""
    if not image_bytes:
        return {"pass": False, "wrong_control_focus": True, "reason": "screenshot_missing"}
    prompt = (
        "Check whether browser focus is in the PUBLIC feed/post composer/editor (good) "
        "or in the address bar, bookmarks bar, search field, OR a private DM/chat message box (bad). "
        'JSON only: {"composer_open": <bool>, "wrong_control_focus": <bool>, '
        '"is_dm_or_chat": <bool>, "cursor_in_editor": <bool>, "findings": "<short>"}'
    )
    vision_result = vision_analyze(
        image_bytes=image_bytes,
        prompt=prompt,
        plan_id="focus_check",
        step_id="verify_typing_focus",
    )
    fields = parse_draft_vision_fields(str(getattr(vision_result, "findings", "") or ""))
    wrong = bool(fields.get("wrong_control_focus") or fields.get("is_dm_or_chat"))
    composer_open = bool(fields.get("composer_open") or fields.get("cursor_in_editor")) and not bool(
        fields.get("is_dm_or_chat")
    )
    passed = composer_open and not wrong
    return {
        "pass": passed,
        "wrong_control_focus": wrong,
        "composer_open": composer_open,
        "is_dm_or_chat": bool(fields.get("is_dm_or_chat")),
        "findings": str(fields.get("findings") or getattr(vision_result, "findings", "") or ""),
    }


def open_web_composer(
    *,
    payload: dict[str, Any],
    vision_analyze: VisionAnalyzeFn | None = None,
    image_bytes: bytes = b"",
    focus_composer_fn: FocusComposerFn | None = None,
) -> dict[str, Any]:
    """Vision-guided composer open: locate Create/Создать, click, verify composer open."""
    url = str(payload.get("active_site") or payload.get("url") or "").strip()
    window_name = str(payload.get("window_name") or url_host_label(url)).strip()
    delay_sec = float(payload.get("delay_sec") or 1.0)
    if delay_sec > 0:
        time.sleep(min(delay_sec, 8.0))

    focus_fn = focus_composer_fn or (focus_mytape_composer if is_mytape_site(url) else default_focus_composer)
    attempts: list[dict[str, Any]] = []

    if vision_analyze is not None and image_bytes:
        prefer_feed = bool(payload.get("prefer_feed_composer", True))
        locate = vision_analyze(
            image_bytes=image_bytes,
            prompt=build_composer_locate_prompt(prefer_feed_post=prefer_feed),
            plan_id=str(payload.get("plan_id") or "plan"),
            step_id=str(payload.get("step_id") or "open_web_composer"),
        )
        locate_fields = parse_composer_locate_fields(str(getattr(locate, "findings", "") or ""))
        attempts.append({"action": "vision_locate", "result": locate_fields})
        if not locate_fields.get("found") and is_mytape_site(url):
            focus_fn = focus_mytape_composer

    focus_ok, focus_detail = focus_fn({**payload, "window_name": window_name, "target_app": "browser"})
    attempts.append({"action": "focus_composer", "focus_ok": focus_ok, "detail": focus_detail})

    composer_open = focus_ok
    wrong_focus = False
    findings = focus_detail
    if vision_analyze is not None and image_bytes and bool(payload.get("verify_composer_open", True)):
        verify = verify_typing_focus(image_bytes=image_bytes, vision_analyze=vision_analyze, url=url)
        attempts.append({"action": "verify_composer_open", "result": verify})
        composer_open = bool(verify.get("composer_open")) or focus_ok
        wrong_focus = bool(verify.get("wrong_control_focus"))
        findings = str(verify.get("findings") or findings)
        if wrong_focus and is_mytape_site(url):
            retry_ok, retry_detail = focus_mytape_composer(payload)
            attempts.append({"action": "mytape_retry", "focus_ok": retry_ok, "detail": retry_detail})
            composer_open = retry_ok or composer_open
            findings = retry_detail if retry_ok else findings

    draft_state = "composer_found" if composer_open and not wrong_focus else "composer_not_found"
    return {
        "pass": composer_open and not wrong_focus,
        "composer_open": composer_open,
        "wrong_control_focus": wrong_focus,
        "draft_state": draft_state,
        "findings": findings,
        "recovery_attempts": attempts,
        "handler": "open_web_composer",
    }


def default_focus_composer(payload: dict[str, Any]) -> tuple[bool, str]:
    """Best-effort composer focus via xdotool (MVP — no DOM access)."""
    xdotool = shutil.which("xdotool")
    if not xdotool:
        return False, "xdotool_missing"

    window_name = str(payload.get("window_name") or "").strip()
    target_app = str(payload.get("target_app") or "browser").strip().lower()
    if target_app != "browser":
        return False, "focus_requires_browser_target"

    from .window_focus_recovery import recover_window_focus

    focus_ok, focus_detail, _strategy = recover_window_focus(
        xdotool,
        target_app=target_app,
        window_name=window_name,
        launch_pid=int(payload.get("launch_pid") or 0),
    )
    if not focus_ok:
        return False, str(focus_detail or "browser_focus_failed")

    for key_seq in (["Escape"], ["Tab"] * 4, ["ctrl+Home"], ["Tab"] * 2):
        args = [xdotool, "key", "--clearmodifiers", *key_seq]
        result = subprocess.run(args, capture_output=True, text=True, timeout=10, check=False)
        if result.returncode != 0:
            return False, (result.stderr or result.stdout or "composer_focus_keys_failed").strip()
        time.sleep(0.15)
    return True, "composer_focus_attempted"


def recover_composer_and_retry(
    *,
    expected_text: str,
    type_payload: dict[str, Any],
    verify_payload: dict[str, Any],
    type_text_fn: TypeTextFn,
    verify_fn: VerifyDraftFn,
    focus_composer_fn: FocusComposerFn | None = None,
    locate_composer_fn: Callable[[bytes], dict[str, Any]] | None = None,
    image_bytes: bytes = b"",
    max_attempts: int = WEB_DRAFT_MAX_RECOVERY_ATTEMPTS,
) -> dict[str, Any]:
    """On verify fail: locate composer, refocus, retype, re-verify (max 2 attempts)."""
    focus_fn = focus_composer_fn or default_focus_composer
    attempts: list[dict[str, Any]] = []
    last_verify: dict[str, Any] = {}
    url = str(type_payload.get("active_site") or type_payload.get("url") or "").strip()
    if is_mytape_site(url):
        focus_fn = focus_mytape_composer

    for attempt in range(1, max(max_attempts, 0) + 1):
        reason = str(last_verify.get("reason") or last_verify.get("draft_state") or "")
        recovery_action = "focus_composer"
        if reason == "composer_not_found" and locate_composer_fn is not None and image_bytes:
            locate = locate_composer_fn(image_bytes)
            attempts.append({"attempt": attempt, "action": "locate_composer", "result": locate})
            if not locate.get("found"):
                break
            recovery_action = "locate_then_focus"

        focus_ok, focus_detail = focus_fn(type_payload)
        attempts.append(
            {
                "attempt": attempt,
                "action": recovery_action,
                "focus_ok": focus_ok,
                "detail": focus_detail,
            }
        )
        if not focus_ok:
            continue

        type_result = type_text_fn(dict(type_payload))
        attempts.append({"attempt": attempt, "action": "retry_type", "result": type_result})
        if not type_result.get("success"):
            last_verify = {
                "pass": False,
                "draft_state": "typing_failed",
                "reason": "typing_failed",
            }
            continue

        last_verify = verify_fn(dict(verify_payload))
        attempts.append({"attempt": attempt, "action": "re_verify", "result": last_verify})
        if last_verify.get("pass"):
            last_verify["recovery_attempts"] = attempts
            return last_verify

    if not last_verify:
        last_verify = {"pass": False, "draft_state": "draft_not_created", "reason": "draft_not_created"}
    last_verify["recovery_attempts"] = attempts
    last_verify.setdefault("draft_state", "draft_not_created")
    last_verify.setdefault("reason", last_verify.get("draft_state"))
    return last_verify
