from __future__ import annotations

from ast import literal_eval
import asyncio
from contextlib import AbstractAsyncContextManager
import io
import logging
import re
from typing import IO

import asyncclick as click
from construct import (
    Const,
    ConstructError,
    GreedyRange,
    Int8ul,
    RawCopy,
    Select,
    Struct,
    Terminated,
)

from ..coil import CoilData
from ..connection import Connection
from ..connection.modbus import Modbus
from ..connection.nibegw import NibeGW, Request, Response
from ..exceptions import NibeException
from ..heatpump import HeatPump, Model

Ack = Struct("fields" / RawCopy(Struct("Ack" / Const(0x06, Int8ul))))

Nak = Struct("fields" / RawCopy(Struct("Nak" / Const(0x15, Int8ul))))

Block = Select(
    Terminated,
    Response,
    Request,
    Ack,
    Nak,
)

Stream = GreedyRange(Block)


@click.group()
@click.option("-v", "--verbose", count=True)
async def cli(verbose: int):
    if verbose == 0:
        log_level = logging.WARNING
    elif verbose == 1:
        log_level = logging.INFO
    else:
        log_level = logging.DEBUG

    logging.basicConfig(
        format="[%(levelname)-8s] %(message)s",
        level=log_level,
    )
    logging.log(logging.INFO, "Log level set to %r", log_level)


class ConnectionContext(AbstractAsyncContextManager):
    def __init__(self, heatpump: HeatPump, connection: Connection) -> None:
        self.heatpump = heatpump
        self.connection = connection

    async def __aenter__(self) -> ConnectionContext:
        await self.connection.start()
        return self

    async def __aexit__(self, __exc_type, __exc_value, __traceback):
        await self.connection.stop()


@cli.group(chain=True, help="Connect using nibegw protocol")
@click.argument("remote_ip", type=str)
@click.option("--listening_ip", type=str)
@click.option("--listening_port", type=int, default=10090)
@click.option("--remote_read_port", type=int, default=10091)
@click.option("--remote_write_port", type=int, default=10092)
@click.option(
    "--model",
    type=click.Choice([model.name for model in Model.__members__.values()]),
    default=None,
)
@click.pass_context
async def nibegw(
    ctx: click.Context,
    remote_ip: str,
    listening_port: int,
    listening_ip: str | None,
    remote_read_port: int,
    remote_write_port: int,
    model: str | None,
):
    heatpump = HeatPump()
    if model:
        heatpump.model = Model[model]
        await heatpump.initialize()

    connection = NibeGW(
        heatpump=heatpump,
        remote_ip=remote_ip,
        listening_port=listening_port,
        listening_ip=listening_ip,
        remote_read_port=remote_read_port,
        remote_write_port=remote_write_port,
    )

    ctx.obj = await ctx.with_async_resource(ConnectionContext(heatpump, connection))


@cli.group(chain=True, help="Connect using modbus protocol")
@click.argument("remote_ip", type=str)
@click.option("--remote_port", type=int, default=502)
@click.option("--slave_id", type=int, default=1)
@click.option(
    "--model",
    type=click.Choice([model.name for model in Model]),
    default=Model.F1155.name,
)
@click.pass_context
async def modbus(
    ctx: click.Context, remote_ip: str, remote_port: str, model: str, slave_id: int
):
    heatpump = HeatPump(Model[model])
    await heatpump.initialize()
    connection = Modbus(
        heatpump=heatpump,
        url=f"tcp://{remote_ip}:{remote_port}",
        slave_id=slave_id,
    )

    ctx.obj = await ctx.with_async_resource(ConnectionContext(heatpump, connection))


def add_connect_command(command: click.Command):
    nibegw.add_command(command)
    modbus.add_command(command)


@click.command(help="Monitor data sent by pump out of band")
@click.pass_obj
async def monitor(obj: ConnectionContext):
    def on_coil_update(coil_data: CoilData):
        click.echo(coil_data)

    obj.heatpump.subscribe(HeatPump.COIL_UPDATE_EVENT, on_coil_update)

    while True:
        await asyncio.sleep(1)


add_connect_command(monitor)


@click.command()
@click.pass_obj
async def product(obj: ConnectionContext):
    product_info = await obj.connection.read_product_info()
    click.echo(product_info)


add_connect_command(product)


@click.command()
@click.pass_obj
@click.argument("parameter", type=int)
async def read(obj: ConnectionContext, parameter: int, **kwargs):
    coil = obj.heatpump.get_coil_by_address(parameter)
    click.echo(await obj.connection.read_coil(coil))


add_connect_command(read)


@click.command()
@click.pass_obj
@click.argument("parameter", type=int)
@click.argument("value", type=str)
async def write(obj: ConnectionContext, parameter: int, value: str, **kwargs):
    coil = obj.heatpump.get_coil_by_address(parameter)
    if not coil.mappings:
        value = float(value)

    coil_data = CoilData(coil, value)
    await obj.connection.write_coil(coil_data)
    click.echo(coil_data)


add_connect_command(write)


@cli.command()
@click.argument("data", type=str)
@click.option("--type", type=click.Choice(["hex", "bytes"]), default="hex")
async def parse_data(data: str, type: str):
    if type == "hex":
        raw = bytes.fromhex(data)
    elif type == "bytes":
        raw = bytes(literal_eval(data))

    try:
        request = Block.parse(raw)
        click.echo(request)
    except ConstructError as exception:
        click.echo(f"Failed to parse: {exception}", err=True)


def read_bytes_socat(file: IO):
    lines: list[str] = file.readlines()

    for line in lines:
        if line.startswith("> "):
            continue
        yield from bytes.fromhex(line)


RE_NIBEPI_LOG = re.compile(r"Serial: \[((?:[0-9]+,?)+)\]")


def read_bytes_nibepi(file: IO):
    lines: list[str] = file.readlines()
    for line in lines:
        data = RE_NIBEPI_LOG.match(line)
        if data:
            yield from map(int, data.group(1).split(","))


def parse_stream(stream: io.RawIOBase):
    while block := Block.parse_stream(stream):
        yield block


@cli.command()
@click.argument("file", type=click.File())
@click.option("--type", type=click.Choice(["hex", "nibepi"]), default="hex")
def parse_file(file: IO, type: str):
    if type == "hex":
        data = read_bytes_socat(file)
    elif type == "nibepi":
        data = read_bytes_nibepi(file)
    else:
        raise ValueError("invalid argument")

    with io.BytesIO(bytes(data)) as stream:
        for packet in parse_stream(stream):
            click.echo(packet.fields.value)

        remaining = stream.read()
        if remaining:
            click.echo(f"Remaining: {stream.read()}")


def main():
    try:
        cli()
    except NibeException as exception:
        click.echo(repr(exception))
    except (KeyboardInterrupt, SystemExit):
        pass


if __name__ == "__main__":
    main()
