#!/usr/bin/env python3
"""Control a debug DroidDeck build over ADB."""

from __future__ import annotations

import argparse
import base64
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path

from adb_device import DeviceResolutionError, resolve_serial


AUTHORITY = "content://com.droiddeck.launcher.agent"


class ControlError(RuntimeError):
    def __init__(self, message: str, code: str = "COMMAND_FAILED") -> None:
        super().__init__(message)
        self.code = code


def adb_path(configured: str | None) -> str:
    if configured:
        return configured
    environment_adb = os.environ.get("ADB")
    if environment_adb:
        return environment_adb
    found = shutil.which("adb")
    if found:
        return found
    sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
    if sdk:
        candidate = Path(sdk) / "platform-tools" / "adb"
        if candidate.is_file():
            return str(candidate)
    home_dir = Path.home()
    candidate = home_dir / "Library" / "Android" / "sdk" / "platform-tools" / "adb"
    if candidate.is_file():
        return str(candidate)
    raise ControlError("adb not found; set ADB, ANDROID_HOME, or ANDROID_SDK_ROOT", "ADB_NOT_FOUND")


def run_adb(adb: str, serial: str, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[bytes]:
    try:
        return subprocess.run(
            [adb, "-s", serial, *args], capture_output=True, check=False, timeout=timeout
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        raise ControlError(str(exc), "ADB_FAILED") from exc


def _content_result(output: str) -> dict:
    marker = "json="
    start = output.find(marker)
    if start < 0:
        detail = output.strip() or "The debug agent provider returned no result"
        raise ControlError(detail, "INVALID_PROVIDER_RESPONSE")
    raw = output[start + len(marker):].lstrip()
    try:
        response, _ = json.JSONDecoder().raw_decode(raw)
        if not isinstance(response, dict):
            raise ValueError("expected an object")
        return response
    except (json.JSONDecodeError, ValueError) as exc:
        raise ControlError("Could not decode the agent provider response", "INVALID_PROVIDER_RESPONSE") from exc


def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None, timeout: int = 30) -> dict:
    command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method]
    if arg is not None:
        command.extend(["--arg", arg])
    if request is not None:
        encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
        command.extend(["--extra", f"request:s:{encoded}"])
    result = run_adb(adb, serial, *command, timeout=timeout)
    output = (result.stdout + result.stderr).decode(errors="replace")
    response = _content_result(output)
    if result.returncode != 0 and response.get("ok") is not False:
        raise ControlError(output.strip() or "ADB content call failed", "ADB_FAILED")
    return response


def start_agent_activity(adb: str, serial: str, request: dict) -> None:
    encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
    result = run_adb(
        adb,
        serial,
        "shell",
        "am",
        "start",
        "-W",
        "-n",
        "com.droiddeck.launcher/.agent.AgentStartActivity",
        "--es",
        "request",
        encoded,
        timeout=90,
    )
    output = (result.stdout + result.stderr).decode(errors="replace")
    if result.returncode != 0 or "status: ok" not in output.lower():
        raise ControlError(output.strip() or "The agent start Activity did not launch", "ACTIVITY_START_FAILED")


def confirm_start(adb: str, serial: str, request: dict, previous: dict) -> dict:
    deadline = time.monotonic() + 95
    expected_mode = {"steam": "steam", "desktop": "lxqt", "run": "run"}[request["mode"]]
    while True:
        state = get_state(adb, serial)
        session = state.get("session", {})
        phase = session.get("phase")
        if session.get("mode") == expected_mode and session.get("id") != previous.get("id"):
            return state
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise ControlError(
                f"The agent Activity launched but no new session appeared; phase is {phase}",
                "ACTIVITY_START_FAILED",
            )
        time.sleep(min(0.25, remaining))


def get_state(adb: str, serial: str) -> dict:
    response = provider_call(adb, serial, "state")
    if response.get("ok") is False:
        error = response.get("error", {})
        raise ControlError(error.get("message", "state failed"), error.get("code", "STATE_FAILED"))
    return response


def wait_for_phase(adb: str, serial: str, wanted: str, timeout: float) -> dict:
    deadline = time.monotonic() + timeout
    last_state: dict | None = None
    while True:
        last_state = get_state(adb, serial)
        session = last_state.get("session", {})
        phase = session.get("phase")
        if phase == wanted:
            return last_state
        if phase == "FAILED" and wanted != "FAILED":
            failure = session.get("failure") or {}
            raise ControlError(
                f"Session failed ({failure.get('code', 'FAILED')}): {failure.get('message', 'unknown error')}",
                failure.get("code", "SESSION_FAILED"),
            )
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise ControlError(
                f"Timed out waiting for {wanted}; last phase was {phase}", "TIMEOUT"
            )
        time.sleep(min(1.0, remaining))


def wait_for_artifacts(adb: str, serial: str, state: dict, timeout: float) -> dict:
    deadline = time.monotonic() + timeout
    while True:
        session = state.get("session", {})
        if session.get("artifactsComplete") or not session.get("artifactsAvailable"):
            return state
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise ControlError("Session stopped, but artifact collection did not complete", "ARTIFACT_TIMEOUT")
        time.sleep(min(1.0, remaining))
        state = get_state(adb, serial)


def add_serial(response: dict, serial: str) -> dict:
    response["deviceSerial"] = serial
    return response


def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
    if args.command == "state":
        return add_serial(get_state(adb, serial), serial)

    if args.command == "start":
        current = get_state(adb, serial)
        if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
            raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
        request = {"mode": args.mode}
        if args.mode == "steam":
            if args.ui == "desktop":
                request["steamUi"] = "desktop"
            if args.url:
                if not args.url.startswith("steam://"):
                    raise ControlError("Steam URL must start with steam://", "INVALID_STEAM_URL")
                request["steamUrl"] = args.url
        start_agent_activity(adb, serial, request)
        started = confirm_start(adb, serial, request, current["session"])
        if args.wait:
            return add_serial(wait_for_phase(adb, serial, "READY", args.timeout), serial)
        session = started["session"]
        return add_serial({
            "ok": True,
            "command": "start",
            "mode": args.mode,
            "sessionId": session.get("id"),
            "phase": session.get("phase"),
        }, serial)

    if args.command == "run":
        current = get_state(adb, serial)
        if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
            raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
        program_args = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
        request = {"mode": "run", "program": args.program, "programArgs": program_args}
        start_agent_activity(adb, serial, request)
        started = confirm_start(adb, serial, request, current["session"])
        session = started["session"]
        return add_serial({
            "ok": True,
            "command": "start",
            "mode": "run",
            "sessionId": session.get("id"),
            "phase": session.get("phase"),
        }, serial)

    if args.command == "wait":
        phases = {"ready": "READY", "idle": "IDLE", "failed": "FAILED"}
        return add_serial(wait_for_phase(adb, serial, phases[args.phase], args.timeout), serial)

    if args.command == "stop":
        current = get_state(adb, serial)
        if current["session"].get("phase") in {"IDLE", "FAILED"}:
            return add_serial(wait_for_artifacts(adb, serial, current, args.timeout), serial)
        response = provider_call(adb, serial, "stop")
        if response.get("ok") is False:
            error = response.get("error", {})
            raise ControlError(error.get("message", "stop failed"), error.get("code", "STOP_FAILED"))
        state = wait_for_phase(adb, serial, "IDLE", args.timeout)
        return add_serial(wait_for_artifacts(adb, serial, state, args.timeout), serial)

    if args.command == "resume":
        response = provider_call(adb, serial, "resume")
        if response.get("ok") is False:
            error = response.get("error", {})
            raise ControlError(error.get("message", "resume failed"), error.get("code", "RESUME_FAILED"))
        return add_serial(response, serial)

    if args.command == "logs":
        state = get_state(adb, serial)
        session = state.get("session", {})
        if not session.get("running") and session.get("artifactsAvailable") and not session.get("artifactsComplete"):
            state = wait_for_artifacts(adb, serial, state, args.timeout)
            session = state.get("session", {})
        response = provider_call(adb, serial, "logs", timeout=120)
        if response.get("ok") is False:
            error = response.get("error", {})
            raise ControlError(error.get("message", "log export failed"), error.get("code", "LOG_EXPORT_FAILED"))
        destination = Path(args.destination or f"{response['sessionId']}.zip").expanduser().resolve()
        result = run_adb(adb, serial, "exec-out", "run-as", "com.droiddeck.launcher", "cat", response["path"], timeout=120)
        if result.returncode != 0:
            detail = result.stderr.decode(errors="replace").strip()
            raise ControlError(detail or "log export failed", "LOG_PULL_FAILED")
        destination.parent.mkdir(parents=True, exist_ok=True)
        destination.write_bytes(result.stdout)
        return add_serial({"ok": True, "command": "logs", "sessionId": response["sessionId"], "path": str(destination)}, serial)

    if args.command == "screenshot":
        destination = Path(args.path).expanduser().resolve()
        result = run_adb(adb, serial, "exec-out", "screencap", "-p", timeout=30)
        if result.returncode != 0:
            detail = result.stderr.decode(errors="replace").strip()
            raise ControlError(detail or "screencap failed", "SCREENSHOT_FAILED")
        try:
            destination.parent.mkdir(parents=True, exist_ok=True)
            destination.write_bytes(result.stdout)
        except OSError as exc:
            raise ControlError(str(exc), "FILE_IO") from exc
        return add_serial({"ok": True, "command": "screenshot", "path": str(destination)}, serial)

    raise ControlError(f"Unknown command: {args.command}", "UNKNOWN_COMMAND")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--adb", help="adb executable (defaults to ADB, PATH, or the Android SDK)")
    parser.add_argument("--serial", help="ADB serial; defaults to ADB_SERIAL or ANDROID_SERIAL")
    commands = parser.add_subparsers(dest="command", required=True)

    state = commands.add_parser("state", help="print machine-readable session state")
    state.add_argument("--json", action="store_true", help="accepted for explicit machine-readable usage")

    start = commands.add_parser("start", help="start Steam or the desktop")
    start.add_argument("mode", choices=["steam", "desktop"])
    start.add_argument("--ui", choices=["bigpicture", "desktop"], default="bigpicture")
    start.add_argument("--url", help="steam:// URL to pass to the client")
    start.add_argument("--wait", action="store_true", help="wait for READY after starting")
    start.add_argument("--timeout", type=float, default=90)

    run = commands.add_parser("run", help="run a guest program under Gamescope")
    run.add_argument("program")
    run.add_argument("arguments", nargs=argparse.REMAINDER)

    wait = commands.add_parser("wait", help="wait for a session phase")
    wait.add_argument("phase", choices=["ready", "idle", "failed"])
    wait.add_argument("--timeout", type=float, default=90)

    stop = commands.add_parser("stop", help="stop the session and wait for IDLE")
    stop.add_argument("--timeout", type=float, default=90)
    commands.add_parser("resume", help="resume a suspended session")

    logs = commands.add_parser("logs", help="export sanitized session logs as a ZIP")
    logs.add_argument("which", choices=["latest"])
    logs.add_argument("destination", nargs="?")
    logs.add_argument("--timeout", type=float, default=90)

    screenshot = commands.add_parser("screenshot", help="save a device screenshot")
    screenshot.add_argument("path")
    return parser


def exit_code(exc: Exception) -> int:
    if isinstance(exc, DeviceResolutionError):
        return 3
    code = getattr(exc, "code", "")
    if code in {"ADB_NOT_FOUND", "ADB_FAILED", "INVALID_PROVIDER_RESPONSE"}:
        return 3
    if code in {"SESSION_ACTIVE", "NO_ACTIVE_SESSION", "SESSION_NOT_SUSPENDED", "SESSION_FAILED",
                "GUEST_EXIT", "GUEST_START_FAILED", "RUNTIME_INSTALL_FAILED", "DESKTOP_INSTALL_FAILED",
                "ACTIVITY_START_FAILED"}:
        return 4
    if code in {"TIMEOUT", "ARTIFACT_TIMEOUT"}:
        return 5
    if code in {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO"}:
        return 6
    return 2


def main() -> int:
    args = build_parser().parse_args()
    serial = None
    try:
        adb = adb_path(args.adb)
        serial = resolve_serial(adb, args.serial, log=True)
        result = execute(args, adb, serial)
        print(json.dumps(result, indent=2, sort_keys=True))
        return 0
    except (ControlError, DeviceResolutionError) as exc:
        error = {"code": getattr(exc, "code", "DEVICE_RESOLUTION_FAILED"), "message": str(exc)}
        response = {"ok": False, "error": error}
        if serial:
            response["deviceSerial"] = serial
        print(json.dumps(response, indent=2, sort_keys=True))
        return exit_code(exc)


if __name__ == "__main__":
    raise SystemExit(main())
