"""
Helper tools for IP related information.

Uses ipinfo.io API to retrive IP related information.

- A free **API token** is required to call the API.
- Tokens can be aquired by going to https://ipinfo.io/signup.
- To integrate **token** with this package, run
  ```python -m dt_tools.cli.set_iphelper_token```  
  or (with poetry)  
  ```poetry run python -m dt_tools.cli.set_iphelper_token```

To minimize API calls (and response time):

- IP Data is cached locally in ~/.IpHelper/cache.json.
- MAC Data is cached locally in ~/.IpHelper/mac_info.json.

For devices that are only identified by their IP and MAC address (no hostname),
if hostname known, you may 'force' by manually updating ~/.IpHelper/mac_info.json, 
which is keyed by mac address.

Format:

.. code-block:: python

    {
        "XX:XX:XX:XX:XX:XX": {
            "vendor": "Ring LLC",
            "hostname": "Ring.Doorbell"
        },
    }

"""

import json
import pathlib
from datetime import datetime, timedelta
from threading import Semaphore
from typing import Dict, List, Tuple

import requests
import urllib3
from dateutil import parser
from dt_tools.logger.logging_helper import logger_wraps
from loguru import logger as LOGGER

import dt_tools.net.ip_info_helper as ih
from dt_tools.net import net_helper as nh
from dt_tools.console.console_helper import ConsoleHelper as console
from dt_tools.console.console_helper import TextStyle


BASE_URL='https://ipinfo.io'
TOKEN="NOT SET"

IP_INFO_TOKEN_LOCATION=pathlib.Path('~').expanduser().absolute() / ".IpHelper" / "ipinfo_token.json"
IP_INFO_CACHE_LOCATION=pathlib.Path('~').expanduser().absolute() / ".IpHelper" / "cache.json"
MAC_INFO_LOCATION=pathlib.Path('~').expanduser().absolute() / ".IpHelper" / "mac_info.json"

_SECONDS_IN_MINUTE = 60
_SECONDS_IN_HOUR = 60 * _SECONDS_IN_MINUTE
_CACHE_TTL_HOURS = 48 
_CACHE_SEMAPHORE = Semaphore()
_UNKNOWN = 'unknown'

class IpHelper():
    """
    This class provides information about an IP address.  
    
    It interfaces with the free **ipinfo.io** site.  The ipinfo.io site
    requires a user token which is free.

    - See 'setting up user token' in docs for information on aquiring and setting up token.

    In order to minimize API calls and improve performance, a cache
    for IP and MAC information is created and stored locally.
      
    The local data will be refresed if it is > 48 hours old. It can also
    be manually refreshed/cleared from cache.
    
    The class provides the following information:

    For WAN IPs:

    - ip         : xxx.xxx.xxx.xxx
    - hostname   : hostname.domain
    - city       : Atlanta
    - region     : Georgia
    - country    : US
    - loc        : 33.7490,-84.3880
    - org        : XXXXXXXXXXXX
    - postal     : 30302
    - timezone   : America/New_York

    For LAN IPs:

    - ip         : xxx.xxx.xxx.xxx
    - hostname   : hostname.domain[or workgroup]
    - bogon      : True (identifies this as a local IP)
    - mac        : XX:XX:XX:XX:XX:XX
    - vendor     : Raspberry Pi Trading Ltd

    """
    # Class variables
    _cache_last_update_mtime: float = 0.0
    _cache: Dict[str, dict] = None
    _mac_info: Dict[str, dict] = None  # Manually maintained.
    _token_initialized: bool = False

    @classmethod
    @logger_wraps(level="TRACE")
    def __init__(cls, purge_stale_entries: bool = True, no_token:bool = False):
        if not no_token:
            cls._validate_token()        
        cls._load_cache(purge_stale_entries)

    @property
    def cache_dict(cls) -> dict:
        """
        The IP Info cache dictionary.
        """
        return IpHelper._cache
            
    @classmethod            
    @logger_wraps(level="TRACE")
    def clear_cache(cls, ip_address: str = None)-> int:
        """
        Clear the IP Info cache or cache entry.

        Keyword Arguments:
            ip_address -- IP address to be cleared (default: {None})
              If None, all entries will be removed.
        
        Returns:
            int -- Number of entries removed.
        """
        IP_INFO_CACHE_LOCATION.parent.mkdir(exist_ok=True)
        IP_INFO_CACHE_LOCATION.touch(exist_ok=True)
        cls._load_cache()
        initial_cache_len = len(IpHelper._cache)
        if ip_address:
            if ip_address in IpHelper._cache.keys():
                del IpHelper._cache[ip_address]
                _CACHE_SEMAPHORE.acquire(timeout=2.0)
                IP_INFO_CACHE_LOCATION.write_text(json.dumps(IpHelper._cache))
                _CACHE_SEMAPHORE.release()
                IpHelper._cache_last_update_mtime = IP_INFO_CACHE_LOCATION.stat().st_mtime
                LOGGER.debug(f'{ip_address} removed from cache')
            else:
                LOGGER.warning(f'{ip_address} does NOT exist in cache')
        else:
            _CACHE_SEMAPHORE.acquire(timeout=2.0)
            IP_INFO_CACHE_LOCATION.write_text("{}")
            _CACHE_SEMAPHORE.release()
            IpHelper._cache = {}
            IpHelper._cache_last_update_mtime = IP_INFO_CACHE_LOCATION.stat().st_mtime
        
        cur_len = len(IpHelper._cache)
        removed_cnt = initial_cache_len - cur_len
        LOGGER.debug(f'IpHelper Cache cleared.  {removed_cnt} entries removed. {cur_len} entries remaining.')
        return removed_cnt
    
    @classmethod
    def is_cached(cls, ip_address: str) -> bool:
        """
        Check if IP address is in cache.

        Arguments:
            ip_address -- Ip address to be checked.

        Returns:
            True if found else False.
        """
        cls._load_cache()  # Load cache if needed
        ip_info = IpHelper._cache.get(ip_address, None)
        if ip_info:
            return ip_info.get('_cached', False)
    
        return False
    
    @classmethod
    @logger_wraps(level="TRACE")
    def get_ip_info(cls, ip_address: str, 
                    include_unknown_fields: bool = False, 
                    include_private_fields: bool = False,
                    bypass_cache: bool = False) -> dict:
        """
        Get all information related to IP Address

        Arguments:
            ip_address: Target IP address.

        Keyword Arguments:
            bypass_cache: If true, force API call and refresh (default: {False}).

        Returns:
            JSON dictionary of all data related to target IP address.
        """
        urllib3.disable_warnings()
        cls._load_cache() 
        entry_updated = False
        if not nh.is_ipv4_address(ip_address):
            ip_info = {'ip': ip_address, "title": "Not IPv4 address", "error": "Only IPv4 addresses supported"}
            return ip_info

        LOGGER.debug(f'GET_IP_INFO for {ip_address}')   
        if bypass_cache:
            LOGGER.debug('- Bypassing cache lookup')
            ip_info = None
        else:
            ip_info = IpHelper._cache.get(ip_address, None)
            if ip_info and cls._stale(ip_address):
                LOGGER.debug(f'- {ip_address} stale, will re-fresh')
                ip_info = None

        if ip_info is None:
            LOGGER.debug(f'- {ip_address} NOT in cache or stale')
            url=f'{BASE_URL}/{ip_address}?token={TOKEN}'
            try:
                resp = requests.get(url, verify=False)
                ip_info = resp.json()
                entry_updated = True
            except Exception as ex:
                resp = ""
                LOGGER.debug(f'  ERROR- url: {url} ex: {repr(ex)}')
                ip_info = {'ip': {ip_address}, "title": "Error in API call", 'error': f'Exception: {url}- {repr(ex)}'}

        if 'error' in ip_info.keys():
            LOGGER.warning(f'ERROR - url: {url}  resp: {ip_info}')
            return ip_info

        ip = ip_info['ip']
        mac = ip_info.get('mac', _UNKNOWN)
        if ip_info.get('hostname', None) is None:
            LOGGER.debug(f'- get hostname from ip {ip}')
            ip_info['hostname'] = nh.get_hostname_from_ip(ip)
            if _UNKNOWN not in ip_info['hostname']:        
                entry_updated = True

        if _UNKNOWN in mac:
            LOGGER.debug(f'- get mac address for ip {ip}')
            mac = nh.get_mac_address(ip)
            if mac is None:
                mac = _UNKNOWN
            else:
                ip_info['vendor'] = nh.get_vendor_from_mac(mac)
                entry_updated = True

        if _UNKNOWN in mac:
            LOGGER.debug(f'- Unable to determine MAC from ip {ip}')
        else:
            ip_info['mac'] = mac
            if _UNKNOWN in ip_info['hostname']:
                # Check the Mac Info cache
                hostname = IpHelper._mac_info.get(mac,{'hostname': _UNKNOWN})['hostname']
                LOGGER.debug(f'- {mac} - Lookup hostname based on mac_info cache. [{hostname}]')
                ip_info['hostname'] = f'-> {hostname}'
                if _UNKNOWN not in hostname:
                    entry_updated = True
            if _UNKNOWN in ip_info.get('vendor',_UNKNOWN):
                # Check the Mac Info cache
                vendor = IpHelper._mac_info.get(mac,{'vendor': _UNKNOWN})['vendor']
                LOGGER.debug(f'- {mac} - Lookup vendor based on mac_info cache. {vendor}')
                ip_info['vendor'] = f'-> {vendor}'
                if _UNKNOWN not in vendor:
                    entry_updated = True

        if entry_updated:
            ip_info["_cached"] = datetime.now().isoformat() 
            cls._load_cache() 
            _CACHE_SEMAPHORE.acquire(timeout=5.0)
            IpHelper._cache[ip_address] = ip_info
            # TODO: Performance, ONLY write new entry
            IP_INFO_CACHE_LOCATION.write_text(json.dumps(IpHelper._cache))
            _CACHE_SEMAPHORE.release()
            LOGGER.debug(f'SUCCESS: cache updated: {ip}/{ip_info["hostname"]}/{mac}')
            IpHelper._cache_last_update_mtime = IP_INFO_CACHE_LOCATION.stat().st_mtime
        
        if not include_private_fields or not include_unknown_fields:
            new_dict = {}
            for key, value in ip_info.items():
                if not include_private_fields and key.startswith('_'):
                    continue
                if not include_unknown_fields and value == _UNKNOWN:
                    continue
                new_dict[key] = value
            ip_info = new_dict

        return ip_info

    @classmethod
    def get_wan_ip_info(cls) -> Tuple[dict, int]:
        """
        Return the WAN (ie. Internet) address for this device.

        Returns:
            Tuple[dict, int]: ip_info entry and return code.
        """
        urllib3.disable_warnings()
        cls._validate_token()
        ip_info = None
        url=f'{BASE_URL}?token={TOKEN}'
        resp = ""
        rc = -1
        try:
            resp = requests.get(url, verify=False)
            ip_info = resp.json()
            rc = resp.status_code
        except Exception as ex:
            ip_info = {'error': f'Exception: {repr(ex)}'}

        if 'error' in ip_info:
            LOGGER.warning(f'ERROR- url: {url}  resp: {ip_info}')

        return (ip_info, rc)
        
    @classmethod        
    def find_in_cache(cls, search_token: str) -> List[str]:
        """
        Search each cache entry for supplied search string.

        Arguments:
            search_token: Text to search for.

        Returns:
            A list of IPs (str) whose data containes the search_token.
        """
        cls._load_cache() # update memory with any changes
        found_keys = []
        for key, entry in IpHelper._cache.items():
            for field_val in entry.values():
                if search_token.lower() in str(field_val).lower():
                    found_keys.append(key)
                    LOGGER.debug(f'- found {key} in {field_val}')
                    break

        found = len(found_keys)
        if found == 0:
            LOGGER.warning(f'- Search key [{search_token}] NOT found')
        else:
            for key in found_keys:
                cls.list_cache(key)
            LOGGER.info('')
            LOGGER.success(f'- {found} entries found.')

        return found_keys
        
    @classmethod
    def list_cache(cls, ip: str = None, show_all_fields: bool = True):
        """
        Print formatted contents of cache to console.

        Args:
            ip (str, optional): Target IP address. Defaults to None.
            show_all_fields (bool, optional): Include private (_) fields. Defaults to True.
        """        
        cls._load_cache() # Update memory with any changes
        if len(IpHelper._cache) == 0:
            LOGGER.warning('Sorry, cache is empty')
        elif ip is not None:
            entry = IpHelper._cache.get(ip, None)
            if entry:
                LOGGER.success(f'{console.cwrap(f"-- {ip} ---------------------------------------", style=TextStyle.BOLD)}')
                cls._print_entry(entry, show_all=show_all_fields)
            else:
                LOGGER.warning(f'{ip} does NOT exist in cache.')
        else:
            for key, entry in IpHelper._cache.items():
                LOGGER.success(f'{console.cwrap(f"-- {key} ---------------------------------------", style=TextStyle.BOLD)}')
                cls._print_entry(entry, show_all=show_all_fields)
                LOGGER.info('')
            LOGGER.info(f'IP Info cache contains {len(IpHelper._cache)} entries')

    @classmethod
    def _validate_token(cls) -> bool:
        LOGGER.debug('Token validation in progress...')
        rc = -1
        if not cls._token_initialized:
            if not IP_INFO_TOKEN_LOCATION.exists():
                LOGGER.error('Cached token NOT found.')
                cls._raise_token_error()            
            token_dict: dict = json.loads(IP_INFO_TOKEN_LOCATION.read_text())
            ih.TOKEN = token_dict.get('token', 'NOT VALID')
            cls._token_initialized = True
            ip_info, rc = cls.get_wan_ip_info()
            if rc != 200:
                LOGGER.error(f'Token check returns: {(ip_info)}')
                cls._raise_token_error()

        return True

    @classmethod
    def _raise_token_error(cls):
        LOGGER.warning('A token is required for IpHelper to function.')
        LOGGER.warning('Tokens are free, to get your token, go to "https://ipinfo.io/missingauth')
        LOGGER.warning('When you have a token run dt_set_iptoken to set the token for IpHelper.')
        raise RuntimeError('Invalid token for ipinfo.io.  See log and https://ipinfo.io/missingauth')

    @classmethod        
    def _load_cache(cls, purge_stale: bool = False):
        _CACHE_SEMAPHORE.acquire(timeout=15.0)
        if cls._refresh_required:
            IpHelper._cache = {}
            if not IP_INFO_CACHE_LOCATION.exists():
                cls.clear_cache()
            try:
                buffer = IP_INFO_CACHE_LOCATION.read_text()
                if buffer == "":
                    buffer = "{}"
                IpHelper._cache = json.loads(buffer)
                LOGGER.debug(f'{len(IpHelper._cache)} IpHelper cache entries loaded.')
                if purge_stale:
                    dropped = cls._drop_stale_entries()
                    LOGGER.debug(f'{dropped} IpHelper cache stale entries dropped.')
                IpHelper._cache_last_update_mtime = IP_INFO_CACHE_LOCATION.stat().st_mtime
            except Exception as ex:
                LOGGER.error(f'Unable to load IP Info Cache: {IP_INFO_CACHE_LOCATION}')
                LOGGER.warning(f'  {repr(ex)}')

        if IpHelper._mac_info is None:
            if not MAC_INFO_LOCATION.parent.exists():
                LOGGER.debug(f'Create directory {MAC_INFO_LOCATION.parent}')
                MAC_INFO_LOCATION.parent.mkdir(exist_ok=True)
            if not MAC_INFO_LOCATION.exists():
                LOGGER.debug(f'Create MAC Cache file: {MAC_INFO_LOCATION}')
                MAC_INFO_LOCATION.touch(exist_ok=True)
            buffer = MAC_INFO_LOCATION.read_text()
            if buffer == "":
                buffer = "{}"
            LOGGER.debug(f'Load MAC info from {MAC_INFO_LOCATION}')
            IpHelper._mac_info = json.loads(buffer)

        _CACHE_SEMAPHORE.release()

    @property
    def _refresh_required(cls) -> bool:
        refresh = False
        if IP_INFO_CACHE_LOCATION.exists():
            last_update = IP_INFO_CACHE_LOCATION.stat().st_mtime
            if last_update > IpHelper._cache_last_update_mtime:
                # Cache has been updated
                LOGGER.debug('Cache refresh required!')
                refresh = True
        elif IpHelper._cache is None:
            LOGGER.debug('Cache is empty, refresh required!')
            refresh = True
        else:
            LOGGER.debug('Cache refresh is NOT required.')
        return refresh

    @classmethod
    def _stale(self, ip_address: str) -> bool:
        """Return true if cached entry is stale"""
        ip_info = IpHelper._cache.get(ip_address, None)
        if ip_info:
            cached_time = ip_info.get('_cached', None)
            if cached_time:
                created = parser.parse(cached_time)
                delta = datetime.now() - created
                age_in_hours = delta.total_seconds() / _SECONDS_IN_HOUR
                # LOGGER.debug(f'{ip_info["hostname"]} age in hours {age_in_hours}')
                if age_in_hours > _CACHE_TTL_HOURS:
                    return True
            
        return False

    @classmethod
    def _age(cls, entry: dict) -> str:
        cached_time =  entry.get('_cached', None)
        age = 'N/A'
        if cached_time:
            created = parser.parse(cached_time)
            td = datetime.now() - created
            age = f'{td.days} days, {td.seconds//3600} hours, {(td.seconds//60)%60} minutes'
        return age

    @classmethod
    def _expires(cls, entry: dict) -> str:
        cached_time =  entry.get('_cached', None)
        expires = "Unknown"
        ttl = 'N/A'
        if cached_time:
            created = parser.parse(cached_time)
            expire_time = created + timedelta(hours=_CACHE_TTL_HOURS)
            ttl = expire_time - datetime.now()
            if ttl.seconds < 0:
                expires = "expired."
            else:
                expires = f'{ttl.days} days, {ttl.seconds//3600} hours, {(ttl.seconds//60)%60} minutes'
        return expires

    @classmethod
    def _drop_stale_entries(self) -> int:
        dropped = 0
        del_list = []
        for ip_address in IpHelper._cache.keys():
            # Create list of IPs to be removed
            if self._stale(ip_address):
                del_list.append(ip_address)
        for ip_address in del_list:
            # Remove identified IPs
            self.clear_cache(ip_address)
            dropped += 1
        return dropped 
    
    @classmethod
    def _print_entry(cls, entry: dict, show_all: bool = True):
        entry['_cache_age'] = cls._age(entry)
        entry['_expires'] = cls._expires(entry)
        entry['_stale'] = cls._stale(entry['ip'])
        for k,v in entry.items():
            if not show_all and k.startswith('_'):
                pass
            else:
                LOGGER.info(f'  {k:10} : {v}')

