#  Copyright 2022 Upstream Data Inc
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

import asyncio
import ipaddress
import json
import logging
from collections.abc import AsyncIterable
from typing import List, Tuple, Union

import asyncssh
import httpx

from pyasic.errors import APIError
from pyasic.miners._backends.bmminer import BMMiner  # noqa - Ignore _module import
from pyasic.miners._backends.bosminer import BOSMiner  # noqa - Ignore _module import
from pyasic.miners._backends.bosminer_old import (  # noqa - Ignore _module import
    BOSMinerOld,
)
from pyasic.miners._backends.btminer import BTMiner  # noqa - Ignore _module import
from pyasic.miners._backends.cgminer import CGMiner  # noqa - Ignore _module import
from pyasic.miners.antminer import *
from pyasic.miners.avalonminer import *
from pyasic.miners.base import AnyMiner
from pyasic.miners.innosilicon import *
from pyasic.miners.unknown import UnknownMiner
from pyasic.miners.whatsminer import *
from pyasic.misc import Singleton
from pyasic.settings import PyasicSettings

MINER_CLASSES = {
    "ANTMINER S9": {
        "Default": BOSMinerS9,
        "BOSMiner": BOSMinerOld,
        "BOSMiner+": BOSMinerS9,
        "BMMiner": BMMinerS9,
        "CGMiner": CGMinerS9,
    },
    "ANTMINER S9I": {
        "Default": BMMinerS9i,
        "BMMiner": BMMinerS9i,
    },
    "ANTMINER T9": {
        "Default": BMMinerT9,
        "BMMiner": BMMinerT9,
        "Hiveon": HiveonT9,
        "CGMiner": CGMinerT9,
    },
    "ANTMINER S17": {
        "Default": BMMinerS17,
        "BOSMiner+": BOSMinerS17,
        "BMMiner": BMMinerS17,
        "CGMiner": CGMinerS17,
    },
    "ANTMINER S17+": {
        "Default": BMMinerS17Plus,
        "BOSMiner+": BOSMinerS17Plus,
        "BMMiner": BMMinerS17Plus,
        "CGMiner": CGMinerS17Plus,
    },
    "ANTMINER S17 PRO": {
        "Default": BMMinerS17Pro,
        "BOSMiner+": BOSMinerS17Pro,
        "BMMiner": BMMinerS17Pro,
        "CGMiner": CGMinerS17Pro,
    },
    "ANTMINER S17E": {
        "Default": BMMinerS17e,
        "BOSMiner+": BOSMinerS17e,
        "BMMiner": BMMinerS17e,
        "CGMiner": CGMinerS17e,
    },
    "ANTMINER T17": {
        "Default": BMMinerT17,
        "BOSMiner+": BOSMinerT17,
        "BMMiner": BMMinerT17,
        "CGMiner": CGMinerT17,
    },
    "ANTMINER T17+": {
        "Default": BMMinerT17Plus,
        "BOSMiner+": BOSMinerT17Plus,
        "BMMiner": BMMinerT17Plus,
        "CGMiner": CGMinerT17Plus,
    },
    "ANTMINER T17E": {
        "Default": BMMinerT17e,
        "BOSMiner+": BOSMinerT17e,
        "BMMiner": BMMinerT17e,
        "CGMiner": CGMinerT17e,
    },
    "ANTMINER S19": {
        "Default": BMMinerS19,
        "BOSMiner+": BOSMinerS19,
        "BMMiner": BMMinerS19,
        "CGMiner": CGMinerS19,
    },
    "ANTMINER S19 PRO": {
        "Default": BMMinerS19Pro,
        "BOSMiner+": BOSMinerS19Pro,
        "BMMiner": BMMinerS19Pro,
        "CGMiner": CGMinerS19Pro,
    },
    "ANTMINER S19J": {
        "Default": BMMinerS19j,
        "BOSMiner+": BOSMinerS19j,
        "BMMiner": BMMinerS19j,
        "CGMiner": CGMinerS19j,
    },
    "ANTMINER S19J PRO": {
        "Default": BMMinerS19jPro,
        "BOSMiner+": BOSMinerS19jPro,
        "BMMiner": BMMinerS19jPro,
        "CGMiner": CGMinerS19jPro,
    },
    "ANTMINER S19 XP": {
        "Default": BMMinerS19XP,
        "BMMiner": BMMinerS19XP,
    },
    "ANTMINER S19A": {
        "Default": BMMinerS19a,
        "BMMiner": BMMinerS19a,
    },
    "ANTMINER S19A PRO": {
        "Default": BMMinerS19aPro,
        "BMMiner": BMMinerS19aPro,
    },
    "ANTMINER T19": {
        "Default": BMMinerT19,
        "BOSMiner+": BOSMinerT19,
        "BMMiner": BMMinerT19,
        "CGMiner": CGMinerT19,
    },
    "M20": {"Default": BTMinerM20V10, "BTMiner": BTMinerM20V10, "10": BTMinerM20V10},
    "M20S": {
        "Default": BTMinerM20SV10,
        "BTMiner": BTMinerM20SV10,
        "10": BTMinerM20SV10,
        "20": BTMinerM20SV20,
        "30": BTMinerM20SV30,
    },
    "M20S+": {
        "Default": BTMinerM20SPlusV30,
        "BTMiner": BTMinerM20SPlusV30,
        "30": BTMinerM20SPlusV30,
    },
    "M21": {"Default": BTMinerM21V10, "BTMiner": BTMinerM21V10, "10": BTMinerM21V10},
    "M21S": {
        "Default": BTMinerM21SV20,
        "BTMiner": BTMinerM21SV20,
        "20": BTMinerM21SV20,
        "60": BTMinerM21SV60,
        "70": BTMinerM21SV70,
    },
    "M21S+": {
        "Default": BTMinerM21SPlusV20,
        "BTMiner": BTMinerM21SPlusV20,
        "20": BTMinerM21SPlusV20,
    },
    "M29": {"Default": BTMinerM29V10, "BTMiner": BTMinerM29V10, "10": BTMinerM29V10},
    "M30": {
        "Default": BTMinerM30V10,
        "BTMiner": BTMinerM30V10,
        "10": BTMinerM30V10,
        "20": BTMinerM30V20,
    },
    "M30S": {
        "Default": BTMinerM30SV10,
        "BTMiner": BTMinerM30SV10,
        "10": BTMinerM30SV10,
        "20": BTMinerM30SV20,
        "30": BTMinerM30SV30,
        "40": BTMinerM30SV40,
        "50": BTMinerM30SV50,
        "60": BTMinerM30SV60,
        "70": BTMinerM30SV70,
        "80": BTMinerM30SV80,
        "E10": BTMinerM30SVE10,
        "E20": BTMinerM30SVE20,
        "E30": BTMinerM30SVE30,
        "E40": BTMinerM30SVE40,
        "E50": BTMinerM30SVE50,
        "E60": BTMinerM30SVE60,
        "E70": BTMinerM30SVE70,
        "F10": BTMinerM30SVF10,
        "F20": BTMinerM30SVF20,
        "F30": BTMinerM30SVF30,
        "G10": BTMinerM30SVG10,
        "G20": BTMinerM30SVG20,
        "G30": BTMinerM30SVG30,
        "G40": BTMinerM30SVG40,
        "H10": BTMinerM30SVH10,
        "H20": BTMinerM30SVH20,
        "H30": BTMinerM30SVH30,
        "H40": BTMinerM30SVH40,
        "H50": BTMinerM30SVH50,
        "H60": BTMinerM30SVH60,
        "I20": BTMinerM30SVI20,
    },
    "M30S+": {
        "Default": BTMinerM30SPlusV10,
        "BTMiner": BTMinerM30SPlusV10,
        "10": BTMinerM30SPlusV10,
        "20": BTMinerM30SPlusV20,
        "30": BTMinerM30SPlusV30,
        "40": BTMinerM30SPlusV40,
        "50": BTMinerM30SPlusV50,
        "60": BTMinerM30SPlusV60,
        "70": BTMinerM30SPlusV70,
        "80": BTMinerM30SPlusV80,
        "90": BTMinerM30SPlusV90,
        "100": BTMinerM30SPlusV100,
        "E30": BTMinerM30SPlusVE30,
        "E40": BTMinerM30SPlusVE40,
        "E50": BTMinerM30SPlusVE50,
        "E60": BTMinerM30SPlusVE60,
        "E70": BTMinerM30SPlusVE70,
        "E80": BTMinerM30SPlusVE80,
        "E90": BTMinerM30SPlusVE90,
        "E100": BTMinerM30SPlusVE100,
        "F20": BTMinerM30SPlusVF20,
        "F30": BTMinerM30SPlusVF30,
        "G30": BTMinerM30SPlusVG30,
        "G40": BTMinerM30SPlusVG40,
        "G50": BTMinerM30SPlusVG50,
        "G60": BTMinerM30SPlusVG60,
        "H10": BTMinerM30SPlusVH10,
        "H20": BTMinerM30SPlusVH20,
        "H30": BTMinerM30SPlusVH30,
        "H40": BTMinerM30SPlusVH40,
        "H50": BTMinerM30SPlusVH50,
        "H60": BTMinerM30SPlusVH60,
    },
    "M30S++": {
        "Default": BTMinerM30SPlusPlusV10,
        "BTMiner": BTMinerM30SPlusPlusV10,
        "10": BTMinerM30SPlusPlusV10,
        "20": BTMinerM30SPlusPlusV20,
        "E30": BTMinerM30SPlusPlusVE30,
        "E40": BTMinerM30SPlusPlusVE40,
        "E50": BTMinerM30SPlusPlusVE50,
        "F40": BTMinerM30SPlusPlusVF40,
        "G30": BTMinerM30SPlusPlusVG30,
        "G40": BTMinerM30SPlusPlusVG40,
        "G50": BTMinerM30SPlusPlusVG50,
        "H10": BTMinerM30SPlusPlusVH10,
        "H20": BTMinerM30SPlusPlusVH20,
        "H30": BTMinerM30SPlusPlusVH30,
        "H40": BTMinerM30SPlusPlusVH40,
        "H50": BTMinerM30SPlusPlusVH50,
        "H60": BTMinerM30SPlusPlusVH60,
        "H70": BTMinerM30SPlusPlusVH70,
        "H80": BTMinerM30SPlusPlusVH80,
        "H90": BTMinerM30SPlusPlusVH90,
        "H100": BTMinerM30SPlusPlusVH100,
        "J20": BTMinerM30SPlusPlusVJ20,
        "J30": BTMinerM30SPlusPlusVJ30,
    },
    "M31": {
        "Default": BTMinerM31V10,
        "BTMiner": BTMinerM31V10,
        "10": BTMinerM31V10,
        "20": BTMinerM31V20,
    },
    "M31S": {
        "Default": BTMinerM31SV10,
        "BTMiner": BTMinerM31SV10,
        "10": BTMinerM31SV10,
        "20": BTMinerM31SV20,
        "30": BTMinerM31SV30,
        "40": BTMinerM31SV40,
        "50": BTMinerM31SV50,
        "60": BTMinerM31SV60,
        "70": BTMinerM31SV70,
        "80": BTMinerM31SV80,
        "90": BTMinerM31SV90,
        "E10": BTMinerM31SVE10,
        "E20": BTMinerM31SVE20,
        "E30": BTMinerM31SVE30,
    },
    "M31SE": {
        "Default": BTMinerM31SEV10,
        "BTMiner": BTMinerM31SEV10,
        "10": BTMinerM31SEV10,
        "20": BTMinerM31SEV20,
        "30": BTMinerM31SEV30,
    },
    "M31H": {
        "Default": BTMinerM31HV40,
        "BTMiner": BTMinerM31HV40,
        "40": BTMinerM31HV40,
    },
    "M31S+": {
        "Default": BTMinerM31SPlusV10,
        "BTMiner": BTMinerM31SPlusV10,
        "10": BTMinerM31SPlusV10,
        "20": BTMinerM31SPlusV20,
        "30": BTMinerM31SPlusV30,
        "40": BTMinerM31SPlusV40,
        "50": BTMinerM31SPlusV50,
        "60": BTMinerM31SPlusV60,
        "80": BTMinerM31SPlusV80,
        "90": BTMinerM31SPlusV90,
        "100": BTMinerM31SPlusV100,
        "E10": BTMinerM31SPlusVE10,
        "E20": BTMinerM31SPlusVE20,
        "E30": BTMinerM31SPlusVE30,
        "E40": BTMinerM31SPlusVE40,
        "E50": BTMinerM31SPlusVE50,
        "E60": BTMinerM31SPlusVE60,
        "E80": BTMinerM31SPlusVE80,
        "F20": BTMinerM31SPlusVF20,
        "F30": BTMinerM31SPlusVF30,
        "G20": BTMinerM31SPlusVG20,
        "G30": BTMinerM31SPlusVG30,
    },
    "M32": {
        "Default": BTMinerM32V10,
        "BTMiner": BTMinerM32V10,
        "10": BTMinerM32V10,
        "20": BTMinerM32V20,
    },
    "M33": {
        "Default": BTMinerM33V10,
        "BTMiner": BTMinerM33V10,
        "10": BTMinerM33V10,
        "20": BTMinerM33V20,
        "30": BTMinerM33V30,
    },
    "M33S": {
        "Default": BTMinerM33SVG30,
        "BTMiner": BTMinerM33SVG30,
        "G30": BTMinerM33SVG30,
    },
    "M33S+": {
        "Default": BTMinerM33SPlusVH20,
        "BTMiner": BTMinerM33SPlusVH20,
        "H20": BTMinerM33SPlusVH20,
        "H30": BTMinerM33SPlusVH30,
    },
    "M33S++": {
        "Default": BTMinerM33SPlusPlusVH20,
        "BTMiner": BTMinerM33SPlusPlusVH20,
        "H20": BTMinerM33SPlusPlusVH20,
        "H30": BTMinerM33SPlusPlusVH30,
        "G40": BTMinerM33SPlusPlusVG40,
    },
    "M34S+": {
        "Default": BTMinerM34SPlusVE10,
        "BTMiner": BTMinerM34SPlusVE10,
        "E10": BTMinerM34SPlusVE10,
    },
    "M36S": {
        "Default": BTMinerM36SVE10,
        "BTMiner": BTMinerM36SVE10,
        "E10": BTMinerM36SVE10,
    },
    "M36S+": {
        "Default": BTMinerM36SPlusVG30,
        "BTMiner": BTMinerM36SPlusVG30,
        "G30": BTMinerM36SPlusVG30,
    },
    "M36S++": {
        "Default": BTMinerM36SPlusPlusVH30,
        "BTMiner": BTMinerM36SPlusPlusVH30,
        "H30": BTMinerM36SPlusPlusVH30,
    },
    "M39": {"Default": BTMinerM39V20, "BTMiner": BTMinerM39V20, "20": BTMinerM39V20},
    "M50": {
        "Default": BTMinerM50VG30,
        "BTMiner": BTMinerM50VG30,
        "G30": BTMinerM50VG30,
        "H10": BTMinerM50VH10,
        "H20": BTMinerM50VH20,
        "H30": BTMinerM50VH30,
        "H40": BTMinerM50VH40,
        "H50": BTMinerM50VH50,
        "H60": BTMinerM50VH60,
        "H70": BTMinerM50VH70,
        "H80": BTMinerM50VH80,
        "J10": BTMinerM50VJ10,
        "J20": BTMinerM50VJ20,
        "J30": BTMinerM50VJ30,
    },
    "M50S": {
        "Default": BTMinerM50SVJ10,
        "BTMiner": BTMinerM50SVJ10,
        "J10": BTMinerM50SVJ10,
        "J20": BTMinerM50SVJ20,
        "J30": BTMinerM50SVJ30,
        "H10": BTMinerM50SVH10,
        "H20": BTMinerM50SVH20,
        "H30": BTMinerM50SVH30,
        "H40": BTMinerM50SVH40,
        "H50": BTMinerM50SVH50,
    },
    "M50S+": {
        "Default": BTMinerM50SPlusVH30,
        "BTMiner": BTMinerM50SPlusVH30,
        "H30": BTMinerM50SPlusVH30,
        "H40": BTMinerM50SPlusVH40,
        "J30": BTMinerM50SPlusVJ30,
    },
    "M53": {
        "Default": BTMinerM53VH30,
        "BTMiner": BTMinerM53VH30,
        "H30": BTMinerM53VH30,
    },
    "M53S": {
        "Default": BTMinerM53SVH30,
        "BTMiner": BTMinerM53SVH30,
        "H30": BTMinerM53SVH30,
    },
    "M53S+": {
        "Default": BTMinerM53SPlusVJ30,
        "BTMiner": BTMinerM53SPlusVJ30,
        "J30": BTMinerM53SPlusVJ30,
    },
    "M56": {
        "Default": BTMinerM56VH30,
        "BTMiner": BTMinerM56VH30,
        "H30": BTMinerM56VH30,
    },
    "M56S": {
        "Default": BTMinerM56SVH30,
        "BTMiner": BTMinerM56SVH30,
        "H30": BTMinerM56SVH30,
    },
    "M56S+": {
        "Default": BTMinerM56SPlusVJ30,
        "BTMiner": BTMinerM56SPlusVJ30,
        "J30": BTMinerM56SPlusVJ30,
    },
    "M59": {
        "Default": BTMinerM59VH30,
        "BTMiner": BTMinerM59VH30,
        "H30": BTMinerM59VH30,
    },
    "AVALONMINER 721": {
        "Default": CGMinerAvalon721,
        "CGMiner": CGMinerAvalon721,
    },
    "AVALONMINER 741": {
        "Default": CGMinerAvalon741,
        "CGMiner": CGMinerAvalon741,
    },
    "AVALONMINER 761": {
        "Default": CGMinerAvalon761,
        "CGMiner": CGMinerAvalon761,
    },
    "AVALONMINER 821": {
        "Default": CGMinerAvalon821,
        "CGMiner": CGMinerAvalon821,
    },
    "AVALONMINER 841": {
        "Default": CGMinerAvalon841,
        "CGMiner": CGMinerAvalon841,
    },
    "AVALONMINER 851": {
        "Default": CGMinerAvalon851,
        "CGMiner": CGMinerAvalon851,
    },
    "AVALONMINER 921": {
        "Default": CGMinerAvalon921,
        "CGMiner": CGMinerAvalon921,
    },
    "AVALONMINER 1026": {
        "Default": CGMinerAvalon1026,
        "CGMiner": CGMinerAvalon1026,
    },
    "AVALONMINER 1047": {
        "Default": CGMinerAvalon1047,
        "CGMiner": CGMinerAvalon1047,
    },
    "AVALONMINER 1066": {
        "Default": CGMinerAvalon1066,
        "CGMiner": CGMinerAvalon1066,
    },
    "T3H+": {
        "Default": CGMinerInnosiliconT3HPlus,
        "CGMiner": CGMinerInnosiliconT3HPlus,
    },
    "Unknown": {"Default": UnknownMiner},
}


class MinerFactory(metaclass=Singleton):
    """A factory to handle identification and selection of the proper class of miner"""

    def __init__(self) -> None:
        self.miners = {}

    async def get_miner_generator(
        self, ips: List[Union[ipaddress.ip_address, str]]
    ) -> AsyncIterable[AnyMiner]:
        """
        Get Miner objects from ip addresses using an async generator.

        Returns an asynchronous generator containing Miners.

        Parameters:
            ips: a list of ip addresses to get miners for.

        Returns:
            An async iterable containing miners.
        """
        # get the event loop
        loop = asyncio.get_event_loop()
        # create a list of tasks
        scan_tasks = []
        # for each miner IP that was passed in, add a task to get its class
        for miner in ips:
            scan_tasks.append(loop.create_task(self.get_miner(miner)))
        # asynchronously run the tasks and return them as they complete
        scanned = asyncio.as_completed(scan_tasks)
        # loop through and yield the miners as they complete
        for miner in scanned:
            yield await miner

    async def get_miner(self, ip: Union[ipaddress.ip_address, str]) -> AnyMiner:
        """Decide a miner type using the IP address of the miner.

        Parameters:
            ip: An `ipaddress.ip_address` or string of the IP to find the miner.

        Returns:
            A miner class.
        """
        if isinstance(ip, str):
            ip = ipaddress.ip_address(ip)
        # check if the miner already exists in cache
        if ip in self.miners:
            return self.miners[ip]
        # if everything fails, the miner is already set to unknown
        model, api, ver, api_ver = None, None, None, None

        # try to get the API multiple times based on retries
        for i in range(PyasicSettings().miner_factory_get_version_retries):
            try:
                # get the API type, should be BOSMiner, CGMiner, BMMiner, BTMiner, or None
                new_model, new_api, new_ver, new_api_ver = await asyncio.wait_for(
                    self._get_miner_type(ip), timeout=10
                )
                # keep track of the API and model we found first
                if new_api and not api:
                    api = new_api
                if new_model and not model:
                    model = new_model
                if new_ver and not ver:
                    ver = new_ver
                if new_api_ver and not api_ver:
                    api_ver = new_api_ver
                # if we find the API and model, don't need to loop anymore
                if api and model:
                    break
            except asyncio.TimeoutError:
                logging.warning(f"{ip}: Get Miner Timed Out")
        miner = self._select_miner_from_classes(ip, model, api, ver, api_ver)

        # once we have the miner, get the api and firmware version
        # await miner.get_version()

        # save the miner to the cache at its IP if its not unknown
        if not isinstance(miner, UnknownMiner):
            self.miners[ip] = miner

        # return the miner
        return miner

    @staticmethod
    def _select_miner_from_classes(
        ip: ipaddress.ip_address,
        model: Union[str, None],
        api: Union[str, None],
        ver: Union[str, None],
        api_ver: Union[str, None] = None,
    ) -> AnyMiner:
        miner = UnknownMiner(str(ip))
        # make sure we have model information
        if model:
            if not api:
                api = "Default"

            if model not in MINER_CLASSES.keys():
                if "avalon" in model:
                    if model == "avalon10":
                        miner = CGMinerAvalon1066(str(ip), api_ver)
                    else:
                        miner = CGMinerAvalon821(str(ip), api_ver)
                return miner
            if api not in MINER_CLASSES[model].keys():
                api = "Default"
            if ver in MINER_CLASSES[model].keys():
                miner = MINER_CLASSES[model][ver](str(ip), api_ver)
                return miner
            miner = MINER_CLASSES[model][api](str(ip), api_ver)

        # if we cant find a model, check if we found the API
        else:

            # return the miner base class with some API if we found it
            if api:
                if "BOSMiner+" in api:
                    miner = BOSMiner(str(ip), api_ver)
                elif "BOSMiner" in api:
                    miner = BOSMinerOld(str(ip), api_ver)
                elif "CGMiner" in api:
                    miner = CGMiner(str(ip), api_ver)
                elif "BTMiner" in api:
                    miner = BTMiner(str(ip), api_ver)
                elif "BMMiner" in api:
                    miner = BMMiner(str(ip), api_ver)

        return miner

    def clear_cached_miners(self) -> None:
        """Clear the miner factory cache."""
        # empty out self.miners
        self.miners = {}

    async def _get_miner_type(
        self, ip: Union[ipaddress.ip_address, str]
    ) -> Tuple[Union[str, None], Union[str, None], Union[str, None], Union[str, None]]:
        model, api, ver, api_ver = None, None, None, None

        try:
            devdetails, version = await self.__get_devdetails_and_version(ip)
        except APIError as e:
            # catch APIError and let the factory know we cant get data
            logging.warning(f"{ip}: API Command Error: {e}")
            return None, None, None, None
        except OSError or ConnectionRefusedError:
            # miner refused connection on API port, we wont be able to get data this way
            # try ssh
            try:
                _model = await self.__get_model_from_graphql(ip)
                if not _model:
                    _model = await self.__get_model_from_ssh(ip)
                if _model:
                    model = _model
                    api = "BOSMiner+"
            except asyncssh.misc.PermissionDenied:
                try:
                    data = await self.__get_system_info_from_web(ip)
                    if not data.get("success"):
                        _model = await self.__get_dragonmint_version_from_web(ip)
                        if _model:
                            model = _model
                    if "minertype" in data:
                        model = data["minertype"].upper()
                    if "bmminer" in "\t".join(data):
                        api = "BMMiner"
                except Exception as e:
                    logging.debug(f"Unable to get miner - {e}")
            return model, api, ver, api_ver

        # if we have devdetails, we can get model data from there
        if devdetails:
            for _devdetails_key in ["Model", "Driver"]:
                try:
                    model = devdetails["DEVDETAILS"][0][_devdetails_key].upper()
                    if not model == "BITMICRO":
                        break
                except (KeyError, IndexError):
                    continue
            try:
                if devdetails[0]["STATUS"][0]["Msg"]:
                    model = await self.__get_model_from_graphql(ip)
                    if model:
                        api = "BOSMiner+"
            except (KeyError, TypeError, ValueError, IndexError):
                pass
            try:
                if not model:
                    # braiins OS bug check just in case
                    if "s9" in devdetails["STATUS"][0]["Description"]:
                        model = "ANTMINER S9"
                    if "s17" in version["STATUS"][0]["Description"]:
                        model = "ANTMINER S17"
            except (KeyError, TypeError, ValueError, IndexError):
                pass
            try:
                if not api:
                    if "boser" in version["STATUS"][0]["Description"]:
                        api = "BOSMiner+"
            except (KeyError, TypeError, ValueError, IndexError):
                pass
        else:
            try:
                _model = await self.__get_model_from_graphql(ip)
                if _model:
                    model = _model
                    api = "BOSMiner+"
            except (KeyError, TypeError, ValueError, IndexError):
                pass

        # if we have version we can get API type from here
        if version:
            try:
                if isinstance(version.get("Msg"), dict):
                    if "api_ver" in version["Msg"]:
                        api_ver = (
                            version["Msg"]["api_ver"]
                            .replace("whatsminer ", "")
                            .replace("v", "")
                        )
                        api = "BTMiner"

                if version[0]["STATUS"][0]["Msg"]:
                    model = await self.__get_model_from_graphql(ip)
                    if model:
                        api = "BOSMiner+"
                        try:
                            api_ver = version[0]["VERSION"][0]["API"]
                        except (KeyError, TypeError, ValueError, IndexError):
                            pass
                        return model, api, ver, api_ver
            except (KeyError, TypeError, ValueError, IndexError):
                pass
            if "VERSION" in version:
                api_ver = version["VERSION"][0].get("API")
                api_types = ["BMMiner", "CGMiner", "BTMiner"]
                # check basic API types, BOSMiner needs a special check
                for api_type in api_types:
                    if any(api_type in string for string in version["VERSION"][0]):
                        api = api_type

                # check if there are any BOSMiner strings in any of the dict keys
                if any("BOSminer" in string for string in version["VERSION"][0]):
                    api = "BOSMiner"
                    if version["VERSION"][0].get("BOSminer"):
                        if "plus" in version["VERSION"][0]["BOSminer"]:
                            api = "BOSMiner+"
                    if "BOSminer+" in version["VERSION"][0]:
                        api = "BOSMiner+"
                if any("BOSer" in string for string in version["VERSION"][0]):
                    api = "BOSMiner+"

                # check for avalonminers
                for _version_key in ["PROD", "MODEL"]:
                    try:
                        _data = version["VERSION"][0][_version_key].split("-")
                    except KeyError:
                        continue

                    model = _data[0].upper()
                    if _version_key == "MODEL":
                        model = f"AVALONMINER {_data[0]}"
                    if len(_data) > 1:
                        ver = _data[1]

            if version.get("Description") and (
                "whatsminer" in version.get("Description")
            ):
                api = "BTMiner"

        # if we have no model from devdetails but have version, try to get it from there
        if version and not model:
            try:
                model = version["VERSION"][0]["Type"].upper()
            except KeyError:
                pass

        if not model:
            stats = await self._send_api_command(str(ip), "stats")
            if stats:
                try:
                    _model = stats["STATS"][0]["Type"].upper()
                except KeyError:
                    pass
                else:
                    for split_point in [" BB", " XILINX", " (VNISH"]:
                        if split_point in _model:
                            _model = _model.split(split_point)[0]
                    if "PRO" in _model and " PRO" not in _model:
                        _model = _model.replace("PRO", " PRO")
                    model = _model
            else:
                _model = await self.__get_dragonmint_version_from_web(ip)
                if _model:
                    model = _model

        if model:
            if "DRAGONMINT" in model:
                _model = await self.__get_dragonmint_version_from_web(ip)
                if _model:
                    model = _model
            if " HIVEON" in model:
                # do hiveon check before whatsminer as HIVEON contains a V
                model = model.split(" HIVEON")[0]
                api = "Hiveon"
            # whatsminer have a V in their version string (M20SV41), everything after it is ver
            if "V" in model:
                _ver = model.split("V")
                if len(_ver) > 1:
                    ver = model.split("V")[1]
                    model = model.split("V")[0]
            # don't need "Bitmain", just "ANTMINER XX" as model
            if "BITMAIN " in model:
                model = model.replace("BITMAIN ", "")
        return model, api, ver, api_ver

    async def __get_devdetails_and_version(
        self, ip
    ) -> Tuple[Union[dict, None], Union[dict, None]]:
        version = None
        try:
            # get device details and version data
            data = await self._send_api_command(str(ip), "devdetails+version")
            # validate success
            validation = await self._validate_command(data)
            if not validation[0]:
                try:
                    if data["version"][0]["STATUS"][0]["Msg"] == "Disconnected":
                        return data["devdetails"], data["version"]
                except (KeyError, TypeError):
                    pass
                raise APIError(validation[1])
            # copy each part of the main command to devdetails and version
            devdetails = data["devdetails"][0]
            version = data["version"][0]
            if "STATUS" in version:
                if len(version["STATUS"]) > 0:
                    if "Description" in version["STATUS"][0]:
                        if version["STATUS"][0]["Description"] == "btminer":
                            try:
                                new_version = await self._send_api_command(
                                    str(ip), "get_version"
                                )
                                validation = await self._validate_command(new_version)
                                if validation[0]:
                                    version = new_version
                            except Exception as e:
                                logging.warning(
                                    f"([Hidden] Get Devdetails and Version) - Error {e}"
                                )
            if "DEVDETAILS" in devdetails:
                if len(devdetails["DEVDETAILS"]) > 0:
                    if devdetails["DEVDETAILS"][0].get("Driver") == "bitmicro":
                        try:
                            new_version = await self._send_api_command(
                                str(ip), "get_version"
                            )
                            validation = await self._validate_command(new_version)
                            if validation[0]:
                                version = new_version
                        except Exception as e:
                            logging.warning(
                                f"([Hidden] Get Devdetails and Version) - Error {e}"
                            )
            return devdetails, version
        except APIError:
            # try devdetails and version separately (X19s mainly require this)
            # get devdetails and validate
            devdetails = await self._send_api_command(str(ip), "devdetails")
            validation = await self._validate_command(devdetails)
            if not validation[0]:
                # if devdetails fails try version instead
                devdetails = None

                # get version and validate
                version = await self._send_api_command(str(ip), "version")
                validation = await self._validate_command(version)
                if not validation[0]:
                    # finally try get_version (Whatsminers) and validate
                    version = await self._send_api_command(str(ip), "get_version")
                    validation = await self._validate_command(version)

                    # if this fails we raise an error to be caught below
                    if not validation[0]:
                        raise APIError(validation[1])
        return devdetails, version

    @staticmethod
    async def __get_model_from_ssh(ip: ipaddress.ip_address) -> Union[str, None]:
        model = None
        async with asyncssh.connect(
            str(ip),
            known_hosts=None,
            username="root",
            password="admin",
            server_host_key_algs=["ssh-rsa"],
        ) as conn:
            board_name = None
            cmd = await conn.run("cat /tmp/sysinfo/board_name")
            if cmd:
                board_name = cmd.stdout.strip()
        if board_name == "am1-s9":
            model = "ANTMINER S9"
        if board_name == "am2-s17":
            model = "ANTMINER S17"
        return model

    @staticmethod
    async def __get_model_from_graphql(ip: ipaddress.ip_address) -> Union[str, None]:
        model = None
        url = f"http://{ip}/graphql"
        try:
            async with httpx.AsyncClient() as client:
                d = await client.post(
                    url, json={"query": "{bosminer {info{modelName}}}"}
                )
            if d.status_code == 200:
                model = (d.json()["data"]["bosminer"]["info"]["modelName"]).upper()
            return model
        except httpx.HTTPError:
            pass

    @staticmethod
    async def __get_system_info_from_web(ip) -> dict:
        url = f"http://{ip}/cgi-bin/get_system_info.cgi"
        auth = httpx.DigestAuth("root", "root")
        try:
            async with httpx.AsyncClient() as client:
                data = await client.get(url, auth=auth)
            if data.status_code == 200:
                data = data.json()
            return data
        except httpx.HTTPError:
            pass

    @staticmethod
    async def __get_dragonmint_version_from_web(
        ip: ipaddress.ip_address,
    ) -> Union[str, None]:
        response = None
        try:
            async with httpx.AsyncClient() as client:
                auth = (
                    await client.post(
                        f"http://{ip}/api/auth",
                        data={"username": "admin", "password": "admin"},
                    )
                ).json()["jwt"]
                response = (
                    await client.post(
                        f"http://{ip}/api/type",
                        headers={"Authorization": "Bearer " + auth},
                        data={},
                    )
                ).json()
        except httpx.HTTPError as e:
            logging.info(e)
        if response:
            try:
                return response["type"]
            except KeyError:
                pass

    @staticmethod
    async def _validate_command(data: dict) -> Tuple[bool, Union[str, None]]:
        """Check if the returned command output is correctly formatted."""
        # check if the data returned is correct or an error
        if not data or data == {}:
            return False, "No API data."
        # if status isn't a key, it is a multicommand
        if "STATUS" not in data.keys():
            for key in data.keys():
                # make sure not to try to turn id into a dict
                if not key == "id":
                    # make sure they succeeded
                    if "STATUS" in data[key][0].keys():
                        if data[key][0]["STATUS"][0]["STATUS"] not in ["S", "I"]:
                            # this is an error
                            return False, f"{key}: " + data[key][0]["STATUS"][0]["Msg"]
        elif "id" not in data.keys():
            if data["STATUS"] not in ["S", "I"]:
                return False, data["Msg"]
        else:
            # make sure the command succeeded
            if data["STATUS"][0]["STATUS"] not in ("S", "I"):
                return False, data["STATUS"][0]["Msg"]
        return True, None

    @staticmethod
    async def _send_api_command(
        ip: Union[ipaddress.ip_address, str], command: str
    ) -> dict:
        try:
            # get reader and writer streams
            reader, writer = await asyncio.open_connection(str(ip), 4028)
        except OSError as e:
            if e.errno in [10061, 22]:
                raise e
            logging.warning(f"{str(ip)} - Command {command}: {e}")
            return {}
        # create the command
        cmd = {"command": command}

        # send the command
        writer.write(json.dumps(cmd).encode("utf-8"))
        await writer.drain()

        # instantiate data
        data = b""

        # loop to receive all the data
        try:
            while True:
                d = await reader.read(4096)
                if not d:
                    break
                data += d
        except Exception as e:
            logging.debug(f"{str(ip)}: {e}")

        try:
            # some json from the API returns with a null byte (\x00) on the end
            if data.endswith(b"\x00"):
                # handle the null byte
                str_data = data.decode("utf-8")[:-1]
            else:
                # no null byte
                str_data = data.decode("utf-8")
            # fix an error with a btminer return having an extra comma that breaks json.loads()
            str_data = str_data.replace(",}", "}")
            # fix an error with a btminer return having a newline that breaks json.loads()
            str_data = str_data.replace("\n", "")
            # fix an error with a bmminer return missing a specific comma that breaks json.loads()
            str_data = str_data.replace("}{", "},{")
            # parse the json
            data = json.loads(str_data)
        # handle bad json
        except json.decoder.JSONDecodeError:
            # raise APIError(f"Decode Error: {data}")
            data = None

        # close the connection
        writer.close()
        await writer.wait_closed()

        return data
