# -*- coding: utf-8 -*-
#
# Copyright (C) 2015 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import aiohttp
import asyncio
import tempfile
import zipfile
import aiofiles
import time

from gns3server.web.route import Route
from gns3server.controller import Controller
from gns3server.controller.import_project import import_project
from gns3server.controller.export_project import export_project
from gns3server.utils.asyncio import aiozipstream
from gns3server.utils.path import is_safe_path
from gns3server.config import Config


from gns3server.schemas.project import (
    PROJECT_OBJECT_SCHEMA,
    PROJECT_UPDATE_SCHEMA,
    PROJECT_LOAD_SCHEMA,
    PROJECT_CREATE_SCHEMA,
    PROJECT_DUPLICATE_SCHEMA
)

import logging
log = logging.getLogger()


async def process_websocket(ws):
    """
    Process ping / pong and close message
    """
    try:
        await ws.receive()
    except aiohttp.WSServerHandshakeError:
        pass

CHUNK_SIZE = 1024 * 8  # 8KB


class ProjectHandler:

    @Route.post(
        r"/projects",
        description="Create a new project on the server",
        status_codes={
            201: "Project created",
            409: "Project already created"
        },
        output=PROJECT_OBJECT_SCHEMA,
        input=PROJECT_CREATE_SCHEMA)
    async def create_project(request, response):

        controller = Controller.instance()
        project = await controller.add_project(**request.json)
        response.set_status(201)
        response.json(project)

    @Route.get(
        r"/projects",
        description="List projects",
        status_codes={
            200: "List of projects",
        })
    def list_projects(request, response):
        controller = Controller.instance()
        response.json([p for p in controller.projects.values()])

    @Route.get(
        r"/projects/{project_id}",
        description="Get a project",
        parameters={
            "project_id": "Project UUID",
        },
        status_codes={
            200: "Project information returned",
            404: "The project doesn't exist"
        })
    def get(request, response):
        controller = Controller.instance()
        project = controller.get_project(request.match_info["project_id"])
        response.json(project)

    @Route.put(
        r"/projects/{project_id}",
        status_codes={
            200: "Node updated",
            400: "Invalid request",
            404: "Instance doesn't exist"
        },
        description="Update a project instance",
        input=PROJECT_UPDATE_SCHEMA,
        output=PROJECT_OBJECT_SCHEMA)
    async def update(request, response):
        project = Controller.instance().get_project(request.match_info["project_id"])

        # Ignore these because we only use them when creating a project
        request.json.pop("project_id", None)

        await project.update(**request.json)
        response.set_status(200)
        response.json(project)

    @Route.delete(
        r"/projects/{project_id}",
        description="Delete a project from disk",
        parameters={
            "project_id": "Project UUID",
        },
        status_codes={
            204: "Changes have been written on disk",
            404: "The project doesn't exist"
        })
    async def delete(request, response):

        controller = Controller.instance()
        project = controller.get_project(request.match_info["project_id"])
        await project.delete()
        controller.remove_project(project)
        response.set_status(204)

    @Route.get(
        r"/projects/{project_id}/stats",
        description="Get a project statistics",
        parameters={
            "project_id": "Project UUID",
        },
        status_codes={
            200: "Project statistics returned",
            404: "The project doesn't exist"
        })
    def get(request, response):
        controller = Controller.instance()
        project = controller.get_project(request.match_info["project_id"])
        response.json(project.stats())

    @Route.post(
        r"/projects/{project_id}/close",
        description="Close a project",
        parameters={
            "project_id": "Project UUID",
        },
        status_codes={
            204: "The project has been closed",
            404: "The project doesn't exist"
        },
        output=PROJECT_OBJECT_SCHEMA)
    async def close(request, response):

        controller = Controller.instance()
        project = controller.get_project(request.match_info["project_id"])
        await project.close()
        response.set_status(204)

    @Route.post(
        r"/projects/{project_id}/open",
        description="Open a project",
        parameters={
            "project_id": "Project UUID",
        },
        status_codes={
            201: "The project has been opened",
            404: "The project doesn't exist"
        },
        output=PROJECT_OBJECT_SCHEMA)
    async def open(request, response):

        controller = Controller.instance()
        project = controller.get_project(request.match_info["project_id"])
        await project.open()
        response.set_status(201)
        response.json(project)

    @Route.post(
        r"/projects/load",
        description="Open a project (only local server)",
        parameters={
            "path": ".gns3 path",
        },
        status_codes={
            201: "The project has been opened",
            403: "The server is not the local server"
        },
        input=PROJECT_LOAD_SCHEMA,
        output=PROJECT_OBJECT_SCHEMA)
    async def load(request, response):

        controller = Controller.instance()
        config = Config.instance()
        dot_gns3_file = request.json.get("path")
        if config.get_section_config("Server").getboolean("local", False) is False:
            log.error("Cannot load '{}' because the server has not been started with the '--local' parameter".format(dot_gns3_file))
            response.set_status(403)
            return
        project = await controller.load_project(dot_gns3_file,)
        response.set_status(201)
        response.json(project)

    @Route.get(
        r"/projects/{project_id}/notifications",
        description="Receive notifications about projects",
        parameters={
            "project_id": "Project UUID",
        },
        status_codes={
            200: "End of stream",
            404: "The project doesn't exist"
        })
    async def notification(request, response):

        controller = Controller.instance()
        project = controller.get_project(request.match_info["project_id"])
        response.content_type = "application/json"
        response.set_status(200)
        response.enable_chunked_encoding()
        await response.prepare(request)
        log.info("New client has connected to the notification stream for project ID '{}' (HTTP long-polling method)".format(project.id))

        try:
            with controller.notification.project_queue(project.id) as queue:
                while True:
                    msg = await queue.get_json(5)
                    await response.write(("{}\n".format(msg)).encode("utf-8"))
        finally:
            log.info("Client has disconnected from notification for project ID '{}' (HTTP long-polling method)".format(project.id))
            if project.auto_close:
                # To avoid trouble with client connecting disconnecting we sleep few seconds before checking
                # if someone else is not connected
                await asyncio.sleep(5)
                if not controller.notification.project_has_listeners(project.id):
                    log.info("Project '{}' is automatically closing due to no client listening".format(project.id))
                    await project.close()


    @Route.get(
        r"/projects/{project_id}/notifications/ws",
        description="Receive notifications about projects from a Websocket",
        parameters={
            "project_id": "Project UUID",
        },
        status_codes={
            200: "End of stream",
            404: "The project doesn't exist"
        })
    async def notification_ws(request, response):

        controller = Controller.instance()
        project = controller.get_project(request.match_info["project_id"])
        ws = aiohttp.web.WebSocketResponse()
        await ws.prepare(request)

        request.app['websockets'].add(ws)
        asyncio.ensure_future(process_websocket(ws))
        log.info("New client has connected to the notification stream for project ID '{}' (WebSocket method)".format(project.id))
        try:
            with controller.notification.project_queue(project.id) as queue:
                while True:
                    notification = await queue.get_json(5)
                    if ws.closed:
                        break
                    await ws.send_str(notification)
        finally:
            log.info("Client has disconnected from notification stream for project ID '{}' (WebSocket method)".format(project.id))
            if not ws.closed:
                await ws.close()
            request.app['websockets'].discard(ws)
            if project.auto_close:
                # To avoid trouble with client connecting disconnecting we sleep few seconds before checking
                # if someone else is not connected
                await asyncio.sleep(5)
                if not controller.notification.project_has_listeners(project.id):
                    log.info("Project '{}' is automatically closing due to no client listening".format(project.id))
                    await project.close()

        return ws

    @Route.get(
        r"/projects/{project_id}/export",
        description="Export a project as a portable archive",
        parameters={
            "project_id": "Project UUID",
        },
        raw=True,
        status_codes={
            200: "File returned",
            404: "The project doesn't exist"
        })
    async def export_project(request, response):

        controller = Controller.instance()
        project = await controller.get_loaded_project(request.match_info["project_id"])
        if request.query.get("include_snapshots", "no").lower() == "yes":
            include_snapshots = True
        else:
            include_snapshots = False
        if request.query.get("include_images", "no").lower() == "yes":
            include_images = True
        else:
            include_images = False
        if request.query.get("reset_mac_addresses", "no").lower() == "yes":
            reset_mac_addresses = True
        else:
            reset_mac_addresses = False
        if request.query.get("keep_compute_ids", "no").lower() == "yes":
            keep_compute_ids = True
        else:
            keep_compute_ids = False

        compression_query = request.query.get("compression", "zip").lower()
        if compression_query == "zip":
            compression = zipfile.ZIP_DEFLATED
        elif compression_query == "none":
            compression = zipfile.ZIP_STORED
        elif compression_query == "bzip2":
            compression = zipfile.ZIP_BZIP2
        elif compression_query == "lzma":
            compression = zipfile.ZIP_LZMA

        try:
            begin = time.time()
            # use the parent directory as a temporary working dir
            working_dir = os.path.abspath(os.path.join(project.path, os.pardir))
            with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
                with aiozipstream.ZipFile(compression=compression) as zstream:
                    await export_project(
                        zstream,
                        project,
                        tmpdir,
                        include_snapshots=include_snapshots,
                        include_images=include_images,
                        reset_mac_addresses=reset_mac_addresses,
                        keep_compute_ids=keep_compute_ids
                    )

                    # We need to do that now because export could fail and raise an HTTP error
                    # that why response start need to be the later possible
                    response.content_type = 'application/gns3project'
                    response.headers['CONTENT-DISPOSITION'] = 'attachment; filename="{}.gns3project"'.format(project.name)
                    response.enable_chunked_encoding()
                    await response.prepare(request)

                    async for chunk in zstream:
                        await response.write(chunk)

            log.info("Project '{}' exported in {:.4f} seconds".format(project.name, time.time() - begin))

        # Will be raised if you have no space left or permission issue on your temporary directory
        # RuntimeError: something was wrong during the zip process
        except (ValueError, OSError, RuntimeError) as e:
            raise aiohttp.web.HTTPNotFound(text="Cannot export project: {}".format(str(e)))

    @Route.post(
        r"/projects/{project_id}/import",
        description="Import a project from a portable archive",
        parameters={
            "project_id": "Project UUID",
        },
        raw=True,
        output=PROJECT_OBJECT_SCHEMA,
        status_codes={
            200: "Project imported",
            403: "Forbidden to import project"
        })
    async def import_project(request, response):

        controller = Controller.instance()

        if request.get("path"):
            config = Config.instance()
            if config.get_section_config("Server").getboolean("local", False) is False:
                response.set_status(403)
                return
        path = request.json.get("path")
        name = request.json.get("name")

        # We write the content to a temporary location and after we extract it all.
        # It could be more optimal to stream this but it is not implemented in Python.
        try:
            begin = time.time()
            # use the parent directory or projects dir as a temporary working dir
            if path:
                working_dir = os.path.abspath(os.path.join(path, os.pardir))
            else:
                working_dir = controller.projects_directory()
            with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
                temp_project_path = os.path.join(tmpdir, "project.zip")
                async with aiofiles.open(temp_project_path, 'wb') as f:
                    while True:
                        chunk = await request.content.read(CHUNK_SIZE)
                        if not chunk:
                            break
                        await f.write(chunk)

                with open(temp_project_path, "rb") as f:
                    project = await import_project(controller, request.match_info["project_id"], f, location=path, name=name)

            log.info("Project '{}' imported in {:.4f} seconds".format(project.name, time.time() - begin))
        except OSError as e:
            raise aiohttp.web.HTTPInternalServerError(text="Could not import the project: {}".format(e))
        response.json(project)
        response.set_status(201)

    @Route.post(
        r"/projects/{project_id}/duplicate",
        description="Duplicate a project",
        parameters={
            "project_id": "Project UUID",
        },
        input=PROJECT_DUPLICATE_SCHEMA,
        output=PROJECT_OBJECT_SCHEMA,
        status_codes={
            201: "Project duplicate",
            403: "The server is not the local server",
            404: "The project doesn't exist"
        })
    async def duplicate(request, response):

        controller = Controller.instance()
        project = await controller.get_loaded_project(request.match_info["project_id"])

        if request.json.get("path"):
            config = Config.instance()
            if config.get_section_config("Server").getboolean("local", False) is False:
                response.set_status(403)
                return
            location = request.json.get("path")
        else:
            location = None

        reset_mac_addresses = request.json.get("reset_mac_addresses", False)

        new_project = await project.duplicate(name=request.json.get("name"), location=location, reset_mac_addresses=reset_mac_addresses)

        response.json(new_project)
        response.set_status(201)

    @Route.get(
        r"/projects/{project_id}/files/{path:.+}",
        description="Get a file from a project. Beware you have warranty to be able to access only to file global to the project (for example README.txt)",
        parameters={
            "project_id": "Project UUID",
        },
        status_codes={
            200: "File returned",
            403: "Permission denied",
            404: "The file doesn't exist"
        })
    async def get_file(request, response):

        controller = Controller.instance()
        project = await controller.get_loaded_project(request.match_info["project_id"])
        path = os.path.normpath(request.match_info["path"])

        # Raise error if user try to escape
        if not is_safe_path(path, project.path):
            raise aiohttp.web.HTTPForbidden()
        path = os.path.join(project.path, path)
        await response.stream_file(path)

    @Route.post(
        r"/projects/{project_id}/files/{path:.+}",
        description="Write a file to a project",
        parameters={
            "project_id": "Project UUID",
        },
        raw=True,
        status_codes={
            200: "File returned",
            403: "Permission denied",
            404: "The path doesn't exist"
        })
    async def write_file(request, response):

        controller = Controller.instance()
        project = await controller.get_loaded_project(request.match_info["project_id"])
        path = os.path.normpath(request.match_info["path"])

        # Raise error if user try to escape
        if not is_safe_path(path, project.path):
            raise aiohttp.web.HTTPForbidden()
        path = os.path.join(project.path, path)
        response.set_status(201)

        try:
            async with aiofiles.open(path, 'wb+') as f:
                while True:
                    try:
                        chunk = await request.content.read(CHUNK_SIZE)
                    except asyncio.TimeoutError:
                        raise aiohttp.web.HTTPRequestTimeout(text="Timeout when writing to file '{}'".format(path))
                    if not chunk:
                        break
                    await f.write(chunk)
        except FileNotFoundError:
            raise aiohttp.web.HTTPNotFound()
        except PermissionError:
            raise aiohttp.web.HTTPForbidden()
        except OSError as e:
            raise aiohttp.web.HTTPConflict(text=str(e))
