"""AndroidADBRuntime — controls an Android device via ADB.

Used in two scenarios:
  - Scene 1: PC with ADB-connected phone (``beeos device attach``)
  - Scene 3: Cloud emulator sidecar (ADB over localhost:5555)

Leverages ``adbutils`` for the ADB protocol and falls back to subprocess
calls for operations not covered by the library.
"""

from __future__ import annotations

import asyncio
import io
import logging
import os
import re
import tempfile
import xml.etree.ElementTree as ET

from adbutils import AdbClient, AdbDevice

from .base import (
    DeviceCapability,
    DeviceInfo,
    DeviceRuntime,
    DeviceType,
)

logger = logging.getLogger(__name__)

_DEFAULT_ADB_HOST = "127.0.0.1"
_DEFAULT_ADB_PORT = 5037
_LOCK_DIR = os.path.join(tempfile.gettempdir(), "beeos-device-locks")


def _lock_path(serial: str) -> str:
    safe = serial.replace("/", "_").replace(":", "_")
    return os.path.join(_LOCK_DIR, f"{safe}.lock")


def _is_pid_alive(pid: int) -> bool:
    try:
        os.kill(pid, 0)
        return True
    except (OSError, ProcessLookupError):
        return False


class AndroidADBRuntime(DeviceRuntime):
    """Control an Android device through ADB."""

    def __init__(
        self,
        serial: str | None = None,
        adb_host: str = _DEFAULT_ADB_HOST,
        adb_port: int = _DEFAULT_ADB_PORT,
    ) -> None:
        self._serial = serial
        self._adb_host = adb_host
        self._adb_port = adb_port
        self._client: AdbClient | None = None
        self._device: AdbDevice | None = None
        self._connected = False
        self._lock_file: str | None = None

    @property
    def device(self) -> AdbDevice:
        if self._device is None:
            raise RuntimeError("Not connected — call connect() first")
        return self._device

    @property
    def serial(self) -> str | None:
        return self._serial

    async def connect(self) -> None:
        loop = asyncio.get_event_loop()
        self._client, self._device = await loop.run_in_executor(
            None, self._connect_sync
        )
        self._connected = True
        self._acquire_lock()
        info = await self.get_info()
        logger.info("Connected to %s (%s, %dx%d)", info.name, info.os, info.width, info.height)

    def _connect_sync(self) -> tuple[AdbClient, AdbDevice]:
        client = AdbClient(host=self._adb_host, port=self._adb_port)
        if self._serial:
            device = client.device(self._serial)
        else:
            devices = client.device_list()
            if not devices:
                raise RuntimeError(
                    "No ADB devices found. Connect a device via USB and "
                    "enable USB debugging, or start an emulator."
                )
            if len(devices) == 1:
                device = devices[0]
                self._serial = device.serial
                logger.info("Auto-selected device: %s", device.serial)
            else:
                lines = [f"  {d.serial}  ({d.prop.get('ro.product.model', '?')})"
                         for d in devices]
                raise RuntimeError(
                    f"Multiple ADB devices found ({len(devices)}). "
                    f"Specify one with --adb <serial>:\n" + "\n".join(lines)
                )
        self._check_device_lock(device.serial)
        device.shell("echo ping")
        return client, device

    @staticmethod
    def _check_device_lock(serial: str) -> None:
        lp = _lock_path(serial)
        if not os.path.exists(lp):
            return
        try:
            pid = int(open(lp).read().strip())
        except (ValueError, OSError):
            os.unlink(lp)
            return
        if _is_pid_alive(pid):
            raise RuntimeError(
                f"Device {serial} is already in use by another DeviceAgent "
                f"(pid {pid}). Stop that process first, or use a different device."
            )
        logger.warning("Stale lock for %s (pid %d dead), removing", serial, pid)
        os.unlink(lp)

    def _acquire_lock(self) -> None:
        if not self._serial:
            return
        os.makedirs(_LOCK_DIR, exist_ok=True)
        lp = _lock_path(self._serial)
        with open(lp, "w") as f:
            f.write(str(os.getpid()))
        self._lock_file = lp
        logger.debug("Acquired device lock: %s", lp)

    def _release_lock(self) -> None:
        if self._lock_file and os.path.exists(self._lock_file):
            try:
                os.unlink(self._lock_file)
                logger.debug("Released device lock: %s", self._lock_file)
            except OSError:
                pass
            self._lock_file = None

    async def disconnect(self) -> None:
        self._release_lock()
        self._device = None
        self._client = None
        self._connected = False

    async def get_info(self) -> DeviceInfo:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._get_info_sync)

    def _get_info_sync(self) -> DeviceInfo:
        d = self.device
        model = d.prop.get("ro.product.model", "Unknown")
        brand = d.prop.get("ro.product.brand", "")
        os_version = d.prop.get("ro.build.version.release", "")

        size_raw = d.shell("wm size").strip()
        width, height = 1080, 1920
        m = re.search(r"(\d+)x(\d+)", size_raw)
        if m:
            width, height = int(m.group(1)), int(m.group(2))

        density = 420
        density_raw = d.shell("wm density").strip()
        dm = re.search(r"(\d+)", density_raw)
        if dm:
            density = int(dm.group(1))

        name = f"{brand} {model}".strip() or self._serial or "android"
        return DeviceInfo(
            type=DeviceType.ANDROID,
            name=name,
            os=f"Android {os_version}",
            os_version=os_version,
            width=width,
            height=height,
            density=density,
            capabilities=[
                DeviceCapability.TOUCH,
                DeviceCapability.KEYBOARD,
                DeviceCapability.SCREENSHOT,
                DeviceCapability.SCREEN_STREAM,
                DeviceCapability.APP_INSTALL,
                DeviceCapability.FILE_TRANSFER,
                DeviceCapability.SHELL,
            ],
            metadata={
                "serial": self._serial or "",
                "sdk": d.prop.get("ro.build.version.sdk", ""),
                "model": model,
            },
        )

    async def screenshot(self) -> bytes:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._screenshot_sync)

    def _screenshot_sync(self) -> bytes:
        from PIL import Image

        img = self.device.screenshot()
        buf = io.BytesIO()
        if isinstance(img, Image.Image):
            img.save(buf, format="PNG")
        else:
            buf.write(img)
        return buf.getvalue()

    async def get_ui_tree(self) -> list[dict]:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._get_ui_tree_sync)

    def _get_ui_tree_sync(self) -> list[dict]:
        xml_str = self.device.shell("uiautomator dump /dev/tty")
        idx = xml_str.find("<?xml")
        if idx < 0:
            return []
        xml_str = xml_str[idx:]
        # Android outputs "UI hierarchy dumped..." or "UI hierance dumped..."
        # (the latter is a known typo in some device ROMs)
        end = xml_str.find("UI hier")
        if end < 0:
            end = xml_str.find("</hierarchy>")
            if end > 0:
                end += len("</hierarchy>")
        if end > 0:
            xml_str = xml_str[:end].strip()

        nodes: list[dict] = []
        try:
            root = ET.fromstring(xml_str)
            self._walk_ui_tree(root, nodes)
        except ET.ParseError:
            logger.warning("Failed to parse UI tree XML")
        return nodes

    def _walk_ui_tree(self, elem: ET.Element, nodes: list[dict]) -> None:
        attrs = dict(elem.attrib)
        bounds_str = attrs.get("bounds", "")
        bounds = self._parse_bounds(bounds_str) if bounds_str else None
        node: dict = {
            "class": attrs.get("class", ""),
            "text": attrs.get("text", ""),
            "resource-id": attrs.get("resource-id", ""),
            "content-desc": attrs.get("content-desc", ""),
            "clickable": attrs.get("clickable") == "true",
            "enabled": attrs.get("enabled") == "true",
            "focused": attrs.get("focused") == "true",
            "package": attrs.get("package", ""),
        }
        if bounds:
            node["bounds"] = bounds
        nodes.append(node)
        for child in elem:
            self._walk_ui_tree(child, nodes)

    @staticmethod
    def _parse_bounds(bounds_str: str) -> dict[str, int] | None:
        m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str)
        if not m:
            return None
        return {
            "left": int(m.group(1)),
            "top": int(m.group(2)),
            "right": int(m.group(3)),
            "bottom": int(m.group(4)),
        }

    async def tap(self, x: int, y: int) -> None:
        await self._shell(f"input tap {x} {y}")

    async def long_press(self, x: int, y: int, duration_ms: int = 1000) -> None:
        await self._shell(f"input swipe {x} {y} {x} {y} {duration_ms}")

    async def swipe(
        self, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300
    ) -> None:
        await self._shell(f"input swipe {x1} {y1} {x2} {y2} {duration_ms}")

    _ADBKEYBOARD_IME = "com.android.adbkeyboard/.AdbIME"

    async def type_text(self, text: str) -> None:
        is_ascii = all(ord(c) < 128 for c in text)
        if is_ascii:
            escaped = text.replace("%", "%%").replace(" ", "%s")
            for ch in ("'", '"', "(", ")", "<", ">", "|", ";", "&", "*", "\\", "~", "`"):
                escaped = escaped.replace(ch, f"\\{ch}")
            await self._shell(f"input text {escaped}")
            return

        saved_ime = await self._ensure_adbkeyboard()
        try:
            safe = text.replace("'", "'\\''")
            result = await self._shell(
                f"am broadcast -a ADB_INPUT_TEXT --es msg '{safe}'"
            )
            if "result=0" not in result:
                logger.warning("ADBKeyboard broadcast failed: %s", result)
        finally:
            if saved_ime and saved_ime != self._ADBKEYBOARD_IME:
                await self._shell(f"ime set {saved_ime}")

    async def _ensure_adbkeyboard(self) -> str:
        """Activate ADBKeyboard and return the previous IME id."""
        current = (await self._shell("settings get secure default_input_method")).strip()
        if current == self._ADBKEYBOARD_IME:
            return current
        await self._shell(f"ime enable {self._ADBKEYBOARD_IME}")
        await self._shell(f"ime set {self._ADBKEYBOARD_IME}")
        await asyncio.sleep(0.3)
        return current

    async def press_key(self, keycode: str) -> None:
        await self._shell(f"input keyevent {keycode}")

    async def open_app(self, package_or_path: str) -> None:
        result = await self._shell(
            f"monkey -p {package_or_path} -c android.intent.category.LAUNCHER 1"
        )
        if "No activities found" in result:
            raise RuntimeError(f"Cannot open app: {package_or_path}")

    async def install_app(self, apk_path: str) -> None:
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, self.device.install, apk_path)

    async def execute_shell(self, command: str) -> str:
        return await self._shell(command)

    async def _shell(self, command: str) -> str:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self.device.shell, command)
