import logging
import os
from functools import wraps
from random import uniform
from time import sleep

import uiautomator2 as u2

from GramAddict.core.physics.biomechanics import BezierGesture, PhysicsBody
from GramAddict.core.physics.sendevent_injector import SendEventInjector

logger = logging.getLogger(__name__)


def adb_retry(retries=3, delay=2.0):
    def decorator(func):
        @wraps(func)
        def wrapper(self, *args, **kwargs):
            last_err = None
            for attempt in range(retries):
                try:
                    return func(self, *args, **kwargs)
                except Exception as e:
                    last_err = e
                    logger.warning(f"⚠️ ADB Error in {func.__name__} (Attempt {attempt+1}/{retries}): {e}")
                    sleep(delay * (attempt + 1))  # Exponential backoff
            logger.error(f"❌ ADB action {func.__name__} failed after {retries} retries. Crashing gracefully.")
            raise last_err

        return wrapper

    return decorator


def create_device(device_id, app_id, args=None):
    try:
        return DeviceFacade(device_id, app_id, args)
    except Exception as e:
        err_msg = str(e)
        err_type = str(type(e))
        if any(
            keyword in err_type or keyword in err_msg
            for keyword in ["ConnectError", "ConnectionRefused", "ConnectionError", "Timeout"]
        ):
            logger.error(f"⚠️ [ADB ConnectError] Could not connect to device '{device_id}'.")

            # Proactive Discovery
            try:
                import subprocess

                result = subprocess.run(["adb", "devices"], capture_output=True, text=True, timeout=2)
                lines = [
                    line.strip()
                    for line in result.stdout.split("\n")
                    if line.strip() and not line.startswith("List of devices")
                ]
                devices = [line.split("\t")[0] for line in lines if "device" in line]

                if devices:
                    logger.info("🔍 Proactive Discovery: I found the following devices connected:")
                    for d in devices:
                        if d.split(":")[0] == device_id.split(":")[0]:
                            logger.info(f"   👉 {d} (MATCHING IP - Is this the same device with a different port?)")
                        else:
                            logger.info(f"   - {d}")
                else:
                    logger.warning("🔍 Proactive Discovery: No ADB devices found. Is your phone authorized?")
            except Exception as discovery_err:
                logger.debug(f"Proactive discovery failed: {discovery_err}")

            logger.error("👉 Please verify:")
            logger.error("   1. Your phone is connected via USB or Wi-Fi.")
            logger.error("   2. 'USB Debugging' is enabled in Developer Options.")
            logger.error("   3. You have authorized this computer on your phone's screen.")
            logger.error("   4. The adb server is running ('adb devices').")
            raise SystemExit(1)

        logger.error(f"Failed to create device: {e}")
        # We don't want to just return None and crash later.
        # We should raise so the orchestrator knows it's a fatal boot error.
        raise e


def get_device_info(device):
    if not device or not device.deviceV2:
        logger.error("Cannot get device info: Device not initialized.")
        return
    info = device.info
    logger.debug(f"Device Info: {info.get('productName')} | SDK: {info.get('sdkInt')}")


class DeviceFacade:
    deviceV2 = None
    app_id = None
    device_id = None

    def __init__(self, device_id, app_id, args):
        self.device_id = device_id
        self.app_id = app_id
        self.args = args
        self.deviceV2 = u2.connect(device_id)

        # Configure uiautomator2
        self.deviceV2.settings["wait_timeout"] = 3.0
        self.deviceV2.settings["post_delay"] = 0.5

        # System dialog handler (language-agnostic via resource-id, not text)
        try:
            # u2 v3.x: named watchers with xpath selectors
            # android:id/aerr_close = App crash "Close" button (all languages)
            self.deviceV2.watcher("crash_dialog").when(xpath='//*[@resource-id="android:id/aerr_close"]').click()
            # android:id/button1 = positive system dialog button (all languages)
            self.deviceV2.watcher("system_dialog").when(xpath='//*[@resource-id="android:id/button1"]').click()
            self.deviceV2.watcher.start()
        except Exception as e:
            logger.debug(f"Could not start system watcher: {e}")

        # Transient package cache to prevent repeat lag
        self.last_transient_pkg = None
        self.last_transient_time = 0

    @adb_retry()
    def get_info(self):
        return self.deviceV2.info

    @adb_retry()
    def cm_to_pixels(self, cm: float) -> int:
        info = self.deviceV2.info
        dpx = info.get("displaySizeDpX", 400)
        width = info.get("displayWidth", 1080)
        # Android baseline: 1 dp = 1/160 inch. 1 inch = 2.54 cm
        # PPCM (Pixels Per CM) = (width / dpx) * (160 / 2.54)
        ppcm = (width / dpx) * (160 / 2.54)
        return int(cm * ppcm)

    @adb_retry()
    def wake_up(self):
        if not self.deviceV2.info.get("screenOn"):
            self.deviceV2.screen_on()
            self.deviceV2.press("home")
            sleep(1)

    @adb_retry()
    def unlock(self):
        self.deviceV2.unlock()

    @property
    def info(self):
        return self.deviceV2.info

    @adb_retry()
    def app_start(self, app_id=None, use_monkey=False):
        target_app = app_id or self.app_id
        if use_monkey:
            self.deviceV2.app_start(target_app, use_monkey=True)
        else:
            self.deviceV2.app_start(target_app)

    @adb_retry()
    def app_stop(self, app_id=None):
        target_app = app_id or self.app_id
        self.deviceV2.app_stop(target_app)

    @adb_retry()
    def shell(self, cmd):
        return self.deviceV2.shell(cmd)

    @adb_retry()
    def swipe(self, sx, sy, ex, ey, duration=None):
        """Pass-through strictly for non-biological bezier swiping (e.g., darwin_engine noise correction)"""
        kwargs = {}
        if duration is not None:
            kwargs["duration"] = duration
        self.deviceV2.swipe(sx, sy, ex, ey, **kwargs)

    @adb_retry()
    def long_click(self, x, y, duration=1.5):
        self.deviceV2.long_click(x, y, duration)

    @adb_retry()
    def press(self, key):
        self.deviceV2.press(key)

    @adb_retry()
    def back(self):
        self.deviceV2.press("back")

    @adb_retry()
    def click(self, x=None, y=None, obj=None):
        if obj:
            if isinstance(obj, dict) and "x" in obj and "y" in obj:
                self.human_click(obj["x"], obj["y"])
                return
            try:
                left, top, right, bottom = obj.bounds()
                w = right - left
                h = bottom - top

                # Biological fingerprint via PhysicsBody
                body = PhysicsBody.get_session_instance(self)
                # Thumb bias: right-handers land slightly left-below center
                if body.handedness == "right":
                    cx_base = left + (w * 0.45)
                    cy_base = top + (h * 0.55)
                else:
                    cx_base = left + (w * 0.55)
                    cy_base = top + (h * 0.55)

                from random import gauss

                # Fatigue increases spread
                fatigue_mult = 1.0 + body.fatigue * 0.3
                sigma_x = max(1, w * 0.15 * fatigue_mult)
                sigma_y = max(1, h * 0.15 * fatigue_mult)

                cx = int(gauss(cx_base, sigma_x))
                cy = int(gauss(cy_base, sigma_y))

                # Math constraint to ensure it physically lands on the button
                cx = max(left + 1, min(cx, right - 1))
                cy = max(top + 1, min(cy, bottom - 1))

                self.human_click(cx, cy)
            except Exception as e:
                logger.debug(f"Bounds extraction failed, fallback to native click: {e}")
                obj.click()
        elif x is not None and y is not None:
            self.human_click(x, y)

    @adb_retry()
    def human_click(self, x, y):
        # 🛡️ [Gesture Guard] If clicking near the edges, use native click to prevent
        # triggering System Gestures (e.g., Google Assistant diagonal swipe, App Switcher)
        # and prevent network latency turning edge taps into long-presses (Circle to Search).
        if y > 2100 or y < 200 or x < 50 or x > 1030:
            self.deviceV2.shell(f"input tap {int(x)} {int(y)}")
            return

        try:
            body = PhysicsBody.get_session_instance(self)
            injector = SendEventInjector.get_instance(self)
            points = BezierGesture.tap_curve(x, y, body)
            tap_duration = uniform(40, 90)
            timing = BezierGesture.compute_sigmoid_timing(len(points), tap_duration)

            injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
        except Exception as e:
            logger.debug(f"human_click biomechanics failed, fallback: {e}")
            try:
                self.deviceV2.touch.down(x, y)
                sleep(uniform(0.05, 0.15))
                slip_x = x + int(uniform(-4, 4)) if y < 2100 else x
                slip_y = y + int(uniform(-4, 4))
                self.deviceV2.touch.move(slip_x, slip_y)
                sleep(uniform(0.01, 0.05))
                self.deviceV2.touch.up(slip_x, slip_y)
            except Exception as e2:
                logger.debug(f"human_click u2 failed, final fallback: {e2}")
                self.deviceV2.shell(f"input tap {int(x)} {int(y)}")

    @adb_retry()
    def swipe_points(self, x1, y1, x2, y2, duration=0.1):
        dur_ms = int(duration * 1000)
        self.deviceV2.shell(f"input swipe {int(x1)} {int(y1)} {int(x2)} {int(y2)} {dur_ms}")

    @adb_retry()
    def human_swipe(self, start_x, start_y, end_x, end_y, duration=0.3):
        # 🛡️ [Gesture Guard] If swiping near the very edges, use native swipe to prevent
        # system gesture clashes, unless it's a feed scroll (which is usually safe).
        dur_ms = int(duration * 1000)
        if start_x < 50 or start_x > 1030 or start_y < 200 or start_y > 2100:
            self.deviceV2.shell(f"input swipe {int(start_x)} {int(start_y)} {int(end_x)} {int(end_y)} {dur_ms}")
            return

        try:
            body = PhysicsBody.get_session_instance(self)
            injector = SendEventInjector.get_instance(self)

            # Use scroll_curve for vertical swipes, horizontal_swipe_curve for horizontal
            is_horizontal = abs(end_x - start_x) > abs(end_y - start_y)
            if is_horizontal:
                points = BezierGesture.horizontal_swipe_curve((start_x, start_y), (end_x, end_y), body)
            else:
                points = BezierGesture.scroll_curve((start_x, start_y), (end_x, end_y), body)

            # Use fling timing (J-curve) to ensure high terminal velocity so Android scroll physics works natively
            timing = BezierGesture.compute_fling_timing(len(points), dur_ms)

            injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
        except Exception as e:
            logger.debug(f"human_swipe biomechanics failed, fallback to native swipe: {e}")
            self.deviceV2.shell(f"input swipe {int(start_x)} {int(start_y)} {int(end_x)} {int(end_y)} {dur_ms}")

    @adb_retry()
    def _get_current_app(self):
        """
        SAE-aware app detection.
        Instead of maintaining a hardcoded list of 'transient' packages,
        we check the actual package and let the SAE handle recovery if needed.
        Transient notifications (status bar, brief banners) are handled by
        a single brief retry — no hardcoded app list needed.
        """
        pkg = self.deviceV2.app_current().get("package")
        if pkg == self.app_id:
            return pkg

        # Brief retry: many false positives come from <500ms notification banners
        # A single short wait handles ALL transient overlays regardless of source app
        sleep(0.5)
        pkg = self.deviceV2.app_current().get("package")

        # If still not our app, check if it's just SystemUI (always present, never a real takeover)
        if pkg in ("com.android.systemui", "android"):
            return self.app_id

        return pkg

    @adb_retry()
    def find(self, **kwargs):
        """Standard uiautomator2 find."""
        return self.deviceV2(**kwargs)

    @adb_retry()
    def dump_hierarchy(self):
        # Compressed=True dramatically speeds up UIAutomator2 dumps by skipping invisible elements!
        xml = self.deviceV2.dump_hierarchy(compressed=True)

        # Continuous Session Tracing
        import shutil
        from datetime import datetime

        try:
            traces_root = os.path.join("debug", "session_traces")
            if not hasattr(self, "_trace_counter"):
                self._trace_counter = 0
                ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
                self._trace_dir = os.path.join(traces_root, ts)
                os.makedirs(self._trace_dir, exist_ok=True)

                # Cleanup: keep only last 5 session folders
                try:
                    if os.path.exists(traces_root):
                        folders = [
                            os.path.join(traces_root, d)
                            for d in os.listdir(traces_root)
                            if os.path.isdir(os.path.join(traces_root, d))
                        ]
                        folders.sort(key=os.path.getmtime)
                        while len(folders) > 5:
                            oldest = folders.pop(0)
                            shutil.rmtree(oldest, ignore_errors=True)
                            logger.info(f"🧹 [Cleanup] Removed old session trace: {oldest}")
                except Exception as e:
                    logger.debug(f"Failed to cleanup old traces: {e}")

            self._trace_counter += 1
            trace_path = os.path.join(self._trace_dir, f"{self._trace_counter:05d}.xml")
            with open(trace_path, "w", encoding="utf-8") as f:
                f.write(xml)

            # Dump screenshot as well
            try:
                import base64

                screenshot_b64 = self.get_screenshot_b64()
                if screenshot_b64:
                    screenshot_data = base64.b64decode(screenshot_b64)
                    screenshot_path = trace_path.replace(".xml", ".jpg")
                    with open(screenshot_path, "wb") as f:
                        f.write(screenshot_data)
            except Exception as e:
                logger.debug(f"Failed to capture screenshot for session trace: {e}")

        except Exception as e:
            logger.debug(f"Failed to write session trace: {e}")

        return xml

    @adb_retry()
    def get_screenshot_b64(self):
        import base64
        from io import BytesIO

        img = self.deviceV2.screenshot()
        if img is None:
            return None
        buffered = BytesIO()
        img.save(buffered, format="JPEG", quality=70)  # Compressed for target latency
        return base64.b64encode(buffered.getvalue()).decode("utf-8")

    # Telepathic Semantic UI Integration
    @adb_retry()
    def find_semantic(self, intent_description: str):
        from GramAddict.core.telepathic_engine import TelepathicEngine

        engine = TelepathicEngine.get_instance()
        xml = self.dump_hierarchy()
        # Passing self (DeviceFacade) enables the Vision Cortex VLM fallback
        node = engine.find_best_node(xml, intent_description, device=self)
        return node
