"""
Collection of GNSS related helper methods.

Created on 26 May 2022

:author: semuadmin (Steve Smith)
:copyright: semuadmin © 2020
:license: BSD 3-Clause
"""

# pylint: disable=invalid-name

import logging
import logging.handlers
from argparse import ArgumentParser
from datetime import datetime, timezone
from math import cos, radians, sin
from os import getenv, path
from pathlib import Path
from socket import AF_INET, AF_INET6, gaierror, getaddrinfo
from threading import Event
from time import sleep

from pynmeagps import haversine
from pyubx2 import itow2utc

from pygnssutils.exceptions import ParameterError
from pygnssutils.globals import (
    LOGFORMAT,
    LOGGING_LEVELS,
    LOGLIMIT,
    PYGNSSUTILS_CRT,
    PYGNSSUTILS_CRTPATH,
    PYGNSSUTILS_PEM,
    PYGNSSUTILS_PEMPATH,
    VERBOSITY_CRITICAL,
    VERBOSITY_DEBUG,
    VERBOSITY_HIGH,
    VERBOSITY_LOW,
    VERBOSITY_MEDIUM,
)

CLOCK = ("|", "/", "\u2015", "\\")


def cliclock(stopevent: Event, msg: str, delay: float = 0.25):
    """
    THREADED Display clock timer on console.

    :param Event stopevent: stop event
    :param str msg: msg prompt
    :param float delay: delay in seconds
    """

    while not stopevent.is_set():
        for hand in CLOCK:
            print(f"{msg} {hand} ", end="\r")
            sleep(delay)
    print(f"{'':<{len(msg)+3}}", end="\r")


def prog_callback(progress: int, inc: int = 50):
    """
    Callback function to display progress bar at console.

    :param int progress: % complete
    :param int inc: number of increments (50)
    """

    pct = int(progress * inc / 100)
    print(f"{progress:02d}% " + "\u2593" * pct + "\u2591" * (inc - pct), end="\r")


def progbar(i: int, lim: int, inc: int = 50):
    """
    Display progress bar on console.
    """

    i = min(i, lim)
    pct = int(i * inc / lim)
    if not i % int(lim / inc):
        print(
            f"{int(pct*100/inc):02}% " + "\u2593" * pct + "\u2591" * (inc - pct),
            end="\r",
        )


def parse_config(configfile: str) -> dict:
    """
    Parse config file.

    :param str configfile: fully qualified path to config file
    :returns: config as kwargs, or None if file not found
    :rtype: dict
    :raises: FileNotFoundError
    :raises: ValueError
    """

    config = {}
    try:
        with open(configfile, "r", encoding="utf-8") as infile:
            for cf in infile:
                if cf in ("", "\n", "\r\n"):
                    continue
                if cf[0] != "#":  # comment
                    key, val = cf.split("=", 1)
                    config[key.strip()] = val.strip()
        return config
    except FileNotFoundError as err:
        raise FileNotFoundError(f"Configuration file not found: {configfile}") from err
    except ValueError as err:
        raise ValueError(f"Configuration file invalid: {configfile}, {err}") from err


def set_common_args(
    name: str,
    ap: ArgumentParser,
    logname: str = "pygnssutils",
    logdefault: int = VERBOSITY_MEDIUM,
) -> dict:
    """
    Set common argument parser and logging args.

    :param str name: name of CLI utility e.g. "gnssstreamer"
    :param ArgumentParserap: argument parser instance
    :param str logname: logger name
    :param int logdefault: default logger verbosity level
    :returns: parsed arguments as kwargs
    :rtype: dict
    """

    ap.add_argument(
        "-C",
        "--config",
        required=False,
        help=(
            "Fully qualified path to CLI configuration file "
            f"(will use environment variable {name.upper()}_CONF where set)"
        ),
        default=getenv(f"{name.upper()}_CONF", None),
    )
    ap.add_argument(
        "--verbosity",
        required=False,
        help=(
            f"Log message verbosity "
            f"{VERBOSITY_CRITICAL} = critical, "
            f"{VERBOSITY_LOW} = low (error), "
            f"{VERBOSITY_MEDIUM} = medium (warning), "
            f"{VERBOSITY_HIGH} = high (info), {VERBOSITY_DEBUG} = debug"
        ),
        type=int,
        choices=[
            VERBOSITY_CRITICAL,
            VERBOSITY_LOW,
            VERBOSITY_MEDIUM,
            VERBOSITY_HIGH,
            VERBOSITY_DEBUG,
        ],
        default=logdefault,
    )
    ap.add_argument(
        "--logtofile",
        required=False,
        help="fully qualified log file name, or '' for no log file",
        type=str,
        default="",
    )

    kwargs = vars(ap.parse_args())
    # config file settings will supplement CLI and default args
    cfg = kwargs.pop("config", None)
    if cfg is not None:
        kwargs = {**kwargs, **parse_config(cfg)}

    logger = logging.getLogger(logname)
    set_logging(
        logger, kwargs.get("verbosity", logdefault), kwargs.get("logtofile", "")
    )

    return kwargs


def set_logging(
    logger: logging.Logger,
    verbosity: int = VERBOSITY_MEDIUM,
    logtofile: str = "",
    logform: str = LOGFORMAT,
    limit: int = LOGLIMIT,
):
    """
    Set logging format and level.

    :param logging.Logger logger: module log handler
    :param int verbosity: verbosity level -1,0,1,2,3 (2 - MEDIUM)
    :param str logtofile: fully qualified log file name ("")
    :param str logform: logging format (datetime - level - name)
    :param int limit: maximum logfile size in bytes (10MB)
    """

    try:
        level = LOGGING_LEVELS[int(verbosity)]
    except (KeyError, ValueError):
        level = logging.WARNING

    logger.setLevel(logging.DEBUG)
    logformat = logging.Formatter(
        logform,
        datefmt="%Y-%m-%d %H:%M:%S",
        style="{",
    )
    if logtofile == "":
        loghandler = logging.StreamHandler()
    else:
        loghandler = logging.handlers.RotatingFileHandler(
            logtofile, mode="a", maxBytes=limit, backupCount=10, encoding="utf-8"
        )
    loghandler.setFormatter(logformat)
    loghandler.setLevel(level)
    logger.addHandler(loghandler)


def get_mp_distance(lat: float, lon: float, mp: list) -> float:
    """
    Get distance to mountpoint from current location (if known).

    Predicated on the sourcetable being formatted as a
    list of sourcetable entries, where for each entry:
    entry[0] = mountpoint name
    entry[8] = mountpoint latitude
    entry[9] = mountpoint longitude

    :param float lat: current latitude
    :param float lon: current longitude
    :param list mp: sourcetable mountpoint entry
    :returns: distance to mountpoint in km, or None if n/a
    :rtype: float or None
    """

    dist = None
    try:
        if len(mp) > 9:  # if location provided for this mountpoint
            lat = float(lat)
            lon = float(lon)
            lat2 = float(mp[8])
            lon2 = float(mp[9])
            dist = haversine(lat, lon, lat2, lon2)
    except (TypeError, ValueError):
        pass

    return dist


def find_mp_distance(
    lat: float, lon: float, sourcetable: list, name: str = ""
) -> tuple:
    """
    Find distance to named mountpoint. If mountpoint name
    is not provided, find closest mountpoint in sourcetable.

    Predicated on the sourcetable being formatted as a
    list of sourcetable entries, where for each entry:
    entry[0] = mountpoint name
    entry[8] = mountpoint latitude
    entry[9] = mountpoint longitude

    :param float lat: reference latitude
    :param float lon: reference longitude
    :param list sourcetable: sourcetable as list
    :param str name: (optional) mountpoint name (None)
    :returns: tuple of (name of closest mountpoint, distance in km)
    :rtype: tuple
    """

    mindist = 9999999
    mpname = None
    for mp in sourcetable:
        dist = get_mp_distance(lat, lon, mp)
        if dist is not None:
            if name == "":  # find closest
                if dist < mindist:
                    mpname = mp[0]
                    mindist = dist
            else:
                if mp[0] == name:
                    mpname = mp[0]
                    mindist = dist
                    break

    return mpname, round(mindist, 2)


def cel2cart(elevation: float, azimuth: float) -> tuple:
    """
    Convert celestial coordinates (degrees) to Cartesian coordinates.

    :param float elevation: elevation
    :param float azimuth: azimuth
    :returns: cartesian x,y coordinates
    :rtype: tuple
    """

    if not (isinstance(elevation, (float, int)) and isinstance(azimuth, (float, int))):
        return (0, 0)
    elevation = radians(elevation)
    azimuth = radians(azimuth)
    x = cos(azimuth) * cos(elevation)
    y = sin(azimuth) * cos(elevation)
    return (x, y)


def format_dates() -> tuple:
    """
    Format response header dates.

    :returns: tuple of (http_date, server_date)
    :rtype: tuple
    """

    dat = datetime.now(timezone.utc)
    http_date = dat.strftime("%a, %d %b %Y %H:%M:%S %Z")
    server_date = dat.strftime("%d %b %Y")
    return (http_date, server_date)


def format_json(message: object) -> str:
    """
    Format object as JSON document.

    :returns: JSON document as string
    :rtype: str
    """

    ident = ""
    if hasattr(message, "identity"):
        ident = message.identity

    sta = "{"
    end = "}"
    stg = f'{sta}"type": "{type(message)}", "identity": "{ident}", "payload": {sta}'
    for i, att in enumerate(message.__dict__):
        if att[0] != "_":  # only format public attributes
            val = message.__dict__[att]
            if att == "iTOW":  # convert UBX iTOW to UTC
                val = itow2utc(val)
            if isinstance(val, bool):
                stg += f'"{att}": {"true" if val else "false"}'
            elif isinstance(val, (int, float)):
                stg += f'"{att}": {val}'
            else:
                stg += f'"{att}": "{str(val)}"'
            if i < len(message.__dict__) - 1:
                stg += ", "
    stg += f"{end}{end}"

    return stg


def format_conn(
    family: int, server: str, port: int, flowinfo: int = 0, scopeid: int = 0
) -> tuple:
    """
    Return formatted socket connection string.

    :param int family: IP family (AF_INET, AF_INET6)
    :param str server: server
    :param int port: port
    :param int flowinfo: flow info (0)
    :param int scopeid: scope ID (0)
    :returns: connection tuple
    :rtype: tuple
    """

    if family == AF_INET6:
        if flowinfo != 0 or scopeid != 0:
            return (server, port, flowinfo, scopeid)
        try:
            gai = getaddrinfo(server, port)
            if len(gai) == 1:  # No IP6 support (Windows)
                return gai[0][4]
            return gai[1][4]  # IP6 support (Posix)
        except gaierror as err:
            raise ValueError(f"Invalid server or port {server} {port}") from err
    if family == AF_INET:
        return (server, port)
    raise ValueError(f"Invalid family value {family}")


def ipprot2int(family: str) -> int:
    """
    Convert IP family string to integer.

    :param str family: family string ("IPv4", "IPv6")
    :returns: value as int AF_INET, AF_INET6
    :rtype: int
    """

    if family == "IPv6":
        return AF_INET6
    return AF_INET


def ipprot2str(family: int) -> str:
    """
    Convert IP family integer to string.

    :param str family: family int (AF_INET, AF_INET6)
    :returns: value as str ("IPv4", "IPv6")
    :rtype: int
    """

    if family == AF_INET6:
        return "IPv6"
    return "IPv4"


def gtype(data: object) -> str:
    """
    Get type of GNSS data as user-friendly string.

    :param object data: data
    :returns: type e.g. "UBX"
    :rtype: str
    """

    for typ in ("NMEA", "UBX", "RTCM", "SPARTN"):
        if typ in str(type(data)):
            return typ
    return ""


def parse_url(url: str) -> tuple:
    """
    Parse URL. If protocol, port or path not specified, they
    default to 'http', 80 and '/'.

    :param str url: full URL e.g. 'https://example.com:443/path'
    :returns: tuple of (protocol, hostname, port, path)
    :rtype: tuple
    """

    try:

        prothost = url.split("://", 1)
        if len(prothost) == 1:
            prot = "http"
            host = prothost[0]
        else:
            prot, host = prothost

        hostpath = host.split("/", 1)
        if len(hostpath) == 1:
            hostname = hostpath[0]
            pth = "/"
        else:
            hostname, pth = hostpath

        hostport = hostname.split(":", 1)
        if len(hostport) == 1:
            hostname = hostport[0]
            port = 80
        else:
            hostname = hostport[0]
            port = int(hostport[1])

    except (ValueError, IndexError) as err:
        raise ParameterError(f"Invalid URL {url} {err}") from err

    return prot, hostname, port, pth


def check_pemfile() -> tuple:
    """
    Check TLS PEM (Certificate/Key) file for HTTPS server connection.

    :return: tuple of (path to PEM file, exists flag)
    :rtype: tuple
    """

    pem = getenv(PYGNSSUTILS_PEMPATH, default=path.join(Path.home(), PYGNSSUTILS_PEM))
    return pem, Path(pem).exists()


def check_crtfile() -> tuple:
    """
    Check TLS CRT (Certificate) file for HTTPS client connection.

    If CRT path does not exist, try PEM path.

    :return: tuple of (path to CRT file, exists flag)
    :rtype: tuple
    """

    crt = getenv(PYGNSSUTILS_CRTPATH, default=path.join(Path.home(), PYGNSSUTILS_CRT))
    if not Path(crt).exists():
        crt = getenv(
            PYGNSSUTILS_PEMPATH, default=path.join(Path.home(), PYGNSSUTILS_PEM)
        )
    return crt, Path(crt).exists()
