import os
from dataclasses import dataclass
from functools import singledispatchmethod
from pathlib import Path
from typing import List, Optional, Union

import qrcode
from qrcode.image.styledpil import StyledPilImage
from qrcode.image.styles.colormasks import RadialGradiantColorMask
from qrcode.image.styles.moduledrawers.pil import RoundedModuleDrawer

from fugue.config import Config
from fugue.lib import utils
from fugue.lib.hub import Hub
from fugue.lib.interface import Interface
from fugue.lib.peer import Peer
from fugue.lib.spoke import Spoke
from fugue.lib.tunnel import Tunnel


@dataclass
class FS:
    """
    Interacts with the filesystem, e.g. writes, removes files & directories.
    """

    config: Config

    @singledispatchmethod
    def get_conf_fp(self, obj) -> Path:
        raise RuntimeError

    @get_conf_fp.register
    def _(self, obj: Peer) -> Path:
        if not obj.name:
            raise RuntimeError(f"Peer with name: '{obj.name}' not found")
        return Path(self.config.FUGUE_PEERS_ROOT, f"{obj.name}.conf")

    @get_conf_fp.register
    def _(self, obj: Interface) -> Path:
        if not obj.name:
            raise RuntimeError(f"Interface with name: '{obj.name}' not found")
        return Path(self.config.FUGUE_INTERFACES_ROOT, f"{obj.name}.conf")

    @get_conf_fp.register
    def _(self, obj: Tunnel) -> Path:
        return Path(self.config.FUGUE_TUNNELS_ROOT, f"{obj.name}.conf")

    @get_conf_fp.register
    def _(self, obj: Hub) -> Path:
        """
        Returns a Hub's configuration directory, e.g. /etc/wireguard/hubs/<name>.d/
        """
        return Path(self.config.FUGUE_HUBS_ROOT, f"{obj.name}.d")

    @singledispatchmethod
    def write(self, obj) -> Path:
        raise NotImplementedError(f"error: unrecognised argument: '{obj}'")

    @write.register
    def _(self, obj: Union[Peer, Interface, Tunnel]) -> Path:
        fp_conf: Path = self.get_conf_fp(obj)
        utils.write_protected(fp_conf, obj.as_ini())
        return fp_conf

    @write.register
    def _(self, obj: Hub) -> Path:
        """
        Writes all of the Hub's objects to filesystem. This includes:
        * The Tunnel conf file
        * The Interface (hidden) conf file
        * The Peer (hidden) conf file
        * The Hub's symlinks to the Tunnel and As Peer configuration files
        """
        # writes tunnel, interface and peer objects under configured path
        _ = self.write(obj.tunnel.interface)
        for peer in obj.tunnel.peers:
            _ = self.write(peer)
        fp_conf_tunnel: Path = self.write(obj.tunnel)
        fp_conf_as_peer: Path = self.write(obj.as_peer)
        # A Hub directory, including its spokes, only contains references
        # (symlinks) to actual peer, interface, or tunnel objects.
        hub_dir_conf: Path = self.get_conf_fp(obj)
        symlink_tunnel: Path = Path(
            self.config.FUGUE_HUBS_ROOT, f"{obj.name}.conf"
        )
        symlink_as_peer: Path = Path(hub_dir_conf, "as_peer.conf")
        utils.makedir_protected(hub_dir_conf)
        os.symlink(
            fp_conf_tunnel,  # target
            symlink_tunnel,  # source
        )
        os.symlink(
            fp_conf_as_peer,  # target
            symlink_as_peer,  # source
        )
        return hub_dir_conf

    @write.register
    def _(self, obj: Spoke) -> Path:
        _ = self.write(obj.tunnel.interface)
        fp_conf_as_peer: Path = self.write(obj.as_peer)
        fp_conf_tunnel: Path = self.write(obj.tunnel)
        # TODO: is the hub already persisted? Do we need a Hub.is_persisted
        # method? If it is, the spoke's tunnel's peer should already be
        # persisted (as it's the `as_peer` peer of the Hub) If the spoke gets
        # added a peer (for some reason I'm not too sure of yet). Something
        # like that maybe?
        # for peer in self.tunnel.peers:
        #     if not peer.is_persisted:
        #         peer.write()
        # make spoke config directory
        hub_dir_conf: Path = self.get_conf_fp(obj.hub)
        hub_dir_spokes: Path = Path(hub_dir_conf, "spokes")
        spoke_dir: Path = Path(hub_dir_spokes, f"{obj.name}.d")
        utils.makedir_protected(spoke_dir)
        # symlink tunnel and as peer config files
        symlink_tunnel_src: Path = Path(spoke_dir, "tunnel.conf")
        symlink_as_peer_src: Path = Path(spoke_dir, "as_peer.conf")
        os.symlink(fp_conf_tunnel, symlink_tunnel_src)
        os.symlink(fp_conf_as_peer, symlink_as_peer_src)
        # make QR code for phones
        fp_tunnel_png: Path = Path(spoke_dir, "tunnel.conf.png")
        qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
        qr.add_data(obj.tunnel.as_ini())
        qr.make(fit=True)
        # we add some style! https://github.com/lincolnloop/python-qrcode, I like RoundedModuleDrawer()
        # NOTE: adding style adds about 1 second latency to the peer creation process, but it's kinda pretty
        img = qr.make_image(
            back_color=(39, 119, 255),
            fill_color=(248, 249, 251),
            image_factory=StyledPilImage,
            module_drawer=RoundedModuleDrawer(),
            color_mask=RadialGradiantColorMask(),
        )
        img.save(fp_tunnel_png)
        return spoke_dir

    @singledispatchmethod
    def rm(self, obj):
        raise NotImplementedError

    @rm.register
    def _(self, obj: Peer):
        fp_conf: Path = Path(self.config.FUGUE_PEERS_ROOT, f"{obj.name}.conf")
        if not fp_conf.is_file():
            raise RuntimeError(
                f"error: peer with name: {obj.name} not found at expected: {fp_conf}"
            )
        fp_conf.unlink()
        utils.rm_dir_if_empty(self.config.FUGUE_PEERS_ROOT)

    @rm.register
    def _(self, obj: Interface):
        fp_conf: Path = self.get_conf_fp(obj)
        if not fp_conf.is_file():
            raise RuntimeError(
                f"error: interface with name: '{obj.name}' not found at expected: '{fp_conf}'"
            )
        fp_conf.unlink()
        utils.rm_dir_if_empty(self.config.FUGUE_INTERFACES_ROOT)

    @rm.register
    def _(self, obj: Tunnel):
        fp_conf: Path = self.get_conf_fp(obj)
        if not fp_conf.is_file():
            raise RuntimeError(
                f"error: tunnel with name: '{obj.name}' not found at expected: '{fp_conf}'"
            )
        fp_conf.unlink()
        utils.rm_dir_if_empty(self.config.FUGUE_TUNNELS_ROOT)

    @rm.register
    def _(self, obj: Hub):
        """
        Removes the current Hub's objects from the filesystem.
        """
        # TODO: gather errors
        # TODO: is this working? do I wanna write a test for it?
        warnings: List = []
        # remove tunnel's interface
        try:
            self.rm(obj.tunnel.interface)
        except Exception as e:
            warnings.append(e)
        # remove tunnel
        try:
            self.rm(obj.tunnel)
        except Exception as e:
            warnings.append(e)
        # remove as_peer
        try:
            self.rm(obj.as_peer)
        except Exception as e:
            warnings.append(e)
        # remove directory
        hub_dir_conf: Path = self.get_conf_fp(obj)
        try:
            utils.rm_tree(hub_dir_conf)
        except Exception as e:
            warnings.append(e)
        # remove hub directory if empty
        try:
            utils.rm_dir_if_empty(self.config.FUGUE_HUBS_ROOT)
        except Exception as e:
            warnings.append(e)
        if warnings:
            print(f"warning: detected errors when removing hub: '{obj.name}'")
            for warn in warnings:
                print(warn)

    # def get_objs() -> List[Obj]
    def get_peers(self) -> List[Peer]:
        peers: List[Peer] = []
        conf_fps: List[Path] = [
            Path(self.config.FUGUE_PEERS_ROOT, child)
            for child in self.config.FUGUE_PEERS_ROOT.iterdir()
            if child.exists() and child.absolute().name.endswith(".conf")
        ]
        for conf_fp in conf_fps:
            # TODO: we should try/catch here and report on malformed conf files
            peers.append(Peer.from_conf(conf_fp))
        return peers

    def get_interfaces(self) -> List[Interface]:
        interfaces: List[Interface] = []
        conf_fps: List[Path] = [
            child
            for child in self.config.FUGUE_INTERFACES_ROOT.iterdir()
            if child.exists() and child.absolute().name.endswith(".conf")
        ]
        for conf_fp in conf_fps:
            # TODO: we should try/catch here and report on malformed conf files
            interfaces.append(Interface.from_conf(conf_fp))
        return interfaces

    def get_tunnels(self) -> List[Tunnel]:
        tunnels: List[Tunnel] = []
        conf_fps: List[Path] = [
            Path(self.config.FUGUE_TUNNELS_ROOT, child)
            for child in os.listdir(self.config.FUGUE_TUNNELS_ROOT)
            if Path(self.config.FUGUE_TUNNELS_ROOT, child)
            and child.endswith(".conf")
        ]
        for conf_fp in conf_fps:
            # TODO: we should try/catch here and report on malformed conf files
            tunnels.append(Tunnel.from_conf(conf_fp))
        return tunnels

    def get_hubs(self) -> List[Hub]:
        """
        Returns the exhaustive list of Hubs from the filesystem.
        """
        hubs: List[Hub] = []
        hubs_dirs: List[Path] = [
            child
            for child in self.config.FUGUE_HUBS_ROOT.iterdir()
            if child and child.name.endswith(".d") and child.is_dir()
        ]
        name: str
        symlink_tunnel: Path
        symlink_as_peer: Path
        tunnel: Tunnel
        as_peer: Peer
        # TODO: detect some state discrepancies, somewhere, somehow
        for hub_dir in hubs_dirs:
            name = hub_dir.name.removesuffix(".d")
            # TODO: is the tunnel file missing?
            symlink_tunnel = Path(self.config.FUGUE_HUBS_ROOT, f"{name}.conf")
            # TODO: is the as_peer file missing?
            symlink_as_peer = Path(hub_dir, "as_peer.conf")
            # TODO: are the links broken?
            tunnel = Tunnel.from_conf(symlink_tunnel)
            as_peer = Peer.from_conf(symlink_as_peer)
            hubs.append(Hub(name, tunnel, as_peer))
        return hubs

    def get_spokes(self) -> List[Spoke]:
        hubs: List[Hub] = self.get_hubs()
        spokes: List[Spoke] = []
        hub_spokes: Optional[List[Spoke]]
        for hub in hubs:
            hub_spokes = self.get_spokes_for_hub(hub)
            if hub_spokes:
                spokes.extend(hub_spokes)
        return spokes

    def get_spokes_for_hub(self, hub: Hub) -> Optional[List[Spoke]]:
        spokes: List[Spoke] = []
        tunnel: Tunnel
        as_peer: Peer
        name: str
        hub_dir_spokes: Path = Path(self.get_conf_fp(hub), "spokes")
        if not hub_dir_spokes.exists():
            return None
        if not hub_dir_spokes.is_dir():
            raise RuntimeError(f"'{hub_dir_spokes}' should be a directory.")
        spokes_dirs = [
            spoke_dir
            for spoke_dir in hub_dir_spokes.iterdir()
            if (spoke_dir.is_dir() and spoke_dir.absolute().name.endswith(".d"))
        ]
        for spoke_dir in spokes_dirs:
            # remove <.d> extension from the name
            name = spoke_dir.with_suffix("").name
            # TODO: tunnel should be optional for a spoke
            tunnel = Tunnel.from_conf(Path(spoke_dir, "tunnel.conf"))
            as_peer = Peer.from_conf(Path(spoke_dir, "as_peer.conf"))
            spokes.append(Spoke(name, tunnel, as_peer, hub))
        return spokes or None

    # def get_obj_from_name(self, name: str) -> Obj
    def get_peer_from_name(self, name: str) -> Peer:
        peers: List[Peer] = self.get_peers()
        matching_peers: List[Peer] = [p for p in peers if p.name == name]
        if len(matching_peers) == 0:
            raise RuntimeError(f"error: peer with name: {name} not found")
        elif len(matching_peers) > 1:
            raise RuntimeError(
                f"error: found multiple peers with name: {name}: {matching_peers}"
            )
        return matching_peers[0]

    def get_interface_from_name(self, name: str) -> Interface:
        interfaces: List[Interface] = self.get_interfaces()
        matching_interfaces: List[Interface] = [
            p for p in interfaces if p.name == name
        ]
        if len(matching_interfaces) == 0:
            raise RuntimeError(f"error: interface with name: {name} not found.")

        elif len(matching_interfaces) > 1:
            raise RuntimeError(
                f"error: found multiple interfaces with name: {name}: {matching_interfaces}"
            )
        return matching_interfaces[0]

    def get_tunnel_from_name(self, name: str) -> Tunnel:
        tunnels: List[Tunnel] = self.get_tunnels()
        # TODO: add checks, like for interfaces and peers
        return [tunnel for tunnel in tunnels if tunnel.name == name].pop()

    def get_hub_from_name(self, name: str) -> Hub:
        hubs: List[Hub] = self.get_hubs()
        matching_hubs: List[Hub] = [h for h in hubs if h.name == name]
        if len(matching_hubs) == 0:
            raise RuntimeError("error: Hub with name: '{name}' not found.")
        elif len(matching_hubs) > 1:
            raise RuntimeError(
                "error: found multiple hubs with name: '{name}': {matching_hubs}"
            )
        return matching_hubs[0]
