#!/usr/bin/env python3
#
# Copyright (c) 2024 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

"""
Pythonic declaration of mDNS .local domains for YunoHost
"""

import sys
from ipaddress import ip_address
from pathlib import Path
from time import sleep

import ifaddr
import yaml
from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf


def get_network_local_interfaces() -> dict[str, dict[str, list[str]]]:
    """
    Returns interfaces with their associated local IPs
    """

    return {
        adapter.name: {
            "ipv4": [
                str(ip.ip)
                for ip in adapter.ips
                if ip.is_IPv4
                and ip_address(ip.ip).is_private
                and not ip_address(ip.ip).is_link_local
            ],
            "ipv6": [
                ip.ip[0]
                for ip in adapter.ips
                if ip.is_IPv6
                and ip_address(ip.ip[0]).is_private
                and not ip_address(ip.ip[0]).is_link_local
            ],
        }
        for adapter in ifaddr.get_adapters()
        if adapter.name != "lo"
    }


# Listener class, to detect duplicates on the network
# Stores the list of servers in its list property
class Listener:
    def __init__(self) -> None:
        self.list: list[str] = []

    def remove_service(self, zeroconf: Zeroconf, type_: str, name: str) -> None:
        info = zeroconf.get_service_info(type_, name)
        self.list.remove(info.server)

    def update_service(self, zeroconf: Zeroconf, type_: str, name: str) -> None:
        pass

    def add_service(self, zeroconf: Zeroconf, type_: str, name: str) -> None:
        info = zeroconf.get_service_info(type_, name)
        self.list.append(info.server[:-1])


def main() -> bool:
    ###
    #  CONFIG
    ###

    with Path("/etc/yunohost/mdns.yml").open("r") as f:
        config = yaml.safe_load(f) or {}

    required_fields = ["domains"]
    missing_fields = [field for field in required_fields if field not in config]
    interfaces = get_network_local_interfaces()

    if missing_fields:
        print(f"The fields {missing_fields} are required in mdns.yml")
        return False

    if "interfaces" not in config:
        config["interfaces"] = [
            interface
            for interface, local_ips in interfaces.items()
            if local_ips["ipv4"]
        ]

    if "ban_interfaces" in config:
        config["interfaces"] = [
            interface
            for interface in config["interfaces"]
            if interface not in config["ban_interfaces"]
        ]

    # Let's discover currently published .local domains accross the network
    zc = Zeroconf()
    listener = Listener()
    browser = ServiceBrowser(zc, "_device-info._tcp.local.", listener)
    sleep(2)
    browser.cancel()
    zc.close()

    # Always attempt to publish yunohost.local
    if "yunohost.local" not in config["domains"]:
        config["domains"].append("yunohost.local")

    def find_domain_not_already_published(domain: str) -> str:

        # Try domain.local ... but if it's already published by another entity,
        # try domain-2.local, domain-3.local, ...

        i = 1
        domain_i = domain

        while domain_i in listener.list:
            print(f"Uh oh, {domain_i} already exists on the network...")

            i += 1
            domain_i = domain.replace(".local", f"-{i}.local")

        return domain_i

    config["domains"] = [
        find_domain_not_already_published(domain) for domain in config["domains"]
    ]

    zcs: dict[Zeroconf, list[ServiceInfo]] = {}

    for interface in config["interfaces"]:

        if interface not in interfaces:
            print(
                f"Interface {interface} listed in config file is not present on system."
            )
            continue

        # Broadcast IPv4 and IPv6
        ips: list[str] = interfaces[interface]["ipv4"] + interfaces[interface]["ipv6"]

        # If at least one IP is listed
        if not ips:
            continue

        # Create a Zeroconf object, and store the ServiceInfos
        zc = Zeroconf(interfaces=ips)
        zcs[zc] = []

        for d in config["domains"]:
            d_domain = d.replace(".local", "")
            if "." in d_domain:
                print(f"{d_domain}.local: subdomains are not supported.")
                continue
            # Create a ServiceInfo object for each .local domain
            zcs[zc].append(
                ServiceInfo(
                    type_="_device-info._tcp.local.",
                    name=f"{interface}: {d_domain}._device-info._tcp.local.",
                    parsed_addresses=ips,
                    port=80,
                    server=f"{d}.",
                )
            )
            print(f"Adding {d} with addresses {ips} on interface {interface}")

    # Run registration
    print("Registering...")
    for zc, infos in zcs.items():
        for info in infos:
            zc.register_service(
                info, allow_name_change=True, cooperating_responders=True
            )

    try:
        print("Registered. Press Ctrl+C or stop service to stop.")
        while True:
            sleep(1)
    except KeyboardInterrupt:
        pass
    finally:
        print("Unregistering...")
        for zc in zcs:
            zc.unregister_all_services()
            zc.close()

    return True


if __name__ == "__main__":
    sys.exit(0 if main() else 1)
