"""Command line interface."""

import asyncio
import functools
import logging
import socket
import ssl
import urllib.parse
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncIterator, List, Optional, TextIO, BinaryIO
from urllib.parse import urlparse

import orjson
import click
import xdg
from zeroconf import DNSQuestionType, InterfaceChoice, ServiceListener
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf

import pylutron_caseta.leap
from pylutron_caseta.pairing import async_pair


def _cli_main(main):
    """Wrap a method to run in asyncio."""

    def wrapper(*args, **kwargs):
        logging.basicConfig()
        logging.getLogger("pylutron_caseta").setLevel(logging.WARN)

        return asyncio.run(main(*args, **kwargs))

    return functools.update_wrapper(wrapper, main)


class _CertOption(click.Option):
    """An option that accesses a certificate file for the given address."""

    def __init__(self, *args, **kwargs):
        self.suffix = kwargs.pop("suffix")
        self.host = kwargs.pop("host")
        super().__init__(*args, **kwargs)

    def get_default(self, ctx: click.Context, call: bool = True) -> Optional[Path]:
        if not call:
            return None

        config_home = xdg.xdg_config_home()
        base = config_home / "pylutron_caseta"

        config_home.mkdir(exist_ok=True)
        base.mkdir(mode=0o700, exist_ok=True)

        return base / (self.host(ctx) + self.suffix)


class _AddressCertOption(_CertOption):
    """An option that access a certificate file for a plain hostname."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs, host=lambda ctx: ctx.params["address"])


class _ResourceCertOption(_CertOption):
    """An option that access a certificate file for a URL."""

    def __init__(self, *args, **kwargs):
        super().__init__(
            *args, **kwargs, host=lambda ctx: ctx.params["resource"].hostname
        )


class _UrlParamType(click.ParamType):
    """A parameter that is urlparsed."""

    name = "url"

    def convert(
        self, value: str, param: Optional[click.Parameter], ctx: Optional[click.Context]
    ) -> urllib.parse.ParseResult:
        if "://" not in value:
            value = f"leap://{value}"
        return urlparse(value, "leap", False)


URL = _UrlParamType()


@click.command()
@_cli_main
@click.argument("address")
@click.option(
    "--cacert",
    type=click.File("w", encoding="ascii", atomic=True),
    cls=_AddressCertOption,
    suffix="-bridge.crt",
    help="The path to the CA certificate.",
)
@click.option(
    "--cert",
    type=click.File("w", encoding="ascii", atomic=True),
    cls=_AddressCertOption,
    suffix=".crt",
    help="The path to the client certificate.",
)
@click.option(
    "--key",
    type=click.File("w", encoding="ascii", atomic=True),
    cls=_AddressCertOption,
    suffix=".key",
    help="The path to the client certificate key.",
)
async def lap_pair(address: str, cacert: TextIO, cert: TextIO, key: TextIO):
    """
    Perform LAP pairing.

    This program connects to a Lutron bridge device and initiates the LAP pairing
    process. The user will be prompted to press a physical button on the bridge, and
    certificate files will be generated on the local computer.

    By default, the certificate files will be placed in
    $XDG_CONFIG_HOME/pylutron_caseta, named after the address of the bridge device. The
    leap tool will look for certificates in the same location.
    """

    def _ready():
        click.echo(
            "Press the small black button on the back of the bridge to complete "
            "pairing."
        )

    data = await async_pair(address, _ready)
    cacert.write(data["ca"])
    cert.write(data["cert"])
    key.write(data["key"])
    click.echo(f"Successfully paired with {data['version']}")


@click.command()
@_cli_main
@click.option(
    "-i",
    "--interface",
    multiple=True,
    help=(
        "Limit scanned network interfaces. "
        "This option may be specified multiple times."
    ),
)
@click.option(
    "-t",
    "--timeout",
    type=float,
    default=5.0,
    show_default=True,
    help="The amount of time (in seconds) to wait for replies.",
)
async def leap_scan(interface: List[str], timeout: float):
    """
    Scan for LEAP devices on the local network.

    This program uses MDNS to locate LEAP devices on networks connected to the local
    computer.
    """

    async def _async_add_service(zeroconf, type_, name):
        info = AsyncServiceInfo(type_, name)
        await info.async_request(zeroconf, 3000, question_type=DNSQuestionType.QU)

        addresses = [info.server]
        addresses.extend(info.parsed_scoped_addresses())

        click.echo(" ".join(addresses))

    class _Listener(ServiceListener):
        def remove_service(self, *args):
            pass

        def add_service(self, zc, type_, name):
            asyncio.ensure_future(_async_add_service(zc, type_, name))

        def update_service(self, *args):
            pass

    interfaces: Any = InterfaceChoice.All
    if len(interface) > 0:
        interfaces = interface

    async with AsyncZeroconf(interfaces) as azc:
        await azc.zeroconf.async_wait_for_start()
        browser = AsyncServiceBrowser(azc.zeroconf, "_lutron._tcp.local.", _Listener())
        await asyncio.sleep(timeout)
        await browser.async_cancel()


@asynccontextmanager
async def _connect(
    resource: urllib.parse.ParseResult, cacert: str, cert: str, key: str
) -> AsyncIterator[pylutron_caseta.leap.LeapProtocol]:
    ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
    ssl_context.load_verify_locations(cacert)
    ssl_context.load_cert_chain(cert, key)

    if resource.hostname is None:
        raise ValueError("Hostname must be specified")

    protocol = await pylutron_caseta.leap.open_connection(
        resource.hostname,
        resource.port or 8081,
        server_hostname="",
        ssl=ssl_context,
        family=socket.AF_INET,
    )
    run = asyncio.ensure_future(protocol.run())

    try:
        yield protocol
    finally:
        run.cancel()
        try:
            await run
        except asyncio.CancelledError:
            pass
        protocol.close()
        try:
            await protocol.wait_closed()
        except ssl.SSLError:
            pass


@click.command()
@_cli_main
@click.pass_context
@click.option(
    "-X",
    "--request",
    default="ReadRequest",
    help="The CommuniqueType to send.",
    show_default=True,
)
@click.option(
    "--cacert",
    type=click.Path(True, dir_okay=False),
    cls=_ResourceCertOption,
    suffix="-bridge.crt",
    help="The path to the CA certificate.",
)
@click.option(
    "-E",
    "--cert",
    type=click.Path(True, dir_okay=False),
    cls=_ResourceCertOption,
    suffix=".crt",
    help="The path to the client certificate.",
)
@click.option(
    "--key",
    type=click.Path(True, dir_okay=False),
    cls=_ResourceCertOption,
    suffix=".key",
    help="The path to the client certificate key.",
)
@click.option("-d", "--data", help="The JSON data to send with the request.")
@click.option("-p", "--paging", help="The JSON Paging dict to send with the request.")
@click.option(
    "-f",
    "--fail",
    is_flag=True,
    help="Exit when the status code does not indicate success.",
)
@click.option(
    "-o",
    "--output",
    type=click.File("wb"),
    default="-",
    help="Save the response into a file.",
)
@click.option(
    "-v", "--verbose", is_flag=True, help="Output the response headers as well."
)
@click.argument("resource", type=URL)
async def leap(
    ctx: click.Context,
    request: str,
    resource: urllib.parse.ParseResult,
    cacert: str,
    cert: str,
    key: str,
    data: Optional[str],
    paging: Optional[str],
    fail: bool,
    output: BinaryIO,
    verbose: bool,
):
    """
    Make a single LEAP request.

    LEAP is similar to JSON over HTTP, and this tool is similar to Curl.
    """
    async with _connect(resource, cacert, cert, key) as connection:
        body = orjson.loads(data) if data is not None else None
        paging_json = orjson.loads(paging) if paging is not None else None

        res = resource.path
        if resource.query is not None and len(resource.query) > 0:
            res += f"?{resource.query}"

        response = await connection.request(request, res, body, paging=paging_json)

    if (
        fail
        and response.Header.StatusCode is not None
        and not response.Header.StatusCode.is_successful()
    ):
        ctx.exit(1)

    if verbose:
        # LeapProtocol discards the original JSON so reconstruct it here.
        message: dict = {
            "Header": {
                "StatusCode": str(response.Header.StatusCode),
                "Url": response.Header.Url,
                "MessageBodyType": response.Header.MessageBodyType,
            },
            "CommuniqueType": response.CommuniqueType,
            "Body": response.Body,
        }
        if response.Header.Paging:
            message["Header"]["Paging"] = response.Header.Paging

        output.write(orjson.dumps(message))
    else:
        output.write(orjson.dumps(response.Body))

    output.write(b"\n")
