#!/usr/bin/env python3
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A web server to access ADB via web UI, while serving local files."""

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import threading
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer

PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n'
LOCALHOST = '127.0.0.1'
DPI_PER_DP = 160.0
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
STATIC_DIR = os.path.join(SCRIPT_DIR, 'static')


class AdbClient:
    """Wraps ADB path resolution, shell settings, and command execution."""

    def __init__(self):
        self.adb_path = shutil.which('adb')
        if not self.adb_path:
            print("Error: 'adb' command not found. Is it in your PATH?")
            sys.exit(1)
        self.use_shell = os.name == 'nt' and self.adb_path.lower().endswith(
            ('.bat', '.cmd')
        )

    def run(self, args, **kwargs):
        """Executes an ADB command using the global config."""
        cmd = [self.adb_path]
        if _config.serial:
            cmd.extend(['-s', _config.serial])
        cmd.extend(args)

        kwargs.setdefault('shell', self.use_shell)
        kwargs.setdefault('capture_output', True)
        kwargs.setdefault('check', True)
        return subprocess.run(cmd, **kwargs)

    def get_device(self, serial_arg):
        """Gets the ADB device serial, prompting the user if multiple exist."""
        if serial_arg:
            return serial_arg

        try:
            result = self.run(['devices'], text=True)
        except subprocess.CalledProcessError as e:
            print(f"Error running 'adb devices': {e}")
            sys.exit(1)

        lines = result.stdout.strip().split('\n')[1:]
        devices = []
        for line in lines:
            parts = line.split()
            if len(parts) >= 2 and parts[1] == 'device':
                devices.append(parts[0])

        if not devices:
            print("Error: No ADB devices connected.")
            sys.exit(1)

        if len(devices) == 1:
            return devices[0]

        print("Multiple ADB devices detected:")
        for i, device in enumerate(devices):
            print(f"[{i + 1}] {device}")

        while True:
            try:
                choice = input(f"Select a device (1-{len(devices)}): ")
                index = int(choice) - 1
                if 0 <= index < len(devices):
                    return devices[index]
                print("Invalid selection.")
            except ValueError:
                print("Please enter a number.")
            except (KeyboardInterrupt, EOFError):
                print("\nExiting.")
                sys.exit(1)


class ServerConfig:
    """Global server configurations."""

    def __init__(self):
        # Sub-components.
        self.adb = None

        # User-configurable settings.
        self.serial = None
        self.port = 8000
        self.use_cache = False
        self.local = True
        self.require_imprint = True

        # Imprint states.
        self.imprint_lock = threading.Lock()
        self.allowed_client_ip = None

        # Other configs.
        self.timeout = 120  # Seconds.

    @property
    def ip_address(self):
        return LOCALHOST if self.local else ''


# Global states.
_config = ServerConfig()


class MainRequestHandler(SimpleHTTPRequestHandler):
    """A request handler to serve files and ADB endpoints."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=STATIC_DIR, **kwargs)

    def _get_density_data(self):
        """Gets the display density factor from the device."""
        result = _config.adb.run(['shell', 'wm', 'density'], text=True)
        # Output can be "Physical density: 480" or include
        # "Override density: 560". We want the last number found in the output.
        densities = re.findall(r':\s*(\d+)', result.stdout)
        if not densities:
            raise ValueError(f'Could not parse density from: {result.stdout}')
        dpi = int(densities[-1])
        return dpi / DPI_PER_DP

    def handle_density(self):
        """Handles the /api/density endpoint."""
        try:
            density_factor = self._get_density_data()
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(
                json.dumps(
                    {'success': True, 'density_factor': density_factor}
                ).encode('utf-8')
            )
        except (
            subprocess.CalledProcessError,
            FileNotFoundError,
            ValueError,
        ) as e:
            self.send_error(
                HTTPStatus.INTERNAL_SERVER_ERROR, f'Failed to get density: {e}'
            )

    def _get_ui_dump_data(self):
        """Gets the UI hierarchy XML from the device."""
        temp_device_path = None

        try:
            # Create a temporary file on the device
            mktemp_result = _config.adb.run(['shell', 'mktemp'], text=True)
            temp_device_path = mktemp_result.stdout.strip()

            # Dump UI to the temporary file on the device
            _config.adb.run(['shell', 'uiautomator', 'dump', temp_device_path])

            # Read the file content
            result = _config.adb.run(
                ['shell', 'cat', temp_device_path], text=True
            )
            return result.stdout
        finally:
            # Clean up the temporary file on the device if it was created
            if temp_device_path:
                _config.adb.run(['shell', 'rm', temp_device_path], check=False)

    def handle_ui_dump(self):
        """Handles the /api/ui-dump endpoint."""
        try:
            xml_content = self._get_ui_dump_data()
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(
                json.dumps({'success': True, 'xml': xml_content}).encode(
                    'utf-8'
                )
            )
        except (subprocess.CalledProcessError, FileNotFoundError) as e:
            self.send_error(
                HTTPStatus.INTERNAL_SERVER_ERROR, f'Failed to get UI dump: {e}'
            )

    def _get_screenshot_data(self):
        """Gets a screenshot from the device."""
        # exec-out is required for binary output without corruption on some
        # systems.
        result = _config.adb.run(['exec-out', 'screencap', '-p'])
        # Devices with multiple screens (e.g., foldable) might be prefixed with
        # warning message. Truncate everything before PNG file signature.
        data = result.stdout
        start = data.find(PNG_SIGNATURE)
        return data[start:] if start >= 0 else data

    def handle_screenshot(self):
        """Handles the /api/screenshot endpoint."""
        try:
            png_data = self._get_screenshot_data()
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'image/png')
            self.end_headers()
            self.wfile.write(png_data)
        except (subprocess.CalledProcessError, FileNotFoundError) as e:
            self.send_error(
                HTTPStatus.INTERNAL_SERVER_ERROR,
                f'Failed to get screenshot: {e}',
            )

    def do_GET(self):
        """Handles GET requests."""
        if _config.require_imprint:
            client_ip = self.client_address[0]
            with _config.imprint_lock:
                if _config.allowed_client_ip is None:
                    print(f"Imprinting server to client IP: {client_ip}")
                    _config.allowed_client_ip = client_ip
                elif _config.allowed_client_ip != client_ip:
                    print(
                        f"Blocking request from {client_ip} "
                        f"(Imprinted to {_config.allowed_client_ip})"
                    )
                    self.send_error(
                        HTTPStatus.FORBIDDEN,
                        "Forbidden: Server is imprinted to a different IP.",
                    )
                    return

        if self.path == '/':
            self.path = '/index.html'

        if self.path == '/api/ui-dump.xml':
            self.handle_ui_dump()
            return
        if self.path == '/api/screenshot.png':
            self.handle_screenshot()
            return
        if self.path == '/api/density.json':
            self.handle_density()
            return

        super().do_GET()


class CachingMainRequestHandler(MainRequestHandler):
    """An ADB request handler that caches ADB-related data in memory."""

    # The cache is stored in class fields, which is fine since the server is
    # single-threaded and the cache is read-only after the first hit.
    cached_screenshot = None
    cached_ui_dump = None
    cached_density = None

    def _get_screenshot_data(self):
        """Gets a screenshot, using a cache if available."""
        if not CachingMainRequestHandler.cached_screenshot:
            CachingMainRequestHandler.cached_screenshot = (
                super()._get_screenshot_data()
            )
        return CachingMainRequestHandler.cached_screenshot

    def _get_ui_dump_data(self):
        """Gets the UI hierarchy XML, using a cache if available."""
        if not CachingMainRequestHandler.cached_ui_dump:
            CachingMainRequestHandler.cached_ui_dump = (
                super()._get_ui_dump_data()
            )
        return CachingMainRequestHandler.cached_ui_dump

    def _get_density_data(self):
        """Gets the display density factor, using a cache if available."""
        if not CachingMainRequestHandler.cached_density:
            CachingMainRequestHandler.cached_density = (
                super()._get_density_data()
            )
        return CachingMainRequestHandler.cached_density


def run(handler_class):
    """Starts the HTTP server."""
    server_address = (_config.ip_address, _config.port)
    httpd = ThreadingHTTPServer(server_address, handler_class)
    display_ip = _config.ip_address or LOCALHOST
    print(f'Starting server on http://{display_ip}:{_config.port}')

    if _config.require_imprint:

        def check_imprint_timeout():
            if _config.allowed_client_ip is None:
                print(
                    f"Timeout: No client connected within {_config.timeout}s. "
                    "Exiting."
                )
                httpd.shutdown()

        # Start a timer to shut down if not imprinted within the timeout.
        timer = threading.Timer(_config.timeout, check_imprint_timeout)
        timer.start()

    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        if _config.require_imprint and 'timer' in locals():
            timer.cancel()
        httpd.server_close()


def main():
    parser = argparse.ArgumentParser(
        description='Backend server for the Chrome Android Layout Inspector.'
    )
    parser.add_argument(
        '-s',
        '--serial',
        help='Directs ADB commands to the specific device or emulator.',
    )
    parser.add_argument(
        '-p',
        '--port',
        type=int,
        default=_config.port,
        help=f'Port to run the server on (default: {_config.port}).',
    )
    parser.add_argument(
        '--cache',
        action='store_true',
        help='Cache ADB results in memory to speed up front-end debugging.',
    )
    parser.add_argument(
        '--remote',
        action='store_true',
        help='Bind server to all interfaces instead of localhost.',
    )
    parser.add_argument(
        '--open',
        action='store_true',
        help='Disable security imprint and timeout (allow all IPs).',
    )
    args = parser.parse_args()

    # Initialize sub-components.
    _config.adb = AdbClient()

    # Process arguments.
    _config.port = args.port
    _config.use_cache = args.cache
    _config.local = not args.remote
    _config.require_imprint = not args.open
    _config.serial = _config.adb.get_device(args.serial)
    print(f"Using ADB device: {_config.serial}")

    Handler = (
        CachingMainRequestHandler if _config.use_cache else MainRequestHandler
    )
    if _config.use_cache:
        print('Caching is enabled.')

    if _config.require_imprint:
        print(
            f'Security imprint ENABLED. Waiting {_config.timeout}s '
            'for first connection...'
        )
    else:
        print('Security imprint DISABLED. Server is open to all IPs.')
    run(handler_class=Handler)


if __name__ == '__main__':
    main()
