# util.py/Open GoPro, Version 2.0 (C) Copyright 2021 GoPro, Inc. (http://gopro.com/OpenGoPro).
# This copyright was auto-generated on Wed, Sep  1, 2021  5:05:50 PM

"""Miscellaneous utilities for the GoPro package."""

from __future__ import annotations

import argparse
import asyncio
import enum
import logging
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Generic, TypeVar

import pytz
from pydantic import BaseModel
from tzlocal import get_localzone

util_logger = logging.getLogger(__name__)


class Singleton:
    """To be subclassed to create a singleton class."""

    _instances: dict[type[Singleton], Singleton] = {}

    def __new__(cls, *_: Any) -> Any:  # noqa https://github.com/PyCQA/pydocstyle/issues/515
        if cls not in cls._instances:
            cls._instances[cls] = object.__new__(cls)
        return cls._instances[cls]


def map_keys(obj: Any, key: str, func: Callable[[Any], Any]) -> None:
    """Map all matching keys (deeply searched) using the input function

    Args:
        obj (Any): object to modify in place
        key (str): key to search for to modify
        func (Callable[[Any], Any]): mapping function
    """
    if isinstance(obj, dict):
        for k in obj.keys():
            if k == key:
                obj[k] = func(obj[k])
            else:
                map_keys(obj[k], key, func)
    elif isinstance(obj, list):
        for i in obj:
            map_keys(i, key, func)
    else:
        # neither a dict nor a list, do nothing
        pass


def scrub(obj: Any, bad_keys: list | None = None, bad_values: list | None = None) -> None:
    """Recursively scrub a collection (dict / list) of bad keys and / or bad values

    Args:
        obj (Any): collection to scrub
        bad_keys (list | None, optional): Keys to remove. Defaults to None.
        bad_values (list | None, optional): Values to remove. Defaults to None.

    Raises:
        ValueError: Missing bad keys / values
    """
    bad_keys = bad_keys or []
    bad_values = bad_values or []
    if not (bad_values or bad_keys):
        raise ValueError("Must pass either / or bad_keys or bad_values")

    def recurse(obj: Any) -> None:
        if isinstance(obj, dict):
            for key, value in {**obj}.items():
                if key in bad_keys or value in bad_values:
                    del obj[key]
                else:
                    recurse(obj[key])
        elif isinstance(obj, list):
            for i, value in enumerate(list(obj)):
                if value in bad_values:
                    del obj[i]
                else:
                    recurse(obj[i])
        else:
            # neither a dict nor a list, do nothing
            pass

    recurse(obj)


def pretty_print(obj: Any, stringify_all: bool = True, should_quote: bool = True) -> str:
    """Recursively iterate through object and turn elements into strings

    Args:
        obj (Any): object to recurse through
        stringify_all (bool): At the end of each recursion, should the element be turned into a string?
            For example, should an int be turned into a str? Defaults to True.
        should_quote (bool): _description_. Defaults to True.

    Returns:
        str: pretty-printed string
    """
    output = ""
    nest_level = 0

    def sanitize(e: Any) -> str:
        """Get the value part and replace any underscored with spaces

        Args:
            e (Any): argument to sanitize

        Returns:
            str: sanitized string
        """
        value_part = str(e).lower().split(".")[1]
        value_part = value_part.replace("_", " ").title()
        return value_part

    def stringify(elem: Any) -> Any:
        """Get the string value of an element if it is not a number (int, float, etc.)

        Args:
            elem (Any): element to potentially stringify

        Returns:
            Any: string representation or original object
        """

        def quote(elem: Any) -> Any:
            return f'"{elem}"' if should_quote else elem

        ret: str
        if isinstance(elem, (bytes, bytearray)):
            ret = quote(elem.hex(":"))
        if isinstance(elem, enum.Enum) and isinstance(elem, int):
            ret = quote(str(elem) if not stringify_all else sanitize(elem))
        if isinstance(elem, (bool, int, float)):
            ret = quote(elem) if stringify_all else elem
        ret = str(elem)
        return quote(ret)

    def recurse(elem: Any) -> None:
        """Recursion function

        Args:
            elem (Any): current element to work on
        """
        nonlocal output
        nonlocal nest_level
        indent_size = 4
        # Convert to dict if possible
        if isinstance(elem, BaseModel):
            elem = dict(elem)
            scrub(elem, bad_values=[None])
        if isinstance(elem, dict):
            # nested dictionary
            nest_level += 1
            output += "{"
            for k, v in elem.items():
                output += f"\n{' ' * (indent_size * nest_level)}"
                # Add key
                recurse(k)
                output += " : "
                # Add value
                if isinstance(v, (dict, list, BaseModel)):
                    recurse(v)
                else:
                    output += stringify(v)
                output += ","

            nest_level -= 1
            output += f"\n{' '* (indent_size * nest_level)}}}"

        elif isinstance(elem, list):
            # nested list
            nest_level += 1
            output += f"[\n{' '* (indent_size * nest_level)}"
            if len(elem):
                for item in elem[:-1]:
                    recurse(item)
                    output += ", "
                recurse(elem[-1])
            nest_level -= 1
            output += f"\n{' '* (indent_size * nest_level)}]"

        else:
            output += stringify(elem)

    recurse(obj)
    return output


def cmd(command: str) -> str:
    """Send a command to the shell and return the result.

    Args:
        command (str): command to send

    Returns:
        str: response returned from shell
    """
    # We don't want password showing in the log
    if "sudo" in command:
        logged_command = command[: command.find('"') + 1] + "********" + command[command.find(" | sudo") - 1 :]
    else:
        logged_command = command
    util_logger.debug(f"Send cmd --> {logged_command}")
    response = (
        subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)  # type: ignore
        .stdout.read()
        .decode(errors="ignore")
    )
    util_logger.debug(f"Receive response --> {response}")

    return response


T = TypeVar("T")


class SnapshotQueue(asyncio.Queue, Generic[T]):
    """A subclass of the default queue module to safely take a snapshot of the queue

    This is so we can access the elements (in a thread safe manner) without dequeuing them.
    """

    def __init__(self, maxsize: int = 0) -> None:
        self._lock = asyncio.Lock()
        super().__init__(maxsize)

    async def peek_front(self) -> T | None:
        """Get the first element without dequeueing it

        Returns:
            T | None: First element of None if the queue is empty
        """
        async with self._lock:
            return None if self.empty() else self._queue[0]  # type: ignore


def add_cli_args_and_parse(
    parser: argparse.ArgumentParser,
    bluetooth: bool = True,
    wifi: bool = True,
) -> argparse.Namespace:
    """Append common argparse arguments to an argument parser

    WARNING!! This will also parse the arguments (i.e. call parser.parse_args) so ensure to add any additional
    arguments to the parser before passing it to this function.

    Args:
        parser (argparse.ArgumentParser): input parser to modify
        bluetooth (bool): Add bluetooth args?. Defaults to True.
        wifi (bool): Add WiFi args?. Defaults to True.

    Returns:
        argparse.ArgumentParser: modified argument parser
    """
    # Common args
    parser.add_argument("--log", type=Path, help="Location to store detailed log", default="gopro_demo.log")

    if bluetooth:
        parser.add_argument(
            "--identifier",
            type=str,
            help="Last 4 digits of GoPro serial number, which is the last 4 digits of the default camera SSID. \
                If not used, first discovered GoPro will be connected to",
            default=None,
        )

    if wifi:
        parser.add_argument(
            "--wifi_interface",
            type=str,
            help="System Wifi Interface. If not set, first discovered interface will be used.",
            default=None,
        )
        parser.add_argument(
            "--password",
            action="store_true",
            help="Set to read sudo password from stdin. If not set, you will be prompted for password if needed",
        )

    args = parser.parse_args()
    if wifi:
        args.password = sys.stdin.readline() if args.password else None

    return args


async def ainput(string: str, printer: Callable = sys.stdout.write) -> str:
    """Async version of input

    Args:
        string (str): prompt string
        printer (Callable): Callable to display prompt. Defaults to sys.stdout.write.

    Returns:
        str: Input read from console
    """
    await asyncio.get_event_loop().run_in_executor(None, lambda s=string: printer(s + " "))
    return await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)


def get_current_dst_aware_time() -> tuple[datetime, int, bool]:
    """Get the current time, utc offset in minutes, and daylight savings time

    Returns:
        tuple[datetime, int, bool]: [time, utc_offset in minutes, is_dst?]
    """
    tz = pytz.timezone(get_localzone().key)  # type: ignore
    now = tz.localize(datetime.now(), is_dst=None)
    try:
        is_dst = now.tzinfo._dst.seconds != 0  # type: ignore
        offset = (now.utcoffset().total_seconds() - now.tzinfo._dst.seconds) / 60  # type: ignore
    except AttributeError:
        is_dst = False
        offset = now.utcoffset().total_seconds() / 60  # type: ignore
    return (now, int(offset), is_dst)
