#!/usr/bin/python3
# Copyright 2011-2025 The Wazo Authors  (see the AUTHORS file)
# SPDX-License-Identifier: GPL-3.0-or-later

"""Small utility to download and update the configuration files of the DHCP
server installed on a Wazo, so that any phone that is supported by one
of the wazo-* provd plugins is able to boot correctly using the DHCP server
installed on a Wazo.

"""
from __future__ import annotations

import contextlib
import os
import sys
import tarfile
import tempfile
from argparse import ArgumentParser
from configparser import RawConfigParser
from typing import Any, BinaryIO
from urllib.parse import urljoin
from urllib.request import OpenerDirector, ProxyHandler, build_opener

PKG_FILENAME = 'dhcpd.tar.bz2'
DHCPD_UPDATE_FILENAME = 'dhcpd_update.conf'


def _build_opener(proxies: dict[str, Any] | None) -> OpenerDirector:
    return build_opener(ProxyHandler(proxies))


def _extract_pkg_file(fobj: BinaryIO, dhcpd_dir: str) -> None:
    with contextlib.closing(tarfile.open(fileobj=fobj)) as tf:
        tf.extractall(dhcpd_dir)


def download(url: str, dhcpd_dir: str, proxies: dict[str, Any] | None = None):
    # Download the package at url and extract its content into dhcpd_dir.
    opener = _build_opener(proxies)
    with tempfile.TemporaryFile() as fobj:
        with contextlib.closing(opener.open(url)) as url_fobj:
            fobj.write(url_fobj.read())
        fobj.seek(0)
        _extract_pkg_file(fobj, dhcpd_dir)


def regenerate(dhcpd_dir: str, subnet_file: str, ignore_missing: bool) -> None:
    # Regenerate DHCP server configuration files.
    file = os.path.join(dhcpd_dir, subnet_file)
    with open(file, 'w') as fobj:

        def _concat_file(suffix):
            cur_file = os.path.join(dhcpd_dir, subnet_file + suffix)
            with open(cur_file) as cur_fobj:
                fobj.write(cur_fobj.read())

        fobj.write('# This file has been automatically generated by dhcpd-update.\n')
        _concat_file('.head')
        try:
            _concat_file('.middle')
        except OSError:
            if not ignore_missing:
                raise
        _concat_file('.tail')


def new_empty_dhcpd_update_file(dhcpd_dir: str) -> None:
    file = os.path.join(dhcpd_dir, DHCPD_UPDATE_FILENAME)
    with contextlib.suppress(OSError):
        fd = os.open(file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 644)
        os.close(fd)


def _read_config_from_default(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
    return {
        'general': {
            'config_file': '/etc/xivo/dhcpd-update.conf',
            'update_url': 'http://provd.wazo.community/xivo/dhcpd-update/13.17/',
            'dhcpd_dir': '/etc/dhcp/',
            'subnet_file': 'dhcpd_subnet.conf',
        },
    }


def _read_config_from_commandline(config):
    parser = ArgumentParser()
    parser.add_argument('-n', '--newempty', action='store_true')
    parser.add_argument('-d', '--download', action='store_true')
    parser.add_argument('-r', '--regenerate', action='store_true')
    parser.add_argument('-i', '--ignoremissing', action='store_true')
    parser.add_argument('-F', '--configfile', action='store')

    opts, args = parser.parse_known_args()
    result: dict[str, Any] = {'general': {}}
    if opts.newempty:
        result['general']['new_empty'] = 'yes'
    if opts.download:
        result['general']['download'] = 'yes'
    if opts.regenerate:
        result['general']['regenerate'] = 'yes'
    if opts.ignoremissing:
        result['general']['ignore_missing'] = 'yes'
    if opts.configfile:
        result['general']['config_file'] = opts.configfile
    return result


def _read_config_from_file(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
    # read config file
    config_file = config['general']['config_file']
    config_parser = RawConfigParser()
    with open(config_file) as fobj:
        config_parser.read_file(fobj)

    # create config dictionary out of it
    return {
        section: dict(config_parser.items(section))
        for section in config_parser.sections()
    }


def _update_config(config: dict[str, Any], new_config: dict[str, Any]) -> None:
    for key in new_config:
        if key in config:
            config[key] |= new_config[key]
        else:
            config[key] = new_config[key]


def read_config() -> dict[str, Any]:
    # Read and return the configuration for this program.
    # Config is a dictionary of dictionary, with the following keys:
    #   general
    #     config_file -- path to this application configuration file
    #     update_url
    #     dhcpd_dir
    #     subnet_file
    #     ignore_missing
    #     new_empty -- 'yes' to create empty dhcpd update file
    #     download -- 'yes' to download, else won't download
    #     regenerate -- 'yes' to regenerate, else won't regenerate
    #   proxy
    #     http -- proxy for http
    config: dict[str, Any] = {'general': {}, 'proxy': {}}
    _update_config(config, _read_config_from_default(config))
    cli_config = _read_config_from_commandline(config)
    _update_config(config, cli_config)
    _update_config(config, _read_config_from_file(config))
    _update_config(config, cli_config)
    config['proxy'] = config['proxy'] or None  # empty dict means ignore all proxies
    return config


def _do_new_empty(config: dict[str, Any]) -> None:
    dhcpd_dir = config['general']['dhcpd_dir']
    new_empty_dhcpd_update_file(dhcpd_dir)


def _do_download(config: dict[str, Any]) -> None:
    url = urljoin(config['general']['update_url'], PKG_FILENAME)
    dhcpd_dir = config['general']['dhcpd_dir']
    proxies = config['proxy']
    download(url, dhcpd_dir, proxies)


def _do_regenerate(config: dict[str, Any]) -> None:
    dhcpd_dir = config['general']['dhcpd_dir']
    subnet_file = config['general']['subnet_file']
    ignore_missing = config['general'].get('ignore_missing') == 'yes'
    regenerate(dhcpd_dir, subnet_file, ignore_missing)


def main() -> None:
    config = read_config()
    general = config['general']

    if op_new_empty := general.get('new_empty') == 'yes':
        _do_new_empty(config)

    if op_download := (general.get('download') == 'yes'):
        _do_download(config)

    if op_regenerate := (general.get('regenerate') == 'yes'):
        _do_regenerate(config)

    if not (op_new_empty or op_download or op_regenerate):
        # Error: no operation specified
        print('error: no operation specified', file=sys.stderr)
        raise SystemExit(2)


if __name__ == '__main__':
    main()
