#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import ipaddress
import json
from functools import lru_cache
from pathlib import Path
from typing import Type

import requests
from nc_dnsapi import Client, DNSRecord

from netcup_dns.exception import UnknownIPException, MultipleRecordsException
from netcup_dns.record_dst_cache import RecordDestinationCache

from netcup_dns.print_util import print_info, print_err


def main():
    """
    The main effort is done by https://github.com/nbuchwitz/nc_dnsapi
    """
    args = parse_args()
    cfg_dir: Path = args.cfg_dir
    cache: RecordDestinationCache | None = args.cache

    cfg_files = [file for file in cfg_dir.iterdir() if file.name.endswith('.json') and file.is_file()]
    for cfg_file in cfg_files:
        cfg: dict = json.loads(cfg_file.read_text())

        if len(cfg) == 0 or ('disabled' in cfg and cfg['disabled'] is True):
            # Skip empty or disabled configuration file.
            continue

        customer = cfg['customer']
        api_key = cfg['api_key']
        api_password = cfg['api_password']

        entries = cfg['records']
        with Client(customer, api_key, api_password) as api:
            for entry in entries:
                domain = entry['domain']
                hostname = entry['hostname']
                type_ = entry['type'].upper()

                if type_ == 'A':
                    # Lazy: Only determine external IPv4 if an A record shall be updated.
                    try:
                        destination = external_ipv4()
                    except UnknownIPException as e:
                        print_err(str(e))
                        exit(1)
                elif type_ == 'AAAA':
                    # Lazy: Only determine external IPv6 if an AAAA record shall be updated.
                    try:
                        destination = external_ipv6()
                    except UnknownIPException as e:
                        print_err(str(e))
                        exit(1)
                else:
                    raise ValueError(f'DNS record type {type_} is not supported.')

                if update_record_destination(api, domain, hostname, type_, destination, cache):
                    print_info(f'Set {hostname}.{domain} {type_} record to {destination}')
                else:
                    print_info(f'The {hostname}.{domain} {type_} record points already to {destination}')


def parse_args():
    parser = argparse.ArgumentParser(prog='netcup-dns',
                                     description='Update DNS A/AAAA records with your current external IP address'
                                                 ' using the netcup DNS API.')
    parser.add_argument('--config-directory',
                        help='Path to directory where `.json` config files reside.',
                        dest='cfg_dir',
                        default=Path('/etc/netcup-dns'),
                        type=Path)
    parser.add_argument('--cache-directory',
                        help='Path to cache directory. Retrieved and updated DNS records are cached there.',
                        dest='cache_dir',
                        default=Path.home().joinpath('.netcup-dns/cache'),
                        type=Path)
    parser.add_argument('--cache-validity-seconds',
                        help='Value in seconds for how long cached DNS records are valid.'
                             ' Set to `0` to disable caching.',
                        dest='cache_validity_seconds',
                        default=86400,
                        type=int)
    args = parser.parse_args()

    if not args.cfg_dir.exists():
        raise Exception(f'The given config directory does not exist: {args.cfg_dir}')
    if args.cache_validity_seconds < 0:
        raise Exception(f'A negative cache validity duration is not allowed: {args.cache_validity_seconds}')

    if args.cache_validity_seconds == 0:
        # Disable caching.
        args.cache = None
    else:
        args.cache = RecordDestinationCache(args.cache_dir, args.cache_validity_seconds)

    return args


def update_record_destination(api: Client, domain: str, hostname: str, type_: str, destination: str,
                              cache: RecordDestinationCache = None) -> bool:
    """
    Updates the `destination` of the DNS record identified by `domain`, `hostname` and `type`.

    :param api:
    :param domain:
    :param hostname:
    :param type_:
    :param destination:
    :param cache:
    :return: True if `destination` differs from the old destination.
    """
    # If caching is enabled.
    if cache is not None:
        # If a valid cache entry is available, check if the destination is still the same.
        # If this is the case, we do not need to do anything else.
        if cache.get(domain, hostname, type_) == destination:
            return False

    try:
        record = get_record(api, domain, hostname, type_)
    except Exception as e:
        raise Exception(f'Could not get DNS record for domain {domain}, hostname {hostname} and type {type_}') from e

    if record.destination == destination:
        # The new destination is identical with the current one.
        # Thus, we don't need to call the api.update_dns_record() method.
        cache.set(domain, hostname, type_, destination)
        cache.write_to_file()
        return False
    else:
        record.destination = destination
        api.update_dns_record(domain, record)
        # After a successful API call, update the cached destination.
        cache.set(domain, hostname, type_, destination)
        cache.write_to_file()
        return True


def get_record(api: Client, domain: str, hostname: str, type_: str) -> DNSRecord:
    """
    :param api:
    :param domain:
    :param hostname:
    :param type_:
    :return: The DNS record identified by `domain`, `hostname` and `type`.
    :raises MultipleRecordsException:
    """
    records: list[DNSRecord] = api.dns_records(domain)
    record: DNSRecord

    matches = [record for record in records if record.hostname == hostname and record.type == type_]
    if len(matches) != 1:
        raise MultipleRecordsException(f'Expected one DNSRecord for {hostname}.{domain}, but got {len(matches)}')
    return matches[0]


def ipv4_endpoints() -> list[str]:
    """
    :return: List of services that return your external IPv4 address.
    """
    # IPv4 only.
    endpoints = [
        'https://checkipv4.dedyn.io',
        'https://api.ipify.org',
        'https://v4.ident.me/',
    ]
    # Not sure if they return IPv4 addresses only.
    endpoints += ['https://ipinfo.io/ip', 'https://ifconfig.me/ip']
    return endpoints


def ipv6_endpoints() -> list[str]:
    """
    :return: List of services that return your external IPv6 address.
    """
    # IPv6 only.
    endpoints = [
        'https://checkipv6.dedyn.io',
        'https://api6.ipify.org',
        'https://v6.ident.me/',
        'https://ipv6.icanhazip.com',
    ]
    # Returns either IPv4 or IPv6.
    endpoints += ['https://ifconfig.co/ip']
    # Not sure if they return IPv6.
    endpoints += ['https://ipinfo.io/ip']

    # https://ifconfig.co
    # - if it detects `curl`, only the IP is returned
    # - but on a normal request, a HTML website is given
    # https://ifconfig.co/ip
    # - returns only the ip
    # - `curl -4/-6 https://ifconfig.co/ip` -> returns IPv4/IPv6

    return endpoints


def external_ip(version: Type[ipaddress.IPv4Address | ipaddress.IPv6Address], timeout: float = 5,
                endpoints: list[str] = None) -> str:
    """
    :param version: Weather the public IPv4 or IPv6 address shall be determined.
    :param endpoints: List of webservices that return ones public IP IPv4/IPv6 address.
    :argument timeout: Timeout for each webservice in seconds.
    :return: Public IPv4/IPv6 address.
    :raises UnknownIPException:
    """
    if endpoints is None:
        if version == ipaddress.IPv4Address:
            endpoints = ipv4_endpoints()
        elif version == ipaddress.IPv6Address:
            endpoints = ipv6_endpoints()
        else:
            raise ValueError(f'Invalid argument: {version}')

    if len(endpoints) == 0:
        raise ValueError(f'Invalid argument: {endpoints}')

    for endpoint in endpoints:
        try:
            # Timeout after 5 seconds.
            ip = requests.get(endpoint, timeout=timeout).text.strip()
        except requests.exceptions.RequestException:
            continue

        try:
            # Try to parse the IP address.
            parsed_ip = ipaddress.ip_address(ip)
        except ValueError:
            continue

        # Check if it is actually an IPv4/IPv6 address.
        if not isinstance(parsed_ip, version):
            continue

        # Return IP address as string.
        return parsed_ip.exploded

    raise UnknownIPException('Could not determine public IP address.')


@lru_cache(maxsize=None)
def external_ipv4(timeout: float = 5, endpoints: list[str] = None) -> str:
    """
    :param endpoints: List of webservices that return ones public IPv4 address.
    :argument timeout: Timeout for each webservice in seconds.
    :return: Public IPv4 address.
    :raises UnknownIPException:
    """
    return external_ip(ipaddress.IPv4Address, timeout, endpoints)


@lru_cache(maxsize=None)
def external_ipv6(timeout: float = 5, endpoints: list[str] = None) -> str:
    """
    :param endpoints: List of webservices that return ones public IPv6 address.
    :argument timeout: Timeout for each webservice in seconds.
    :return: Public IPv6 address.
    :raises UnknownIPException:
    """
    return external_ip(ipaddress.IPv6Address, timeout, endpoints)


if __name__ == '__main__':
    main()
