import ipaddress
import logging
import os
import time

import click
import netaddr

from collections import defaultdict
from dataclasses import dataclass, field, InitVar
from typing import Dict, List, Optional

from switch_autoconfig_helper import SwitchAutoconfigHelper, render_tftp_file, render_tftp_server

import jinja2
import pprint

def write_header(file):
    """
    Write out generic header for files generated by this script.
    """
    file.write(f"""\
#
# generated from EMF Netbox
# {time.strftime("%Y-%m-%d %H:%M:%S")}
#

""")


def get_scope_ipv4(prefix):
    """
    Get template parameters for an IPv4 prefix's dhcpd scope.
    """
    network = ipaddress.IPv4Network(prefix.prefix)

    reserved = prefix.custom_fields.get('dhcp_reserved', 10)
    pool_start = network.network_address + 1 + reserved
    pool_end = network.broadcast_address - 1

    params = {
        'prefix': prefix,
        'network': network,
        'gateway': network.network_address + 1,  # TODO look up the SVI
        'pool_start': pool_start,
        'pool_end': pool_end
    }
    return params


def get_scope_ipv6(prefix):
    """
    Get template parameters for an IPv6 prefix's dhcpd scope.
    """
    network = ipaddress.IPv6Network(prefix.prefix)

    reserved = prefix.dhcp_reserved if 'dhcp_reserved' in prefix else 10
    pool_start = network.network_address + 1 + reserved
    pool_end = network.network_address + 0xfeff

    params = {
        'prefix': prefix,
        'network': network,
        'gateway': network.network_address + 0xff00,  # TODO look up the SVI
        'pool_start': pool_start,
        'pool_end': pool_end
    }
    return params


@dataclass
class Reservation:
    """
    Template data for a single DHCP reservation.

    Values must be given in the specified types for direct insertion into the
    template, i.e. str values must generally be valid dhcpd.conf(5) syntax.
    """
    name: str
    mac: str
    ip: ipaddress.IPv4Address  # pylint: disable=invalid-name
    section_name: str = ''
    config_lines: List[str] = field(default_factory=list)

    def __post_init__(self):
        if not self.section_name:
            self.section_name = self.name


@dataclass
class DhcpGroup:
    """
    Template data for a DHCP group, containing some Reservations.

    Values must be given in the specified types for direct insertion into the
    template, i.e. str values must generally be valid dhcpd.conf(5) syntax.
    """
    reservations: List[Reservation]
    config_lines: List[str] = field(default_factory=list)


class DhcpGenerator:
    """
    dhcpd config generator for EMF 2022.

    render_*() methods should be called as required to write out different
    files, each corresponding to a certain concern of the DHCP servers (e.g.
    basic per-prefix scopes, network switch autoconfig/ZTP config).
    """

    def __init__(self, config, helper, outdir):
        self.config = config
        self.helper = helper
        self.outdir = outdir

        self.logger = logging.getLogger(__name__)

        loader = jinja2.FileSystemLoader(
            os.path.join(os.path.dirname(__file__), 'templates')
        )
        self.jinja = jinja2.Environment(loader=loader)

        if not os.path.exists(outdir):
            os.mkdir(outdir)

    def _outpath(self, filename):
        """
        Resolve output file path for a given filename.
        """
        return os.path.join(self.outdir, filename)

    def render_subnets(self):
        """
        Render and write out basic dhcpd scope config for each subnet.
        """
        prefixes = self.helper.netbox.ipam.prefixes.filter(cf_dhcp=True)

        configfile4 = self._outpath("dhcpd-subnets.conf")
        configfile6 = self._outpath("dhcpd6-subnets.conf")

        template = self.jinja.get_template('dhcp_scope.j2')
        dhcp = self.config['dhcpd']

        with open(configfile4, 'w') as f4,  open(configfile6, 'w') as f6:
            write_header(f4)
            write_header(f6)

            with click.progressbar(prefixes, label='Scopes',
                                item_show_func=lambda item: item['prefix'] if item else None) as bar:
                for prefix in bar:
                    if prefix.family.value == 4:
                        params = get_scope_ipv4(prefix)
                        params['dns'] = dhcp['dns_ipv4']
                        file = f4
                    elif prefix.family.value == 6:
                        params = get_scope_ipv6(prefix)
                        params['dns'] = dhcp['dns_ipv6']
                        params['sntp'] = dhcp['sntp_ipv6']
                        file = f6

                    params['domain'] = (
                        dhcp['domain_campers']
                        if 'Camper-' in prefix.description
                        else dhcp['domain_orga']
                    )

                    conf = template.render(params)
                    file.write(conf)

    def _build_generic_lease_group(self, existing_ips_by_id):
        all_reserved_ips = list(
            self.helper.netbox.ipam.ip_addresses.all()
        )
        all_reserved_ips = filter(lambda ip: ip.custom_fields['dhcp_mac'] is not None,all_reserved_ips)
        generic_reserved_ips = filter(
            lambda ip: ip.id not in existing_ips_by_id.keys(),
            all_reserved_ips
        )
        reservations = []
        for ip in generic_reserved_ips:
            if not ip.dns_name:
                continue
            if ip.family == 6:
                res = Reservation(
                    name=ip.dns_name,
                    mac=ip.custom_fields['dhcp_mac'],
                    ip=str(ipaddress.IPv6Interface(ip.address).ip),
                )
            else:
                res = Reservation(
                    name=ip.dns_name,
                    mac=ip.custom_fields['dhcp_mac'],
                    ip=str(ipaddress.IPv4Interface(ip.address).ip),
                )
            reservations.append(res)

        group = DhcpGroup(
            reservations=reservations,
        )
        return group

    def render_switch_autoconfig(self):
        """
        Render and write out files to configure switch autoconfig/ZTP support.
        """

        cf = SwitchAutoconfigHelper.load()

        vlan_names = cf.scope['vlans']
        if not vlan_names:
            raise ValueError("No VLAN names specified in scope!")
        relevant_vlans = list(self.helper.netbox.ipam.vlans.filter(name=vlan_names))
        vlan_ids = [vlan.vid for vlan in relevant_vlans]
        relevant_prefixes = list(
            self.helper.netbox.ipam.prefixes.filter(family_value=4, vlan_vid=vlan_ids)
        )

        networks = [ipaddress.IPv4Network(prefix.prefix) for prefix in relevant_prefixes]

        role_slugs = cf.scope['roles']
        if not role_slugs:
            raise ValueError("No device roles specified in scope!")
        devices_matching_roles = list(
            self.helper.netbox.dcim.devices.filter(role=role_slugs)
        )

        # iterate over hosts, check if primary ip in our list of prefixes

        relevant_devices = []

        for device in devices_matching_roles:
            if prip4 := device.primary_ip4:
                if any(
                    ipaddress.IPv4Interface(prip4) in ipaddress.IPv4Network(prefix)
                    for prefix in relevant_prefixes
                ):
                    relevant_devices.append(device)

        # Grab all the IP address objects we are interested in, as the
        # device.primary_ip4 view only gives the basic details (and not the
        # 'dhcp_mac' custom field we will need).
        full_ip_info = dict()
        for result in (
            self.helper.netbox.ipam.ip_addresses.filter(id=[
                device.primary_ip4.id for device in relevant_devices
            ])
        ):
            full_ip_info[result.id] = result

        manufacturer_dhcp_reservations = defaultdict(list)

        def build_config_lines(full_config, overrides_only):
            extra = []

            if vspace := overrides_only.get('vendor-space'):
                extra.append(f"vendor-option-space {vspace}")
            if methods := overrides_only.get('tftp-server-methods'):
                for method in methods:
                    extra.append(
                        render_tftp_server(method, cf.network_info('tftp-servers'))
                    )
            if 'filename' in overrides_only:
                for method in full_config.get('tftp-config-methods'):
                    extra.append(
                        render_tftp_file(method, full_config['filename'])
                    )

            return extra

        with click.progressbar(
            relevant_devices, label='Switches', item_show_func=lambda item: item['name'] if item else None
        ) as bar:
            for device in bar:
                ip_addr = full_ip_info[device.primary_ip4.id]

                netbox_dhcp_mac = ip_addr.custom_fields.get('dhcp_mac')

                if not netbox_dhcp_mac:
                    self.logger.error(
                        "Device %s primary IPv4 address has no dhcp_mac!  Ignoring the device.",
                        device
                    )
                    continue

                try:
                    dhcp_mac = netaddr.EUI(netbox_dhcp_mac)
                except netaddr.core.AddrFormatError:
                    self.logger.error(
                        "dhcp_mac value '%s' for device %s could not be parsed!  Ignoring the device.",
                        netbox_dhcp_mac, device
                    )
                    continue

                manuf_slug = device.device_type.manufacturer.slug

                device_config = cf.for_nbh_device(device)
                device_overrides = cf.overrides_for_nbh_device(device)

                mac_with_offset = netaddr.EUI(dhcp_mac.value + device_config.get('mac-offset', 0))

                res = Reservation(
                    name=device.name,
                    mac=mac_with_offset.format(netaddr.mac_unix_expanded),
                    ip=str(ipaddress.IPv4Interface(ip_addr).ip),
                    config_lines=build_config_lines(device_config, device_overrides),
                )
                manufacturer_dhcp_reservations[manuf_slug].append(res)

        groups = []
        for manuf_slug, reservations in manufacturer_dhcp_reservations.items():
            manuf_config = cf.for_manufacturer(manuf_slug)
            extra = build_config_lines(manuf_config, manuf_config)
            extra.append(f"option domain-name {cf.network_info('domain-name')}")
            manuf_group = DhcpGroup(
                reservations=reservations,
                config_lines=extra,
            )
            groups.append(manuf_group)

        groups.append(self._build_generic_lease_group(full_ip_info))

        template = self.jinja.get_template('dhcp_group.j2')

        with open(self._outpath("dhcp-reservations.conf"), 'w') as outfile:
            outfile.write(template.render(groups=groups))
