import contextlib
import logging
import os
import posixpath
import shlex
from datetime import datetime
from enum import IntEnum
from pathlib import Path
from typing import Annotated, NamedTuple, Optional

import typer
from click.exceptions import BadParameter, MissingParameter, UsageError
from pygments import formatters, highlight, lexers
from typer_injector import InjectingTyper

from pymobiledevice3.cli.cli_common import (
    OSUTILS,
    ServiceProviderDep,
    async_command,
    print_json,
    user_requested_colored_output,
)
from pymobiledevice3.cli.developer.dvt import core_profile_session, simulate_location, sysmon
from pymobiledevice3.exceptions import DvtDirListError, UnrecognizedSelectorError
from pymobiledevice3.services.dvt.instruments.activity_trace_tap import ActivityTraceTap, decode_message_format
from pymobiledevice3.services.dvt.instruments.application_listing import ApplicationListing
from pymobiledevice3.services.dvt.instruments.condition_inducer import ConditionInducer
from pymobiledevice3.services.dvt.instruments.device_info import DeviceInfo
from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider
from pymobiledevice3.services.dvt.instruments.energy_monitor import EnergyMonitor
from pymobiledevice3.services.dvt.instruments.graphics import Graphics
from pymobiledevice3.services.dvt.instruments.network_monitor import ConnectionDetectionEvent, NetworkMonitor
from pymobiledevice3.services.dvt.instruments.notifications import Notifications
from pymobiledevice3.services.dvt.instruments.process_control import ProcessControl
from pymobiledevice3.services.dvt.instruments.screenshot import Screenshot
from pymobiledevice3.services.dvt.testmanaged.xcuitest import (
    TestConfig,
    XCTestCaseResult,
    XCUITestListener,
    XCUITestService,
)
from pymobiledevice3.utils import run_in_loop, start_ipython_shell

logger = logging.getLogger(__name__)

SHELL_USAGE = """
# DVT shell (DtxServiceProvider-backed)
# Connection is available as:
# - dvt  : DvtProvider
# - dtx  : DTXConnection
#
# Open a service channel:
channel = await dtx.open_channel("com.apple.instruments.server.services.deviceinfo")
#
# Invoke a selector:
procs = await channel.invoke("runningProcesses")
#
# Open process control and kill a PID:
pc = await dtx.open_channel("com.apple.instruments.server.services.processcontrol")
await pc.invoke("killPid:", 1234, expects_reply=False)
"""


class MatchedProcessByPid(NamedTuple):
    name: str
    pid: int


cli = InjectingTyper(
    name="dvt",
    help="Drive DVT instrumentation APIs (process control, metrics, traces).",
    no_args_is_help=True,
)

cli.add_typer(sysmon.cli)
cli.add_typer(core_profile_session.cli)
cli.add_typer(simulate_location.cli)

condition_cli = InjectingTyper(
    name="condition",
    help="Force predefined device conditions (network, thermal, battery) via DVT.",
    no_args_is_help=True,
)
cli.add_typer(condition_cli)


@condition_cli.command("list")
@async_command
async def condition_list(service_provider: ServiceProviderDep) -> None:
    """List available condition profiles."""
    async with DvtProvider(service_provider) as dvt, ConditionInducer(dvt) as condition_inducer:
        print_json(await condition_inducer.list())


@condition_cli.command("clear")
@async_command
async def condition_clear(service_provider: ServiceProviderDep) -> None:
    """Clear any active induced condition."""
    async with DvtProvider(service_provider) as dvt, ConditionInducer(dvt) as condition_inducer:
        await condition_inducer.clear()


@condition_cli.command("set")
@async_command
async def condition_set(service_provider: ServiceProviderDep, profile_identifier: str) -> None:
    """Apply a specific condition profile by identifier."""
    async with DvtProvider(service_provider) as dvt, ConditionInducer(dvt) as condition_inducer:
        await condition_inducer.set(profile_identifier)
        OSUTILS.wait_return()


@cli.command("proclist")
@async_command
async def proclist(service_provider: ServiceProviderDep) -> None:
    """Show processes (with start times) via DVT."""
    async with DvtProvider(service_provider) as dvt, DeviceInfo(dvt) as device_info:
        processes = await device_info.proclist()
        for process in processes:
            if "startDate" in process:
                process["startDate"] = str(process["startDate"])

        print_json(processes)


@cli.command("is-running-pid")
@async_command
async def is_running_pid(service_provider: ServiceProviderDep, pid: int) -> None:
    """Check if a PID is currently running."""
    async with DvtProvider(service_provider) as dvt, DeviceInfo(dvt) as device_info:
        print_json(await device_info.is_running_pid(pid))


@cli.command("memlimitoff")
@async_command
async def memlimitoff(service_provider: ServiceProviderDep, pid: int) -> None:
    """Disable jetsam memory limit for a PID."""
    async with DvtProvider(service_provider) as dvt, ProcessControl(dvt) as process_control:
        await process_control.disable_memory_limit_for_pid(pid)


@cli.command("applist")
@async_command
async def applist(service_provider: ServiceProviderDep) -> None:
    """List installed applications via DVT."""
    async with DvtProvider(service_provider) as dvt, ApplicationListing(dvt) as application_listing:
        apps = await application_listing.applist()
        print_json(apps)


class Signals(IntEnum):
    """Platform-independent version of `signal.Signals`, allowing names to be used on Windows."""

    HUP = 1
    INT = 2
    QUIT = 3
    ILL = 4
    TRAP = 5
    ABRT = 6
    EMT = 7
    FPE = 8
    KILL = 9
    BUS = 10
    SEGV = 11
    SYS = 12
    PIPE = 13
    ALRM = 14
    TERM = 15
    URG = 16
    STOP = 17
    TSTP = 18
    CONT = 19
    CHLD = 20
    TTIN = 21
    TTOU = 22
    IO = 23
    XCPU = 24
    XFSZ = 25
    VTALRM = 26
    PROF = 27
    WINCH = 28
    INFO = 29
    USR1 = 30
    USR2 = 31


@cli.command("signal")
@async_command
async def send_signal(
    service_provider: ServiceProviderDep,
    pid: int,
    sig: Annotated[
        Optional[int],
        typer.Argument(),
    ] = None,
    signal_name: Annotated[
        Optional[str],
        typer.Option("--signal-name", "-s"),
    ] = None,
) -> None:
    """Send a signal to a PID (choose numeric SIG or --signal-name)."""
    if sig is not None and signal_name is not None:
        raise UsageError(message="Cannot give SIG and SIGNAL-NAME together")

    if signal_name is not None:
        normalized_signal_name = signal_name.upper().removeprefix("SIG")
        try:
            sig = Signals[normalized_signal_name]
        except KeyError:
            raise BadParameter(f"{signal_name!r} is not a valid signal") from None
    elif sig is not None:
        try:
            sig = Signals(sig)
        except ValueError:
            raise BadParameter(f"{sig} is not a valid signal") from None
    else:
        raise MissingParameter(param_type="argument|option", param_hint="'SIG|SIGNAL-NAME'")

    async with DvtProvider(service_provider) as dvt, ProcessControl(dvt) as process_control:
        await process_control.signal(pid, sig)


@cli.command("kill")
@async_command
async def kill(service_provider: ServiceProviderDep, pid: int) -> None:
    """Kill a process by PID."""
    async with DvtProvider(service_provider) as dvt, ProcessControl(dvt) as process_control:
        await process_control.kill(pid)


@cli.command()
@async_command
async def process_id_for_bundle_id(service_provider: ServiceProviderDep, app_bundle_identifier: str) -> None:
    """Get PID of a bundle identifier (only returns a valid value if its running)."""
    async with DvtProvider(service_provider) as dvt, ProcessControl(dvt) as process_control:
        print(await process_control.process_identifier_for_bundle_identifier(app_bundle_identifier))


async def get_matching_processes(
    device_info: DeviceInfo,
    name: Optional[str] = None,
    bundle_identifier: Optional[str] = None,
) -> list[MatchedProcessByPid]:
    result: list[MatchedProcessByPid] = []
    for process in await device_info.proclist():
        current_name = process["name"]
        current_bundle_identifier = process.get("bundleIdentifier", "")
        pid = process["pid"]
        if (bundle_identifier is not None and bundle_identifier in current_bundle_identifier) or (
            name is not None and name in current_name
        ):
            result.append(MatchedProcessByPid(name=current_name, pid=pid))
    return result


@cli.command("pkill")
@async_command
async def pkill(
    service_provider: ServiceProviderDep,
    expressions: Annotated[
        list[str],
        typer.Argument(help="One or more process-name (or bundle id) expressions to match."),
    ],
    bundle: Annotated[
        bool,
        typer.Option(help="Treat given expressions as bundle-identifiers instead of process names"),
    ] = False,
) -> None:
    """Kill all processes containing each expression in their name."""
    async with (
        DvtProvider(service_provider) as dvt_provider,
        DeviceInfo(dvt_provider) as device_info,
        ProcessControl(dvt_provider) as process_control,
    ):
        for expression in expressions:
            matching_name = expression if not bundle else None
            matching_bundle_identifier = expression if bundle else None
            matching_processes = await get_matching_processes(
                device_info, name=matching_name, bundle_identifier=matching_bundle_identifier
            )

            for process in matching_processes:
                logger.info(f"Killing {process.name}({process.pid})")
                await process_control.kill(process.pid)


@cli.command("launch")
@async_command
async def launch(
    service_provider: ServiceProviderDep,
    arguments: str,
    kill_existing: Annotated[
        bool,
        typer.Option(help="Whether to kill an existing instance of this process"),
    ] = True,
    suspended: Annotated[
        bool,
        typer.Option(help="Same as WaitForDebugger"),
    ] = False,
    env: Annotated[
        Optional[list[str]],
        typer.Option(
            help="Environment variable to pass to process given as key=value (can be specified multiple times)"
        ),
    ] = None,
    stream: bool = False,
) -> None:
    """Launch a process."""
    async with DvtProvider(service_provider) as dvt, ProcessControl(dvt) as process_control:
        parsed_arguments = shlex.split(arguments)
        pid = await process_control.launch(
            bundle_id=parsed_arguments[0],
            arguments=parsed_arguments[1:],
            kill_existing=kill_existing,
            start_suspended=suspended,
            environment=dict(var.split("=", 1) for var in env or ()),
        )
        print(f"Process launched with pid {pid}")
        if stream:
            async for output_received in process_control:
                logging.getLogger(f"PID:{output_received.pid}").info(output_received.message.strip())


@cli.command("shell")
def dvt_shell(service_provider: ServiceProviderDep) -> None:
    """Launch developer shell (used for pymobiledevice3 R&D)"""
    dvt = DvtProvider(service_provider)
    run_in_loop(dvt.connect())
    try:
        start_ipython_shell(
            header=highlight(
                SHELL_USAGE,
                lexers.PythonLexer(),
                formatters.Terminal256Formatter(style="native"),
            ),
            user_ns={"dvt": dvt, "dtx": dvt.dtx},
        )
    finally:
        run_in_loop(dvt.close())


async def show_dirlist(device_info: DeviceInfo, dirname: str, recursive: bool = False) -> None:
    try:
        filenames = await device_info.ls(dirname)
    except DvtDirListError:
        logging.error(f"Failed to list directory: {dirname}")
        return
    if not isinstance(filenames, list):
        return

    for filename in filenames:
        child = posixpath.join(dirname, filename)
        print(child)
        if recursive:
            await show_dirlist(device_info, child, recursive=recursive)


@cli.command("ls")
@async_command
async def ls(
    service_provider: ServiceProviderDep,
    path: str,
    recursive: Annotated[
        bool,
        typer.Option("--recursive", "-r"),
    ] = False,
) -> None:
    """List directory"""
    async with DvtProvider(service_provider) as dvt, DeviceInfo(dvt) as device_info:
        await show_dirlist(device_info, path, recursive=recursive)


@cli.command("device-information")
@async_command
async def device_information(service_provider: ServiceProviderDep) -> None:
    """Print system information"""
    async with DvtProvider(service_provider) as dvt, DeviceInfo(dvt) as device_info:
        info = {
            "hardware": await device_info.hardware_information(),
            "network": await device_info.network_information(),
            "kernel-name": await device_info.mach_kernel_name(),
            "kpep-database": await device_info.kpep_database(),
        }
        with contextlib.suppress(UnrecognizedSelectorError):
            info["system"] = await device_info.system_information()
        print_json(info)


@cli.command("netstat")
@async_command
async def netstat(service_provider: ServiceProviderDep) -> None:
    """Print information about current network activity."""
    async with DvtProvider(service_provider) as dvt, NetworkMonitor(dvt) as monitor:
        async for event in monitor:
            if isinstance(event, ConnectionDetectionEvent):
                logger.info(
                    f"Connection detected: {event.local_address.data.address}:{event.local_address.port} -> "
                    f"{event.remote_address.data.address}:{event.remote_address.port}"
                )


@cli.command("screenshot")
@async_command
async def dvt_screenshot(service_provider: ServiceProviderDep, out: Path) -> None:
    """Take device screenshot"""
    async with DvtProvider(service_provider) as dvt, Screenshot(dvt) as screenshot:
        out.write_bytes(await screenshot.get_screenshot())


@cli.command("xcuitest")
@async_command
async def xcuitest(
    service_provider: ServiceProviderDep,
    bundle_id: Annotated[
        str,
        typer.Argument(
            help="Bundle identifier of the XCTest runner (e.g. com.apple.test.WebDriverAgentRunner.xctrunner)"
        ),
    ],
    target_bundle_id: Annotated[
        Optional[str],
        typer.Option(
            help="Bundle identifier of the target app (if different from runner, e.g. for testing app extensions)"
        ),
    ] = None,
    output_log: Annotated[
        Optional[Path],
        typer.Option(help="log debug output file location ( default to python logger )"),
    ] = None,
    timeout: Annotated[
        Optional[float],
        typer.Option(help="maximum execution time, in seconds"),
    ] = None,
    env: Annotated[
        Optional[list[str]],
        typer.Option(
            help="Environment variable to pass to process given as key=value (can be specified multiple times)"
        ),
    ] = None,
) -> None:
    """
    Start XCUITest

    \b
    Usage example:
    \b    python3 -m pymobiledevice3 developer dvt xcuitest com.facebook.WebDriverAgentRunner.xctrunner
    """
    env_dict = dict(var.split("=", 1) for var in env or ())
    listener: Optional[XCUITestListener] = None

    if output_log is not None:
        output_stream = output_log.open("w")

        def _write(line: str) -> None:
            output_stream.write(line if line.endswith("\n") else line + "\n")
            output_stream.flush()

        class _LogFileListener(XCUITestListener):
            async def log_message(self, message: str) -> None:
                _write(message)

            async def log_debug_message(self, message: str) -> None:
                _write(message)

            async def did_begin_executing_test_plan(self) -> None:
                _write("[plan] begin")

            async def did_finish_executing_test_plan(self) -> None:
                _write("[plan] finish")

            async def test_suite_did_start(self, suite: str, started_at: str) -> None:
                _write(f"[suite] start  {suite}  at={started_at}")

            async def test_suite_did_finish(
                self,
                suite,
                finished_at,
                run_count,
                failures,
                unexpected,
                test_duration,
                total_duration,
                skipped,
                expected_failures,
                uncaught_exceptions,
            ) -> None:
                _write(
                    f"[suite] finish {suite}  at={finished_at}"
                    f"  run={run_count} fail={failures} une={unexpected} dur={test_duration:.3f}s total={total_duration:.3f}s skip={skipped} exp_fail={expected_failures} unc={uncaught_exceptions}"
                )

            async def test_case_did_start(self, test_class: str, method: str) -> None:
                _write(f"[case]  start  {test_class}/{method}")

            async def test_case_did_finish(self, result: XCTestCaseResult) -> None:
                _write(
                    f"[case]  finish {result.test_class}/{result.method}"
                    f"  status={result.status} dur={result.duration:.3f}s"
                )

            async def test_case_did_fail(
                self, test_class: str, method: str, message: str, file: str, line: int
            ) -> None:
                _write(f"[case]  FAIL   {test_class}/{method}  {message}  ({file}:{line})")

        listener = _LogFileListener()

    cfg = await TestConfig.create_for(service_provider, bundle_id, target_bundle_id)
    cfg.runner_app_env = env_dict or None
    svc = XCUITestService(service_provider)
    await svc.run(
        cfg,
        timeout=timeout,
        listener=listener,
    )


@cli.command("trace-codes")
@async_command
async def dvt_trace_codes(service_provider: ServiceProviderDep) -> None:
    """Print KDebug trace codes."""
    async with DvtProvider(service_provider) as dvt, DeviceInfo(dvt) as device_info:
        print_json({hex(k): v for k, v in (await device_info.trace_codes()).items()})


@cli.command("name-for-uid")
@async_command
async def dvt_name_for_uid(service_provider: ServiceProviderDep, uid: int) -> None:
    """Print the assiciated username for the given uid."""
    async with DvtProvider(service_provider) as dvt, DeviceInfo(dvt) as device_info:
        print(await device_info.name_for_uid(uid))


@cli.command("name-for-gid")
@async_command
async def dvt_name_for_gid(service_provider: ServiceProviderDep, gid: int) -> None:
    """Print the assiciated group name for the given gid."""
    async with DvtProvider(service_provider) as dvt, DeviceInfo(dvt) as device_info:
        print(await device_info.name_for_gid(gid))


@cli.command("oslog")
@async_command
async def dvt_oslog(service_provider: ServiceProviderDep, pid: int) -> None:
    """Sniff device oslog (not very stable, but includes more data and normal syslog)"""
    async with DvtProvider(service_provider) as dvt, ActivityTraceTap(dvt) as tap:
        async for message in tap:
            message_pid = message.process
            # without message_type maybe signpost have event_type
            message_type = (
                message.message_type
                if hasattr(message, "message_type")
                else message.event_type
                if hasattr(message, "event_type")
                else "unknown"
            )
            sender_image_path = message.sender_image_path
            image_name = os.path.basename(sender_image_path)
            subsystem = message.subsystem
            category = message.category
            timestamp = datetime.now()

            if pid is not None and message_pid != pid:
                continue

            formatted_message = decode_message_format(message.message) if message.message else message.name

            if user_requested_colored_output():
                timestamp = typer.style(str(timestamp), bold=True)
                message_pid = typer.style(str(message_pid), "magenta")
                subsystem = typer.style(subsystem, "green")
                category = typer.style(category, "green")
                image_name = typer.style(image_name, "yellow")
                message_type = typer.style(message_type, "cyan")

            print(
                f"[{timestamp}][{subsystem}][{category}][{message_pid}][{image_name}] "
                f"<{message_type}>: {formatted_message}"
            )


@cli.command("energy")
@async_command
async def dvt_energy(service_provider: ServiceProviderDep, pid_list: list[str]) -> None:
    """Monitor the energy consumption for given PIDs"""

    if len(pid_list) == 0:
        logger.error("pid_list must not be empty")
        return

    pid_int_list = [int(pid) for pid in pid_list]

    async with (
        DvtProvider(service_provider) as dvt,
        EnergyMonitor(dvt, pid_int_list) as energy_monitor,
    ):
        async for telemetry in energy_monitor:
            logger.info(telemetry)


@cli.command("notifications")
@async_command
async def dvt_notifications(service_provider: ServiceProviderDep) -> None:
    """Monitor memory and app notifications"""
    async with DvtProvider(service_provider) as dvt, Notifications(dvt) as notifications:
        async for notification in notifications:
            logger.info(notification)


@cli.command("graphics")
@async_command
async def dvt_graphics(service_provider: ServiceProviderDep) -> None:
    """Monitor graphics-related information"""
    async with DvtProvider(service_provider) as dvt, Graphics(dvt) as graphics:
        async for stats in graphics:
            logger.info(stats)


@cli.command("har")
@async_command
async def dvt_har(service_provider: ServiceProviderDep) -> None:
    """
    Enable har-logging

    \b
    For more information, please read:
    \b    https://github.com/doronz88/harlogger?tab=readme-ov-file#enable-http-instrumentation-method
    """
    async with DvtProvider(service_provider) as dvt:
        print("> Press Ctrl-C to abort")
        async with ActivityTraceTap(dvt, enable_http_archive_logging=True) as tap:
            while True:
                await tap.channel.receive_message()
