# P2P networking
# Copyright (C) 2019, 2020  Nguyễn Gia Phong
#
# This file is part of Axuy.
#
# Axuy 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.
#
# Axuy 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 Axuy.  If not, see <https://www.gnu.org/licenses/>.

__doc__ = 'Axuy peer'
__all__ = ['__version__', 'PeerConfig', 'Peer']
__version__ = '0.0.12'

from abc import ABC, abstractmethod
from argparse import ArgumentParser, FileType, RawTextHelpFormatter
from configparser import ConfigParser
from importlib.resources import open_text, read_text
from os.path import join as pathjoin, pathsep
from pickle import dumps, loads
from queue import Empty, Queue
from socket import SO_REUSEADDR, SOCK_DGRAM, SOL_SOCKET, socket
from sys import stdout
from threading import Thread
from typing import Iterator, Tuple

from appdirs import AppDirs

from .misc import mapgen, mapidgen
from .pico import Pico


class PeerConfig:
    """Networking configurations.

    Attributes
    ----------
    config : ConfigParser
        INI configuration file parser.
    options : ArgumentParser
        Command-line argument parser.
    host : str
        Host to bind the peer to.
    port : int
        Port to bind the peer to.
    seeder : str
        Address of the peer that created the map.
    """

    def __init__(self) -> None:
        dirs = AppDirs(appname='axuy', appauthor=False, multipath=True)
        parents = dirs.site_config_dir.split(pathsep)
        parents.append(dirs.user_config_dir)
        filenames = [pathjoin(parent, 'settings.ini') for parent in parents]

        # Parse configuration files
        self.config = ConfigParser()
        self.config.read_file(open_text('axuy', 'settings.ini'))
        self.config.read(filenames)
        self.fallback()

        # Parse command-line arguments
        self.options = ArgumentParser(usage='%(prog)s [options]',
                                      formatter_class=RawTextHelpFormatter)
        self.options.add_argument('-v', '--version', action='version',
                                  version=f'Axuy {__version__}')
        self.options.add_argument(
            '--write-config', nargs='?', const=stdout, type=FileType('w'),
            metavar='PATH', dest='cfgout',
            help='write default config to PATH (fallback: stdout) and exit')
        self.options.add_argument(
            '-c', '--config', metavar='PATH',
            help=('location of the configuration file'
                  f' (fallback: {pathsep.join(filenames)})'))
        self.options.add_argument(
            '--host',
            help=f'host to bind this peer to (fallback: {self.host})')
        self.options.add_argument(
            '-p', '--port', type=int,
            help=f'port to bind this peer to (fallback: {self.port})')
        self.options.add_argument(
            '-s', '--seeder', metavar='ADDRESS',
            help='address of the peer that created the map')

    def fallback(self) -> None:
        """Parse fallback configurations."""
        self.host = self.config.get('Peer', 'Host')
        self.port = self.config.getint('Peer', 'Port')

    # Fallback to None when attribute is missing
    def __getattr__(self, name): return None

    @property
    def seeder(self) -> Tuple[str, int]:
        """Seeder address."""
        return self._seed

    @seeder.setter
    def seeder(self, value: str) -> None:
        host, port = value.split(':')
        self._seed = host, int(port)

    def read(self, arguments):
        """Read and parse a argparse.ArgumentParser.Namespace."""
        for option in 'host', 'port', 'seeder':
            value = getattr(arguments, option)
            if value is not None: setattr(self, option, value)

    def parse(self) -> None:
        """Parse all configurations."""
        args = self.options.parse_args()
        if args.cfgout is not None:
            args.cfgout.write(read_text('axuy', 'settings.ini'))
            args.cfgout.close()
            exit()
        if args.config:     # is neither None nor empty
            self.config.read(args.config)
            self.fallback()
        self.read(args)


class Peer(ABC):
    """Axuy peer.

    Parameters
    ----------
    config : PeerConfig
        Networking configurations.

    Attributes
    ----------
    sock : socket
        UDP socket for exchanging instantaneous states with other peers.
    addr : Tuple[str, int]
        Own's address.
    q : Queue[Tuple[bytes, Tuple[str, int]]]
        Queue of (data, addr), where addr is the address of the peer
        who sent the raw data.
    peers : List[Tuple[str, int]]
        Addresses of connected peers.
    mapid : List[int]
        Permutation of map building blocks.
    space : numpy.ndarray of shape (12, 12, 9) of bools
        3D array of occupied space.
    pico : Pico
        Protagonist.
    picos : Dict[Tuple[str, int], Pico]
        All picos present in the map.
    last_time : float
        Timestamp of the previous update.
    """

    def __init__(self, config):
        self.sock = socket(type=SOCK_DGRAM)     # UDP
        self.sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
        self.sock.bind((config.host, config.port))
        self.addr = self.sock.getsockname()
        self.q = Queue()

        if config.seeder is None:
            self.mapid, self.peers = mapidgen(), []
        else:
            client = socket()
            client.connect(config.seeder)
            self.mapid, self.peers = loads(client.recv(1024))

        self.space = mapgen(self.mapid)
        self.pico = Pico(self.addr, self.space)
        self.picos = {self.addr: self.pico}
        self.last_time = self.get_time()

    def __enter__(self): return self

    @property
    @abstractmethod
    def is_running(self) -> bool:
        """Peer status."""

    @property
    def ready(self) -> Iterator[Tuple[bytes, Tuple[str, int]]]:
        """Iterator of (data, addr) that can be used without waiting,
        where addr is the address of the peer who sent the data.
        """
        while True:
            try:
                yield self.q.get_nowait()
            except Empty:
                break
            else:
                self.q.task_done()

    @property
    def fps(self) -> float:
        """Current loop rate."""
        return self.pico.fps

    @fps.setter
    def fps(self, fps: float) -> None:
        self.pico.fps = fps

    def serve(self) -> None:
        """Initiate other peers."""
        with socket() as server:    # TCP server
            server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
            server.bind(self.addr)
            server.listen(7)
            print('Axuy is listening at {}:{}'.format(*self.addr))
            while self.is_running:
                conn, addr = server.accept()
                conn.send(dumps((self.mapid, self.peers+[self.addr])))
                conn.close()
            server.close()

    def pull(self) -> None:
        """Receive other peers' states."""
        while self.is_running: self.q.put(self.sock.recvfrom(1 << 16))
        while not self.q.empty():
            self.q.get()
            self.q.task_done()

    @abstractmethod
    def get_time(self) -> float:
        """Return the current time in seconds."""

    def add_pico(self, address):
        """Add pico from given address."""
        self.picos[address] = Pico(address, self.space)

    def sync(self) -> None:
        """Synchronize states received from other peers."""
        for data, addr in self.ready:
            if addr not in self.picos:
                self.peers.append(addr)
                self.add_pico(addr)
            self.picos[addr].sync(*loads(data))

    def push(self) -> None:
        """Push states to other peers."""
        shards = {i: (s.pos, s.rot, s.power)
                  for i, s in self.pico.shards.items()}
        data = dumps([self.pico.health, self.pico.pos, self.pico.rot, shards])
        for peer in self.peers: self.sock.sendto(data, peer)

    @abstractmethod
    def control(self) -> None:
        """Control the protagonist."""
        self.pico.update()  # just a reminder that this needs to be called

    def update(self) -> None:
        """Update internal states and send them to other peers."""
        next_time = self.get_time()
        self.fps = 1 / (next_time-self.last_time)
        self.last_time = next_time

        self.sync()
        self.control()
        picos = list(self.picos.values())
        for pico in picos:
            shards = {}
            for index, shard in pico.shards.items():
                shard.update(self.fps, picos)
                if shard.power: shards[index] = shard
            pico.shards = shards
        self.push()

    def run(self) -> None:
        """Start main loop."""
        Thread(target=self.serve, daemon=True).start()
        Thread(target=self.pull, daemon=True).start()
        while self.is_running: self.update()

    def __exit__(self, exc_type, exc_value, traceback):
        self.q.join()
        self.sock.close()
