"""
Simulator / Emulator Engine  
============================
Real implementation — no mocks.

iOS path  (macOS only):
  1. Find booted simulator via `xcrun simctl list --json`
  2. Boot one if none running (iPhone 15 Pro preferred)
  3. Build Expo app: `npx expo run:ios --simulator --configuration Release`
     or simpler: `npx expo export --platform ios` + xcrun simctl install
  4. Launch the app:  `xcrun simctl launch booted <bundle-id>`
  5. Wait for each screen to render (configurable delay)
  6. Navigate: send simctl key events or use Maestro if available
  7. Screenshot: `xcrun simctl io booted screenshot <path>.png`
  8. Collect all PNGs → list of Path objects

Android path (cross-platform):
  1. Find running AVD via `adb devices`
  2. Start one if none: `emulator -avd <name> -no-window`
  3. Build: `npx expo run:android`
  4. Wait for app launch via `adb shell pidof <package>`
  5. Navigate using `adb shell input keyevent` / `adb shell am start -n`
  6. Screenshot: `adb exec-out screencap -p > screen.png`
  7. Collect PNGs

Expo Web path (always available if Node installed):
  1. `npx expo export --platform web`
  2. `npx serve dist/ -p 19006`
  3. Use playwright / puppeteer to screenshot each route
  4. Kill server

All paths are real subprocess calls. Falls back gracefully if toolchain absent.
"""
from __future__ import annotations

import asyncio
import json
import os
import platform
import re
import shutil
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

from forge.core.session import GenerationSession

# ── Constants ─────────────────────────────────────────────────────────────────

PREFERRED_IPHONE = "iPhone 15 Pro"
PREFERRED_AVD = "Pixel_6_API_34"
EXPO_WEB_PORT = 19006
SCREEN_SETTLE_SECS = 3.5       # wait after launch before first screenshot
NAVIGATION_SETTLE_SECS = 2.0   # wait after navigating
BUILD_TIMEOUT = 600            # 10 min — Expo first build is slow
LAUNCH_TIMEOUT = 60
SCREENSHOT_TIMEOUT = 15


@dataclass
class ScreenshotResult:
    platform: str                          # 'ios' | 'android' | 'web'
    screenshots: list[Path] = field(default_factory=list)
    success: bool = False
    error: Optional[str] = None
    sim_udid: Optional[str] = None
    bundle_id: Optional[str] = None


# ── Subprocess helper ─────────────────────────────────────────────────────────

async def _run(
    cmd: list[str],
    cwd: Optional[Path],
    timeout: int,
    session: GenerationSession,
    agent: str = "SCREENER",
    check: bool = False,
) -> tuple[int, str, str]:
    session.log(agent, f"  $ {' '.join(cmd[:6])}{'…' if len(cmd) > 6 else ''}")
    try:
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            cwd=str(cwd) if cwd else None,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            env={**os.environ},
        )
        stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
        rc = proc.returncode
    except asyncio.TimeoutError:
        try:
            proc.kill()
        except Exception:
            pass
        session.log(agent, f"  ⚠ Timeout after {timeout}s", "warning")
        return -1, "", f"Timeout {timeout}s"
    except FileNotFoundError as e:
        return -1, "", str(e)

    out = stdout_b.decode("utf-8", errors="replace")
    err = stderr_b.decode("utf-8", errors="replace")

    if check and rc != 0:
        raise RuntimeError(f"Command failed (rc={rc}): {err[:300]}")

    return rc, out, err


def _tool_available(name: str) -> bool:
    return shutil.which(name) is not None


# ─────────────────────────────────────────────────────────────────────────────
# iOS — xcrun simctl
# ─────────────────────────────────────────────────────────────────────────────

async def _ios_find_or_boot_simulator(session: GenerationSession) -> Optional[str]:
    """Return UDID of a booted (or newly booted) iPhone simulator."""
    if not _tool_available("xcrun"):
        session.log("SCREENER", "  xcrun not found (not macOS or Xcode not installed)", "warning")
        return None

    # List simulators as JSON
    rc, out, _ = await _run(
        ["xcrun", "simctl", "list", "devices", "--json"],
        None, 30, session
    )
    if rc != 0:
        return None

    data = json.loads(out)
    devices = data.get("devices", {})

    # Find a booted one first
    for runtime, devs in devices.items():
        if "iOS" not in runtime:
            continue
        for d in devs:
            if d.get("state") == "Booted":
                session.log("SCREENER", f"  Found booted simulator: {d['name']} ({d['udid'][:8]}…)")
                return d["udid"]

    # Boot preferred model
    for runtime in sorted(devices.keys(), reverse=True):  # newest iOS first
        if "iOS" not in runtime:
            continue
        for d in devices[runtime]:
            if d.get("isAvailable") and d.get("name") == PREFERRED_IPHONE:
                udid = d["udid"]
                session.log("SCREENER", f"  Booting {d['name']} ({udid[:8]}…)")
                await _run(["xcrun", "simctl", "boot", udid], None, 60, session)
                await asyncio.sleep(5)  # give sim time to settle
                return udid

    # Boot any available iPhone
    for runtime in sorted(devices.keys(), reverse=True):
        if "iOS" not in runtime:
            continue
        for d in devices[runtime]:
            if d.get("isAvailable") and "iPhone" in d.get("name", ""):
                udid = d["udid"]
                session.log("SCREENER", f"  Booting fallback {d['name']} ({udid[:8]}…)")
                await _run(["xcrun", "simctl", "boot", udid], None, 60, session)
                await asyncio.sleep(5)
                return udid

    session.log("SCREENER", "  No available iPhone simulator found", "error")
    return None


async def _ios_build_and_install(
    project_dir: Path,
    bundle_id: str,
    sim_udid: str,
    session: GenerationSession,
) -> bool:
    """
    Build Expo app for iOS simulator and install it.
    Uses `npx expo run:ios` which handles build + install.
    """
    session.log("SCREENER", "  Building for iOS simulator (this takes a while on first run)…")

    rc, out, err = await _run(
        [
            "npx", "expo", "run:ios",
            "--simulator", sim_udid,
            "--configuration", "Debug",
            "--non-interactive",
        ],
        project_dir,
        BUILD_TIMEOUT,
        session,
    )

    if rc != 0:
        # Try simpler: prebuild + xcodebuild
        session.log("SCREENER", f"  expo run:ios failed (rc={rc}), trying prebuild path…", "warning")
        rc2, _, _ = await _run(
            ["npx", "expo", "prebuild", "--platform", "ios", "--non-interactive"],
            project_dir, 120, session
        )
        if rc2 == 0:
            rc3, _, err3 = await _run(
                [
                    "xcodebuild",
                    "-workspace", f"ios/{project_dir.name}.xcworkspace",
                    "-scheme", project_dir.name,
                    "-configuration", "Debug",
                    "-destination", f"id={sim_udid}",
                    "build",
                    "CODE_SIGN_IDENTITY=",
                    "CODE_SIGNING_REQUIRED=NO",
                ],
                project_dir, BUILD_TIMEOUT, session
            )
            if rc3 != 0:
                session.log("SCREENER", f"  xcodebuild failed: {err3[:200]}", "error")
                return False
        else:
            return False

    session.log("SCREENER", "  ✓ iOS build complete", "success")
    return True


async def _ios_launch_app(
    bundle_id: str,
    sim_udid: str,
    session: GenerationSession,
) -> bool:
    rc, _, _ = await _run(
        ["xcrun", "simctl", "launch", sim_udid, bundle_id],
        None, LAUNCH_TIMEOUT, session
    )
    if rc != 0:
        session.log("SCREENER", f"  ✗ Failed to launch {bundle_id}", "error")
        return False
    await asyncio.sleep(SCREEN_SETTLE_SECS)
    return True


async def _ios_screenshot(
    sim_udid: str,
    out_path: Path,
    session: GenerationSession,
) -> bool:
    rc, _, err = await _run(
        ["xcrun", "simctl", "io", sim_udid, "screenshot", str(out_path)],
        None, SCREENSHOT_TIMEOUT, session
    )
    if rc != 0:
        session.log("SCREENER", f"  ✗ Screenshot failed: {err[:100]}", "error")
        return False
    return out_path.exists()


async def _ios_navigate_tab(
    sim_udid: str,
    tab_index: int,
    session: GenerationSession,
) -> None:
    """Tap on tab bar area to navigate — positions are approximate for iPhone 15 Pro."""
    # Tab bar at y ≈ 840 (out of 932 logical pts), tabs spaced at 393/N width
    # We use simctl io touch (available in Xcode 14+)
    # x positions for up to 5 tabs: divide 393px width equally
    tab_x_positions = [60, 130, 197, 264, 333]
    x = tab_x_positions[min(tab_index, len(tab_x_positions) - 1)]
    y = 840

    await _run(
        ["xcrun", "simctl", "io", sim_udid, "sendkey", "--", f"touch {x} {y}"],
        None, 10, session
    )
    await asyncio.sleep(NAVIGATION_SETTLE_SECS)


async def run_ios_screenshots(
    session: GenerationSession,
    screenshot_dir: Path,
) -> ScreenshotResult:
    result = ScreenshotResult(platform="ios")
    plan = session.plan
    project_dir = session.project_dir

    if not project_dir:
        result.error = "No project directory"
        return result

    # Find/boot simulator
    sim_udid = await _ios_find_or_boot_simulator(session)
    if not sim_udid:
        result.error = "No iOS simulator available"
        return result
    result.sim_udid = sim_udid

    bundle_id = plan.package_name
    result.bundle_id = bundle_id

    # Build and install
    built = await _ios_build_and_install(project_dir, bundle_id, sim_udid, session)
    if not built:
        result.error = "iOS build failed"
        return result

    # Launch
    launched = await _ios_launch_app(bundle_id, sim_udid, session)
    if not launched:
        result.error = "App launch failed"
        return result

    screenshot_dir.mkdir(parents=True, exist_ok=True)

    # Screenshot initial screen
    shot0 = screenshot_dir / "01_launch.png"
    if await _ios_screenshot(sim_udid, shot0, session):
        result.screenshots.append(shot0)
        session.log("SCREENER", f"  📸 {shot0.name}", "success")

    # Navigate through tab screens
    tab_screens = plan.tab_screens or []
    for i, screen_name in enumerate(tab_screens[:5], start=1):
        await _ios_navigate_tab(sim_udid, i - 1, session)
        shot = screenshot_dir / f"{i+1:02d}_{screen_name}.png"
        if await _ios_screenshot(sim_udid, shot, session):
            result.screenshots.append(shot)
            session.log("SCREENER", f"  📸 {shot.name}", "success")

    result.success = len(result.screenshots) > 0
    return result


# ─────────────────────────────────────────────────────────────────────────────
# Android — adb + emulator
# ─────────────────────────────────────────────────────────────────────────────

async def _android_find_device(session: GenerationSession) -> Optional[str]:
    """Return adb device serial of a running emulator."""
    if not _tool_available("adb"):
        session.log("SCREENER", "  adb not found (Android SDK not installed)", "warning")
        return None

    rc, out, _ = await _run(["adb", "devices", "-l"], None, 10, session)
    if rc != 0:
        return None

    for line in out.splitlines()[1:]:
        if "emulator" in line and "offline" not in line:
            serial = line.split()[0]
            session.log("SCREENER", f"  Found Android emulator: {serial}")
            return serial

    return None


async def _android_start_emulator(session: GenerationSession) -> Optional[str]:
    """Start an AVD. Returns serial when booted."""
    if not _tool_available("emulator"):
        return None

    # List AVDs
    rc, out, _ = await _run(["emulator", "-list-avds"], None, 10, session)
    if rc != 0 or not out.strip():
        session.log("SCREENER", "  No AVDs found — create one in Android Studio", "warning")
        return None

    avd_name = out.strip().splitlines()[0]
    session.log("SCREENER", f"  Starting emulator AVD: {avd_name}…")

    # Start in background
    emulator_proc = await asyncio.create_subprocess_exec(
        "emulator", "-avd", avd_name, "-no-window", "-no-audio", "-no-boot-anim",
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.DEVNULL,
    )

    # Poll for boot completion
    for _ in range(60):  # wait up to 60s
        await asyncio.sleep(2)
        rc2, out2, _ = await _run(
            ["adb", "shell", "getprop", "sys.boot_completed"],
            None, 5, session
        )
        if rc2 == 0 and "1" in out2:
            session.log("SCREENER", "  ✓ Android emulator booted", "success")
            # Return the serial
            serial = await _android_find_device(session)
            return serial

    session.log("SCREENER", "  ✗ Emulator boot timeout", "error")
    return None


async def _android_install_app(
    project_dir: Path,
    serial: str,
    session: GenerationSession,
) -> bool:
    session.log("SCREENER", "  Building for Android emulator…")

    rc, out, err = await _run(
        ["npx", "expo", "run:android",
         "--device", serial,
         "--non-interactive"],
        project_dir,
        BUILD_TIMEOUT,
        session,
    )

    if rc != 0:
        session.log("SCREENER", f"  ✗ Android build failed: {err[:200]}", "error")
        return False

    session.log("SCREENER", "  ✓ Android build + install complete", "success")
    return True


async def _android_launch_app(
    package: str,
    serial: str,
    session: GenerationSession,
) -> bool:
    # Force-stop first
    await _run(["adb", "-s", serial, "shell", "am", "force-stop", package], None, 10, session)
    await asyncio.sleep(1)

    # Launch main activity
    rc, _, err = await _run(
        ["adb", "-s", serial, "shell", "monkey",
         "-p", package, "-c", "android.intent.category.LAUNCHER", "1"],
        None, LAUNCH_TIMEOUT, session
    )

    if rc != 0:
        session.log("SCREENER", f"  ✗ Launch failed: {err[:100]}", "error")
        return False

    await asyncio.sleep(SCREEN_SETTLE_SECS)
    return True


async def _android_screenshot(
    serial: str,
    out_path: Path,
    session: GenerationSession,
) -> bool:
    """adb exec-out screencap -p — writes PNG bytes directly to file."""
    proc = await asyncio.create_subprocess_exec(
        "adb", "-s", serial, "exec-out", "screencap", "-p",
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        stdout_b, _ = await asyncio.wait_for(proc.communicate(), timeout=SCREENSHOT_TIMEOUT)
    except asyncio.TimeoutError:
        proc.kill()
        return False

    if not stdout_b or len(stdout_b) < 1000:
        return False

    out_path.write_bytes(stdout_b)
    return True


async def _android_tap(serial: str, x: int, y: int, session: GenerationSession) -> None:
    await _run(["adb", "-s", serial, "shell", "input", "tap", str(x), str(y)], None, 5, session)
    await asyncio.sleep(NAVIGATION_SETTLE_SECS)


async def run_android_screenshots(
    session: GenerationSession,
    screenshot_dir: Path,
) -> ScreenshotResult:
    result = ScreenshotResult(platform="android")
    plan = session.plan
    project_dir = session.project_dir

    serial = await _android_find_device(session)
    if not serial:
        serial = await _android_start_emulator(session)
    if not serial:
        result.error = "No Android emulator available"
        return result

    built = await _android_install_app(project_dir, serial, session)
    if not built:
        result.error = "Android build failed"
        return result

    launched = await _android_launch_app(plan.package_name, serial, session)
    if not launched:
        result.error = "App launch failed"
        return result

    screenshot_dir.mkdir(parents=True, exist_ok=True)

    # Screenshot launch screen
    shot0 = screenshot_dir / "01_launch.png"
    if await _android_screenshot(serial, shot0, session):
        result.screenshots.append(shot0)
        session.log("SCREENER", f"  📸 {shot0.name}", "success")

    # Tab navigation: tap bottom nav bar items
    # Assume 1080x1920 device. Tab bar at y=1800, items spaced 1080/N
    tab_screens = plan.tab_screens or []
    n_tabs = max(len(tab_screens), 1)
    for i, screen_name in enumerate(tab_screens[:5], start=1):
        x = int(1080 / n_tabs * (i - 0.5))
        await _android_tap(serial, x, 1800, session)
        shot = screenshot_dir / f"{i+1:02d}_{screen_name}.png"
        if await _android_screenshot(serial, shot, session):
            result.screenshots.append(shot)
            session.log("SCREENER", f"  📸 {shot.name}", "success")

    result.success = len(result.screenshots) > 0
    return result


# ─────────────────────────────────────────────────────────────────────────────
# Web fallback — Expo Web + Playwright
# ─────────────────────────────────────────────────────────────────────────────

async def run_web_screenshots(
    session: GenerationSession,
    screenshot_dir: Path,
) -> ScreenshotResult:
    """
    Export Expo app as web, serve it, use Playwright to screenshot each route.
    Falls back gracefully if playwright not installed.
    """
    result = ScreenshotResult(platform="web")
    project_dir = session.project_dir
    plan = session.plan

    if not project_dir:
        result.error = "No project directory"
        return result

    # Check playwright
    try:
        import playwright  # noqa: F401
    except ImportError:
        session.log("SCREENER", "  playwright not installed — run: pip install playwright && playwright install chromium", "warning")
        result.error = "playwright not installed"
        return result

    # Export web
    session.log("SCREENER", "  Running: npx expo export --platform web")
    rc, _, err = await _run(
        ["npx", "expo", "export", "--platform", "web"],
        project_dir, 180, session
    )
    if rc != 0:
        session.log("SCREENER", f"  ✗ expo export --platform web failed: {err[:200]}", "error")
        result.error = "expo web export failed"
        return result

    dist_dir = project_dir / "dist"
    if not dist_dir.exists():
        result.error = "dist/ not found after export"
        return result

    # Serve
    server_proc = await asyncio.create_subprocess_exec(
        "npx", "serve", str(dist_dir), "-p", str(EXPO_WEB_PORT), "--no-clipboard",
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.DEVNULL,
    )
    await asyncio.sleep(3)

    screenshot_dir.mkdir(parents=True, exist_ok=True)

    try:
        from playwright.async_api import async_playwright

        async with async_playwright() as pw:
            browser = await pw.chromium.launch(args=["--no-sandbox"])
            context = await browser.new_context(
                viewport={"width": 390, "height": 844},  # iPhone 14 Pro
                device_scale_factor=2,
            )
            page = await context.new_page()

            # Root screen
            base_url = f"http://localhost:{EXPO_WEB_PORT}"
            await page.goto(base_url, wait_until="networkidle")
            await asyncio.sleep(SCREEN_SETTLE_SECS)

            shot0 = screenshot_dir / "01_launch.png"
            await page.screenshot(path=str(shot0), full_page=False)
            result.screenshots.append(shot0)
            session.log("SCREENER", f"  📸 {shot0.name}", "success")

            # Navigate to each tab via hash/route
            screens = plan.screens or []
            for i, screen in enumerate(screens[:8], start=2):
                name = screen.get("name", f"screen_{i}")
                slug = name.lower().replace("screen", "").strip("-_") or name.lower()
                url = f"{base_url}/{slug}"

                await page.goto(url, wait_until="domcontentloaded")
                await asyncio.sleep(NAVIGATION_SETTLE_SECS)

                shot = screenshot_dir / f"{i:02d}_{name}.png"
                await page.screenshot(path=str(shot), full_page=False)
                result.screenshots.append(shot)
                session.log("SCREENER", f"  📸 {shot.name}", "success")

            await browser.close()

    except Exception as e:
        session.log("SCREENER", f"  ✗ Playwright error: {e}", "error")
        result.error = str(e)
    finally:
        server_proc.terminate()
        try:
            await server_proc.wait()
        except Exception:
            pass

    result.success = len(result.screenshots) > 0
    return result


# ─────────────────────────────────────────────────────────────────────────────
# Orchestrator
# ─────────────────────────────────────────────────────────────────────────────

async def run_screenshotter(
    session: GenerationSession,
    screenshot_dir: Optional[Path] = None,
) -> list[Path]:
    """
    Try iOS → Android → Web in order. Returns list of screenshot Paths.
    Never raises — always returns what it got (even empty list).
    """
    if screenshot_dir is None:
        screenshot_dir = session.project_dir / ".forge-screenshots" if session.project_dir else Path("/tmp/forge-screenshots")

    agent_run = session.start_agent("SCREENER", "xcrun simctl / adb / playwright")
    session.log("SCREENER", "Attempting to screenshot running app…")

    screenshots: list[Path] = []
    is_mac = platform.system() == "Darwin"

    # iOS — macOS only
    if is_mac and _tool_available("xcrun"):
        session.log("SCREENER", "Trying iOS simulator…")
        res = await run_ios_screenshots(session, screenshot_dir / "ios")
        if res.success and res.screenshots:
            screenshots = res.screenshots
            session.log_success("SCREENER", f"✓ {len(screenshots)} iOS screenshots captured")
            session.finish_agent(agent_run, platform="ios", screenshots=len(screenshots))
            return screenshots
        else:
            session.log_warn("SCREENER", f"  iOS failed: {res.error} — trying Android")

    # Android
    if _tool_available("adb"):
        session.log("SCREENER", "Trying Android emulator…")
        res = await run_android_screenshots(session, screenshot_dir / "android")
        if res.success and res.screenshots:
            screenshots = res.screenshots
            session.log_success("SCREENER", f"✓ {len(screenshots)} Android screenshots captured")
            session.finish_agent(agent_run, platform="android", screenshots=len(screenshots))
            return screenshots
        else:
            session.log_warn("SCREENER", f"  Android failed: {res.error} — trying web")

    # Web fallback
    session.log("SCREENER", "Trying Expo web export + Playwright…")
    res = await run_web_screenshots(session, screenshot_dir / "web")
    if res.success and res.screenshots:
        screenshots = res.screenshots
        session.log_success("SCREENER", f"✓ {len(screenshots)} web screenshots captured")
    else:
        session.log_warn("SCREENER", f"  Web failed: {res.error}")
        session.log_warn("SCREENER", "  No screenshots captured — run `npx expo start` manually")

    session.finish_agent(
        agent_run,
        platform="web" if res.success else "none",
        screenshots=len(screenshots),
    )
    return screenshots
