#
# Copyright (C) 2020 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/>.

"""
API routes for projects.
"""

import os
import asyncio
import tempfile
import aiofiles
import time
import urllib.parse
import gns3server.utils.zipfile_zstd as zipfile

import logging

log = logging.getLogger()

from fastapi import APIRouter, Depends, Request, Body, HTTPException, status, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse, FileResponse
from websockets.exceptions import ConnectionClosed, WebSocketException
from typing import List, Optional
from uuid import UUID

from gns3server import schemas
from gns3server.controller import Controller
from gns3server.controller.project import Project
from gns3server.controller.link import _UNSET
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
from gns3server.controller.import_project import import_project as import_controller_project
from gns3server.controller.export_project import export_project as export_controller_project
from gns3server.utils.asyncio import aiozipstream
from gns3server.utils.path import is_safe_path
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.pools import ResourcePoolsRepository
from gns3server.services.templates import TemplatesService

from .dependencies.rbac import has_privilege, has_privilege_on_websocket
from .dependencies.authentication import get_current_active_user
from .dependencies.database import get_repository

responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project"}}

router = APIRouter(responses=responses)


def dep_project(project_id: UUID) -> Project:
    """
    Dependency to retrieve a project.
    """

    project = Controller.instance().get_project(str(project_id))
    return project


@router.get(
    "",
    response_model=List[schemas.Project],
    response_model_exclude_unset=True
)
async def get_projects(
        current_user: schemas.User = Depends(get_current_active_user),
        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> List[schemas.Project]:
    """
    Return all projects.

    Required privilege: Project.Audit
    """

    controller = Controller.instance()
    projects = []
    seen_project_ids = set()  # track seen projects to avoid duplicates

    if current_user.is_superadmin:
        # super admin sees all projects
        return [p.asdict() for p in controller.projects.values()]

    # Batch ACE + resource pool check (3 DB queries regardless of project count)
    all_project_ids = list(controller.projects.keys())
    direct_ace_ids, pool_accessible_ids = await rbac_repo.get_accessible_project_ids(
        current_user.user_id, "Project.Audit", all_project_ids
    )

    # Step 2: Filter direct ACE projects by created_by
    # Direct project sharing is only available through resource pools
    for p in controller.projects.values():
        if p.id in direct_ace_ids and p.created_by == current_user.username:
            if p.id not in seen_project_ids:
                projects.append(p.asdict())
                seen_project_ids.add(p.id)

    # Step 3: Resource pool projects (no created_by filter)
    for p in controller.projects.values():
        if p.id in pool_accessible_ids:
            if p.id not in seen_project_ids:
                projects.append(p.asdict())
                seen_project_ids.add(p.id)

    return projects


@router.post(
    "",
    status_code=status.HTTP_201_CREATED,
    response_model=schemas.Project,
    response_model_exclude_unset=True,
    responses={409: {"model": schemas.ErrorMessage, "description": "Could not create project"}},
    dependencies=[Depends(has_privilege("Project.Allocate"))]
)
async def create_project(
        project_data: schemas.ProjectCreate,
        current_user: schemas.User = Depends(get_current_active_user),
) -> schemas.Project:
    """
    Create a new project.

    Required privilege: Project.Allocate
    """

    controller = Controller.instance()
    project_dict = jsonable_encoder(project_data, exclude_unset=True)
    project_dict["created_by"] = current_user.username
    project = await controller.add_project(**project_dict)
    return project.asdict()


@router.get("/{project_id}", response_model=schemas.Project, dependencies=[Depends(has_privilege("Project.Audit"))])
def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
    """
    Return a project.

    Required privilege: Project.Audit
    """

    return project.asdict()


@router.put(
    "/{project_id}",
    response_model=schemas.Project,
    response_model_exclude_unset=True,
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def update_project(
        project_data: schemas.ProjectUpdate,
        project: Project = Depends(dep_project)
) -> schemas.Project:
    """
    Update a project.

    Required privilege: Project.Modify
    """

    await project.update(**jsonable_encoder(project_data, exclude_unset=True))
    return project.asdict()


@router.delete(
    "/{project_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    dependencies=[Depends(has_privilege("Project.Allocate"))]
)
async def delete_project(
        project: Project = Depends(dep_project),
        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> None:
    """
    Delete a project.

    Required privilege: Project.Allocate
    """

    controller = Controller.instance()
    await project.delete()
    controller.remove_project(project)
    await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}")


@router.get("/{project_id}/stats", dependencies=[Depends(has_privilege("Project.Audit"))])
def get_project_stats(project: Project = Depends(dep_project)) -> dict:
    """
    Return a project statistics.

    Required privilege: Project.Audit
    """

    return project.stats()


@router.get("/{project_id}/markers", dependencies=[Depends(has_privilege("Project.Audit"))])
def get_project_markers(project: Project = Depends(dep_project)) -> dict:
    """
    Return all traffic-insight markers across every link in the project.

    Each entry is keyed ``"{link_id}/{marker_name}"`` and carries the
    marker's BPF, tag, color, enabled flag, plus its parent ``link_id``
    and capture-side ``node_id`` for frontend filtering / grouping.

    Required privilege: Project.Audit
    """

    return project.markers


# ---------------------------------------------------------------------------
# Project-level marker definitions (global rules inherited by every link)
# ---------------------------------------------------------------------------

@router.get(
    "/{project_id}/marker-definitions",
    dependencies=[Depends(has_privilege("Project.Audit"))]
)
def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
    """
    Return all project-level marker definitions with their bound link IDs.

    Required privilege: Project.Audit
    """

    result = {}
    for name, d in project.marker_definitions.items():
        # Collect which links currently carry an inherited copy.
        bound = [
            lid for lid, link in project.links.items()
            if f"global-{name}" in link.markers
            and link.markers[f"global-{name}"].get("inherited_from") == name
        ]
        result[name] = {**d, "link_ids": bound}
    return result


@router.post(
    "/{project_id}/marker-definitions",
    status_code=status.HTTP_201_CREATED,
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def create_marker_definition(
    def_data: schemas.MarkerDefinitionCreate,
    project: Project = Depends(dep_project)
) -> dict:
    """
    Create a project-level marker definition and fan out to every link.

    Required privilege: Project.Modify
    """

    if def_data.name and def_data.name.lower().startswith("global"):
        raise ControllerError('Names starting with "global" are reserved for inherited markers')
    name = def_data.name or f"def-{project.id[:8]}"
    await project.create_marker_definition(
        name=name,
        bpf=def_data.bpf,
        tag=def_data.tag,
        direction=def_data.direction,
        color=def_data.color,
        highlight_duration=def_data.highlight_duration,
        data_link_type=def_data.data_link_type,
    )
    return project.marker_definitions.get(name, {})


@router.put(
    "/{project_id}/marker-definitions/{def_name}",
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def update_marker_definition(
    def_name: str,
    def_data: schemas.MarkerDefinitionCreate,
    project: Project = Depends(dep_project)
) -> dict:
    """
    Update a marker definition and sync all inherited copies on every link.

    Required privilege: Project.Modify
    """

    await project.update_marker_definition(
        name=def_name,
        bpf=def_data.bpf if def_data.bpf else None,
        tag=def_data.tag,
        direction=def_data.direction if "direction" in def_data.model_fields_set else _UNSET,
        color=def_data.color,
        highlight_duration=def_data.highlight_duration,
        data_link_type=def_data.data_link_type if "data_link_type" in def_data.model_fields_set else _UNSET,
    )
    return project.marker_definitions.get(def_name, {})


@router.post(
    "/{project_id}/marker-definitions/{def_name}/pause",
    status_code=status.HTTP_204_NO_CONTENT,
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def pause_marker_definition(
    def_name: str,
    project: Project = Depends(dep_project)
) -> None:
    """
    Pause a definition: toggle off every inherited ``global-{def_name}`` copy
    on every link (uBridge ``enable_packet_filter off``, instant — no NIO
    rebuild). The definition's ``paused`` flag is persisted, so links created
    later inherit it already paused.

    Required privilege: Project.Modify
    """

    await project.pause_marker_definition(def_name)


@router.post(
    "/{project_id}/marker-definitions/{def_name}/resume",
    status_code=status.HTTP_204_NO_CONTENT,
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def resume_marker_definition(
    def_name: str,
    project: Project = Depends(dep_project)
) -> None:
    """Resume a paused definition (toggle on every inherited copy).

    Required privilege: Project.Modify
    """

    await project.resume_marker_definition(def_name)


@router.delete(
    "/{project_id}/marker-definitions/{def_name}",
    status_code=status.HTTP_204_NO_CONTENT,
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def delete_marker_definition(
    def_name: str,
    project: Project = Depends(dep_project)
) -> None:
    """
    Delete a marker definition and remove all inherited copies from every link.

    Required privilege: Project.Modify
    """

    await project.delete_marker_definition(def_name)


@router.post(
    "/{project_id}/close",
    status_code=status.HTTP_204_NO_CONTENT,
    responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not close project"}},
    dependencies=[Depends(has_privilege("Project.Allocate"))]
)
async def close_project(project: Project = Depends(dep_project)) -> None:
    """
    Close a project.

    Required privilege: Project.Allocate
    """

    await project.close()


@router.post(
    "/{project_id}/open",
    status_code=status.HTTP_201_CREATED,
    response_model=schemas.Project,
    responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not open project"}},
    dependencies=[Depends(has_privilege("Project.Allocate"))]
)
async def open_project(project: Project = Depends(dep_project)) -> schemas.Project:
    """
    Open a project.

    Required privilege: Project.Allocate
    """

    await project.open()
    return project.asdict()


@router.post(
    "/load",
    status_code=status.HTTP_201_CREATED,
    response_model=schemas.Project,
    responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not load project"}},
    dependencies=[Depends(has_privilege("Project.Allocate"))]
)
async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
    """
    Load a project (local server only).

    Required privilege: Project.Allocate
    """

    controller = Controller.instance()
    dot_gns3_file = path
    project = await controller.load_project(dot_gns3_file)
    return project.asdict()


@router.get("/{project_id}/notifications", dependencies=[Depends(has_privilege("Project.Audit"))])
async def project_http_notifications(project_id: UUID) -> StreamingResponse:
    """
    Receive project notifications about the controller from HTTP stream.

    Required privilege: Project.Audit
    """

    from gns3server.api.server import app
    controller = Controller.instance()
    project = controller.get_project(str(project_id))

    log.info(f"New client has connected to the notification stream for project ID '{project.id}' (HTTP stream method)")

    async def event_stream():

        try:
            with controller.notification.project_queue(project.id) as queue:
                while not app.state.exiting:
                    msg = await queue.get_json(5)
                    yield f"{msg}\n".encode("utf-8")
        finally:
            log.info(f"Client has disconnected from notification for project ID '{project.id}' (HTTP stream method)")
            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(f"Project '{project.id}' is automatically closing due to no client listening")
                    await project.close()

    return StreamingResponse(event_stream(), media_type="application/json")


@router.websocket("/{project_id}/notifications/ws")
async def project_ws_notifications(
        project_id: UUID,
        websocket: WebSocket,
        current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit"))
) -> None:
    """
    Receive project notifications about the controller from WebSocket.

    Required privilege: Project.Audit
    """

    if current_user is None:
        return

    controller = Controller.instance()
    project = controller.get_project(str(project_id))

    log.info(f"New client has connected to the notification stream for project ID '{project.id}' (WebSocket method)")
    try:
        with controller.notification.project_queue(project.id) as queue:
            while True:
                notification = await queue.get_json(5)
                await websocket.send_text(notification)
    except (ConnectionClosed, WebSocketDisconnect):
        log.info(f"Client has disconnected from notification stream for project ID '{project.id}' (WebSocket method)")
    except WebSocketException as e:
        log.warning(f"Error while sending to project event to WebSocket client: {e}")
    finally:
        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(f"Project '{project.id}' is automatically closing due to no client listening")
                await project.close()


@router.websocket("/{project_id}/notifications/markers/ws")
async def project_marker_ws_notifications(
        project_id: UUID,
        websocket: WebSocket,
        current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit"))
) -> None:
    """
    Receive marker notifications (e.g. marker.match) for a project on a
    dedicated WebSocket, separate from the main project stream so high-frequency
    marker.matches do not block topology events (node.*/link.*).

    Required privilege: Project.Audit
    """

    if current_user is None:
        return

    controller = Controller.instance()
    project = controller.get_project(str(project_id))

    log.info(f"New client has connected to the marker notification stream for project ID '{project.id}' (WebSocket method)")
    try:
        with controller.notification.project_marker_queue(project.id) as queue:
            while True:
                notification = await queue.get_json(5)
                await websocket.send_text(notification)
    except (ConnectionClosed, WebSocketDisconnect):
        log.info(f"Client has disconnected from the marker notification stream for project ID '{project.id}' (WebSocket method)")
    except WebSocketException as e:
        log.warning(f"Error while sending marker event to WebSocket client: {e}")


@router.get("/{project_id}/export", dependencies=[Depends(has_privilege("Project.Audit"))])
async def export_project(
    project: Project = Depends(dep_project),
    include_snapshots: bool = False,
    include_images: bool = False,
    reset_mac_addresses: bool = False,
    keep_compute_ids: bool = False,
    compression: schemas.ProjectCompression = "zstd",
    compression_level: int = None,
) -> StreamingResponse:
    """
    Export a project as a portable archive.

    Required privilege: Project.Audit
    """

    if project.is_running():
        raise ControllerError("Project must be stopped in order to export it")

    compression_query = compression.lower()
    if compression_query == "zip":
        compression = zipfile.ZIP_DEFLATED
        if compression_level is not None and (compression_level < 0 or compression_level > 9):
            raise ControllerBadRequestError("Compression level must be between 0 and 9 for ZIP compression")
    elif compression_query == "none":
        compression = zipfile.ZIP_STORED
    elif compression_query == "bzip2":
        compression = zipfile.ZIP_BZIP2
        if compression_level is not None and (compression_level < 1 or compression_level > 9):
            raise ControllerBadRequestError("Compression level must be between 1 and 9 for BZIP2 compression")
    elif compression_query == "lzma":
        compression = zipfile.ZIP_LZMA
    elif compression_query == "zstd":
        compression = zipfile.ZIP_ZSTANDARD
        if compression_level is not None and (compression_level < 1 or compression_level > 22):
            raise ControllerBadRequestError("Compression level must be between 1 and 22 for Zstandard compression")

    if compression_level is not None and compression_query in ("none", "lzma"):
        raise ControllerBadRequestError(f"Compression level is not supported for '{compression_query}' compression method")

    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))

        async def streamer():
            log.info(f"Exporting project '{project.name}' with '{compression_query}' compression "
                     f"(level {compression_level})")
            with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
                with aiozipstream.ZipFile(compression=compression, compresslevel=compression_level) as zstream:
                    await export_controller_project(
                        zstream,
                        project,
                        tmpdir,
                        include_snapshots=include_snapshots,
                        include_images=include_images,
                        keep_compute_ids=keep_compute_ids,
                        reset_mac_addresses=reset_mac_addresses,
                    )
                    async for chunk in zstream:
                        yield chunk

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

    # 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 ConnectionError(f"Cannot export project: {e}")

    fallback = project.name.encode("ascii", "ignore").decode() or "project"
    encoded = urllib.parse.quote(project.name, safe="")
    headers = {
        "Content-Disposition": (
            f'attachment; filename="{fallback}.gns3project"; filename*=UTF-8\'\'{encoded}.gns3project'
        )
    }
    return StreamingResponse(streamer(), media_type="application/gns3project", headers=headers)


@router.post(
    "/{project_id}/import",
    status_code=status.HTTP_201_CREATED,
    response_model=schemas.Project,
    dependencies=[Depends(has_privilege("Project.Allocate"))]
)
async def import_project(
        project_id: UUID,
        request: Request,
        name: Optional[str] = None
) -> schemas.Project:
    """
    Import a project from a portable archive.

    Required privilege: Project.Allocate
    """

    controller = Controller.instance()
    # We write the content to a temporary location and then we extract it all.
    # It could be more optimal to stream this but it is not implemented in Python.
    try:
        begin = time.time()
        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:
                async for chunk in request.stream():
                    await f.write(chunk)
            with open(temp_project_path, "rb") as f:
                project = await import_controller_project(controller, str(project_id), f, name=name)

        log.info(f"Project '{project.name}' imported in {time.time() - begin:.4f} seconds")
    except OSError as e:
        raise ControllerError(f"Could not import the project: {e}")
    return project.asdict()


@router.post(
    "/{project_id}/duplicate",
    status_code=status.HTTP_201_CREATED,
    response_model=schemas.Project,
    responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not duplicate project"}},
    dependencies=[Depends(has_privilege("Project.Audit"))]
)
async def duplicate_project(
        project_data: schemas.ProjectDuplicate,
        project: Project = Depends(dep_project),
        current_user: schemas.User = Depends(get_current_active_user),
        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
        pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository))
) -> schemas.Project:
    """
    Duplicate a project.

    Required privilege: Project.Audit
    """

    pool_memberships = await pools_repo.get_resource_memberships(project.id)

    # check if the project can be duplicated somewhere (either in a pool or in the root)
    if not current_user.is_superadmin:
        can_be_duplicated_somewhere = False
        if pool_memberships:
            for pool in pool_memberships:
                if await rbac_repo.check_user_has_privilege(current_user.user_id, f"/pools/{pool.resource_pool_id}", "Project.Allocate"):
                    can_be_duplicated_somewhere = True
                    break

        if not can_be_duplicated_somewhere and not await rbac_repo.check_user_has_privilege(current_user.user_id, "/projects", "Project.Allocate"):
            log.warning(f"Project {project.name} cannot be duplicated anywhere")
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)

    reset_mac_addresses = project_data.reset_mac_addresses
    new_project = await project.duplicate(
        name=project_data.name, reset_mac_addresses=reset_mac_addresses
    )

    # Add the new project in the same resource pools if the duplicated project belongs to any
    if pool_memberships:
        resource_create = schemas.ResourceCreate(resource_id=new_project.id, resource_type="project", name=new_project.name)
        resource = await pools_repo.create_resource(resource_create)
        for pool in pool_memberships:
            await pools_repo.add_resource_to_pool(pool.resource_pool_id, resource)

    return new_project.asdict()


@router.get("/{project_id}/locked", dependencies=[Depends(has_privilege("Project.Audit"))])
async def locked_project(project: Project = Depends(dep_project)) -> bool:
    """
    Returns whether a project is locked or not.

    Required privilege: Project.Audit
    """

    return project.locked


@router.post(
    "/{project_id}/lock",
    status_code=status.HTTP_204_NO_CONTENT,
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def lock_project(project: Project = Depends(dep_project)) -> None:
    """
    Lock all drawings and nodes in a given project.

    Required privilege: Project.Audit
    """

    project.lock()


@router.post(
    "/{project_id}/unlock",
    status_code=status.HTTP_204_NO_CONTENT,
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def unlock_project(project: Project = Depends(dep_project)) -> None:
    """
    Unlock all drawings and nodes in a given project.

    Required privilege: Project.Modify
    """

    project.unlock()


@router.get("/{project_id}/files/{file_path:path}", dependencies=[Depends(has_privilege("Project.Audit"))])
async def get_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse:
    """
    Return a file from a project.

    Required privilege: Project.Audit
    """

    file_path = urllib.parse.unquote(file_path)
    path = os.path.normpath(file_path)

    # Raise error if user try to escape
    if not is_safe_path(path, project.path):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)

    path = os.path.join(project.path, path)
    if not os.path.exists(path):
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

    return FileResponse(path, media_type="application/octet-stream")


@router.post(
    "/{project_id}/files/{file_path:path}",
    status_code=status.HTTP_204_NO_CONTENT,
    dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def write_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
    """
    Write a file to a project.

    Required privilege: Project.Modify
    """

    file_path = urllib.parse.unquote(file_path)
    path = os.path.normpath(file_path)

    # Raise error if user try to escape
    if not is_safe_path(path, project.path):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)

    path = os.path.join(project.path, path)

    try:
        async with aiofiles.open(path, "wb+") as f:
            async for chunk in request.stream():
                await f.write(chunk)
    except FileNotFoundError:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
    except PermissionError:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    except OSError as e:
        raise ControllerError(str(e))


@router.post(
    "/{project_id}/templates/{template_id}",
    response_model=schemas.Node,
    status_code=status.HTTP_201_CREATED,
    responses={404: {"model": schemas.ErrorMessage, "description": "Could not find project or template"}},
    dependencies=[Depends(has_privilege("Node.Allocate"))]
)
async def create_node_from_template(
    project_id: UUID,
    template_id: UUID,
    template_usage: schemas.TemplateUsage,
    templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> schemas.Node:
    """
    Create a new node from a template.

    Required privilege: Node.Allocate
    """

    template = await TemplatesService(templates_repo).get_template(template_id)

    controller = Controller.instance()
    project = controller.get_project(str(project_id))

    node = await project.add_node_from_template(
        template, x=template_usage.x, y=template_usage.y, name=template_usage.name, compute_id=template_usage.compute_id
    )
    return node.asdict()
