"""
Routes for the product service.
"""

import asyncio
from difflib import Differ

from beanie import PydanticObjectId
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from loguru import logger

from hipposerve.api.models.product import (
    CompleteProductRequest,
    CreateProductRequest,
    CreateProductResponse,
    ReadFilesResponse,
    ReadProductResponse,
    UpdateProductRequest,
    UpdateProductResponse,
)
from hipposerve.database import ProductMetadata
from hipposerve.service import acl, product, storage, users
from hipposerve.service.auth import AuthenticationError, requires

product_router = APIRouter(prefix="/product")

DEFAULT_USER_USER_NAME = "default_user"


@product_router.put("/new")
@requires(["hippo:admin", "hippo:write"])
async def create_product(
    request: Request,
    model: CreateProductRequest,
) -> CreateProductResponse:
    """
    Create a new product, returning the pre-signed URLs for the sources.
    """

    logger.info(
        "Create product request: {} from {}", model.name, request.user.display_name
    )

    if await product.exists(name=model.name):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT, detail="Product already exists"
        )

    item, presigned = await product.create(
        name=model.name,
        description=model.description,
        metadata=model.metadata,
        sources=model.sources,
        user_name=request.user.display_name,
        storage=request.app.storage,
        product_readers=model.product_readers,
        product_writers=model.product_writers,
        mutlipart_size=model.multipart_batch_size,
    )

    logger.info(
        "Successfully created {} pre-signed URL(s) for product upload {} (id: {}) from {}",
        sum(len(x) for x in presigned.values()),
        model.name,
        item.id,
        request.user.display_name,
    )

    return CreateProductResponse(id=item.id, upload_urls=presigned)


@product_router.get("/search/{text}")
@requires(["hippo:admin", "hippo:read"])
async def search(
    text: str,
    request: Request,
) -> list[ProductMetadata]:
    """
    Search for a product by name.
    """

    logger.info(
        "Search for product {} request from {}", text, request.user.display_name
    )

    items = await product.search_by_name(
        name=text, groups=request.user.groups, scopes=request.auth.scopes
    )

    logger.info(
        "Successfully found {} product(s) matching {} requested by {}",
        len(items),
        text,
        request.user.display_name,
    )

    return await asyncio.gather(*[item.to_metadata() for item in items])


@product_router.post("/{id}/complete")
@requires(["hippo:admin", "hippo:write"])
async def complete_product(
    id: PydanticObjectId,
    request: Request,
    model: CompleteProductRequest,
) -> None:
    """
    Complete a product's upload. Must be called before the sources are available.
    """
    logger.info(
        "Complete product request for {} from {}", id, request.user.display_name
    )

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
        )

    success = await product.complete(
        product=item,
        storage=request.app.storage,
        headers=model.headers,
        sizes=model.sizes,
    )

    if not success:
        raise HTTPException(
            status_code=status.HTTP_424_FAILED_DEPENDENCY,
            detail="Not all sources were present.",
        )

    logger.info("Successfully completed product {} (id: {})", item.name, item.id)


@product_router.get("/{id}")
@requires(["hippo:admin", "hippo:read"])
async def read_product(
    id: PydanticObjectId,
    request: Request,
) -> ReadProductResponse:
    """
    Read a single product's metadata.
    """

    logger.info("Read product request for {} from {}", id, request.user.display_name)

    try:
        product_item = await product.read_by_id(
            id, request.user.groups, scopes=request.auth.scopes
        )
        item = await product_item.to_metadata()

        response = ReadProductResponse(
            current_present=item.current,
            current=item.version if item.current else None,
            requested=item.version,
            versions={item.version: item},
        )

        logger.info(
            "Successfully read product {} (id: {}) requested by {}",
            item.name,
            item.id,
            request.user.display_name,
        )

        return response
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
        )


@product_router.get("/{id}/tree")
@requires(["hippo:admin", "hippo:read"])
async def read_tree(
    id: PydanticObjectId,
    request: Request,
) -> ReadProductResponse:
    """
    Read a single product's entire history.
    """

    logger.info(
        "Read product tree request for {} from {}", id, request.user.display_name
    )

    try:
        requested_item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
        current_item = await product.walk_to_current(
            product=requested_item,
            groups=request.user.groups,
            scopes=request.auth.scopes,
        )
        history = await product.walk_history(product=current_item)

        if not current_item.current:
            raise HTTPException(
                status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail="Unable to find the current version of the requested item",
            )

        response = ReadProductResponse(
            current_present=True,
            current=current_item.version,
            requested=requested_item.version,
            versions=history,
        )

        logger.info(
            "Successfully read product tree for {} (id: {}) requested by {}",
            requested_item.name,
            requested_item.id,
            request.user.display_name,
        )

        return response
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
        )


@product_router.get("/{id}/files")
@requires(["hippo:admin", "hippo:read"])
async def read_files(id: PydanticObjectId, request: Request) -> ReadFilesResponse:
    """
    Read a single product's including pre-signed URLs for downloads.
    """

    logger.info("Read files request for {} from {}", id, request.user.display_name)

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
        )

    files = await product.read_files(product=item, storage=request.app.storage)

    logger.info(
        "Read {} pre-signed URLs for product {} (id: {}) requested by {}",
        len(files),
        item.name,
        item.id,
        request.user.display_name,
    )

    return ReadFilesResponse(
        product=await item.to_metadata(),
        files=files,
    )


@product_router.get("/{id}/{slug}")
@requires(["hippo:admin", "hippo:read"])
async def read_slug(request: Request, id: str, slug: str) -> RedirectResponse:
    NOT_FOUND = HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail=f"Slug {slug} not found for product {id}",
    )
    try:
        product_instance = await product.read_by_id(
            id, request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise NOT_FOUND

    # If they have not 401/403'd from reading the product, they can read sources
    try:
        presigned = await storage.read(
            file=product_instance.sources[slug], storage=request.app.storage
        )
    except (FileNotFoundError, KeyError):
        raise NOT_FOUND

    return RedirectResponse(url=presigned, status_code=status.HTTP_302_FOUND)


@product_router.post("/{id}/diff")
@requires(["hippo:admin", "hippo:write"])
async def metadata_diff(
    id: PydanticObjectId,
    model: UpdateProductRequest,
    request: Request,
):
    """
    Calculates a diff between the current product and any new metadata.
    """

    logger.info("Update product request for {} from {}", id, request.user.display_name)

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
        )

    try:
        acl.check_user_access(
            user_groups=request.user.groups,
            document_groups=item.writers,
            scopes=request.auth.scopes,
        )
    except AuthenticationError:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="User does not have write access to this product",
        )

    # Check the _new_ owner is an existing user
    if model.owner is not None:
        try:
            await users.confirm_user(name=model.owner)
        except users.UserNotFound:
            raise HTTPException(
                status_code=status.HTTP_406_NOT_ACCEPTABLE, detail="User not found"
            )

    if not item.current:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Cannot update a product that is not the current version",
        )

    diff = await product.metadata_diff(
        product=item,
        name=model.name,
        description=model.description,
        metadata=model.metadata,
        level=model.level,
    )

    if diff.get("description", None) is not None:
        differ = Differ()
        diffed_description = list(
            differ.compare(
                item.description.splitlines(),
                model.description.splitlines() if model.description else [],
            )
        )

        output_diff = "<table class='table table-bordered'><tbody>\n"
        for line, next_line in zip(diffed_description, diffed_description[1:] + [""]):
            if line.startswith("? "):
                continue
            if line.startswith("+ "):
                line = line[2:]
                if next_line.startswith("? "):
                    # Two lines: first is addition, second is location in the original line
                    position = next_line[2:]

                    new_line = ""
                    in_diff = False

                    for outchar, indicator in zip(line, position + " " * len(line)):
                        if indicator != " ":
                            if not in_diff:
                                new_line += "<b class='text-success'>"
                                in_diff = True
                                new_line += outchar
                                continue
                            else:
                                new_line += outchar
                                continue

                        if in_diff:
                            new_line += "</b>"
                            in_diff = False

                        new_line += outchar

                    line = new_line
                output_diff += f"<tr class='table-success'><td><b class='text-success'>+</b></td><td class='text-success'>{line}</td></tr>\n"
            elif line.startswith("- "):
                line = line[2:]
                if next_line.startswith("? "):
                    # Two lines: first is addition, second is location in the original line
                    position = next_line[2:]

                    new_line = ""
                    in_diff = False

                    for outchar, indicator in zip(line, position + " " * len(line)):
                        if indicator != " ":
                            if not in_diff:
                                new_line += "<b class='text-danger'>"
                                in_diff = True
                                new_line += outchar
                                continue
                            else:
                                new_line += outchar
                                continue

                        if in_diff:
                            new_line += "</b>"
                            in_diff = False

                        new_line += outchar

                    line = new_line
                output_diff += f"<tr class='table-danger'><td><b class='text-danger'>-</b></td><td class='text-danger'>{line}</td></tr>\n"
            else:
                output_diff += f"<tr><td></td><td>{line[2:]}</td></tr>\n"

        output_diff += "</tbody></table>\n"
        diff["description"] = output_diff

    logger.debug(
        "Found metadata diff for product {} (id: {}): {}", item.name, item.id, diff
    )

    return diff


@product_router.post("/{id}/update")
@requires(["hippo:admin", "hippo:write"])
async def update_product(
    id: PydanticObjectId,
    model: UpdateProductRequest,
    request: Request,
) -> UpdateProductResponse:
    """
    Update a product's details.
    """

    logger.info("Update product request for {} from {}", id, request.user.display_name)

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
        )

    try:
        acl.check_user_access(
            user_groups=request.user.groups,
            document_groups=item.writers,
            scopes=request.auth.scopes,
        )
    except AuthenticationError:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="User does not have write access to this product",
        )

    # Check the _new_ owner is an existing user
    if model.owner is not None:
        try:
            await users.confirm_user(name=model.owner)
        except users.UserNotFound:
            raise HTTPException(
                status_code=status.HTTP_406_NOT_ACCEPTABLE, detail="User not found."
            )

    if model.level:
        try:
            new_product, upload_urls = await product.update(
                product=item,
                access_groups=request.user.groups,
                scopes=request.auth.scopes,
                name=model.name,
                description=model.description,
                metadata=model.metadata,
                new_sources=model.new_sources,
                replace_sources=model.replace_sources,
                drop_sources=model.drop_sources,
                storage=request.app.storage,
                level=model.level,
            )
        except AuthenticationError:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="User does not have write access to this product",
            )

        logger.info(
            "Successfully updated product {} (new id: {}; {}; old id: {}; {}) from {}",
            new_product.name,
            new_product.id,
            new_product.version,
            item.id,
            item.version,
            request.user.display_name,
        )
    else:
        new_product = item
        upload_urls = {}

    new_product = await acl.update_access_control(
        doc=new_product,
        owner=model.owner,
        add_readers=model.add_readers,
        remove_readers=model.remove_readers,
        add_writers=model.add_writers,
        remove_writers=model.remove_writers,
    )

    logger.info(
        "Successfully updated product {} (id: {}) with new access control policy",
        new_product.name,
        new_product.id,
    )

    return UpdateProductResponse(
        version=new_product.version,
        id=new_product.id,
        upload_urls=upload_urls,
    )


@product_router.post("/{id}/confirm")
@requires(["hippo:admin", "hippo:read"])
async def confirm_product(id: PydanticObjectId, request: Request) -> None:
    """
    Confirm a product's sources.
    """

    logger.info("Confirm product request for {} from {}", id, request.user.display_name)

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
        success = await product.confirm(
            product=item,
            storage=request.app.storage,
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found."
        )

    if not success:
        raise HTTPException(
            status_code=status.HTTP_424_FAILED_DEPENDENCY,
            detail="Not all sources were present.",
        )

    logger.info("Successfully confirmed product {} (id: {})", item.name, item.id)


@product_router.post("/{id}/pin")
@requires(["hippo:admin"])
async def pin_product(
    id: PydanticObjectId,
    request: Request,
) -> None:
    """
    Pin a product to place it on the homepage.
    """

    logger.info("Pin product request for {} from {}", id, request.user.display_name)

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found."
        )

    item.pinned = True
    await item.save()

    logger.info("Successfully pinned product {} (id: {})", item.name, item.id)


@product_router.post("/{id}/unpin")
@requires(["hippo:admin"])
async def unpin_product(
    id: PydanticObjectId,
    request: Request,
) -> None:
    """
    Unpin a product to remvove it from the homepage.
    """

    logger.info("Unpin product request for {} from {}", id, request.user.display_name)

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found."
        )

    item.pinned = False
    await item.save()

    logger.info("Successfully unpinned product {} (id: {})", item.name, item.id)


@product_router.delete("/{id}")
@requires(["hippo:admin", "hippo:write"])
async def delete_product(
    id: PydanticObjectId,
    request: Request,
    data: bool = False,
) -> None:
    """
    Delete a product.
    """

    logger.info(
        "Delete (single) product request for {} from {}", id, request.user.display_name
    )

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found."
        )

    await product.delete_one(
        product=item,
        access_groups=request.user.groups,
        storage=request.app.storage,
        data=data,
        scopes=request.auth.scopes,
    )

    logger.info(
        "Successfully deleted product {} (id: {}) including data"
        if data
        else "Successfully deleted product {} (id: {}) excluding data",
        item.name,
        item.id,
    )


@product_router.delete("/{id}/tree")
@requires(["hippo:admin", "hippo:write"])
async def delete_tree(
    id: PydanticObjectId,
    request: Request,
    data: bool = False,
) -> None:
    """
    Delete a product.
    """

    logger.info(
        "Delete (tree) product request for {} from {}", id, request.user.display_name
    )

    try:
        item = await product.read_by_id(
            id=id, groups=request.user.groups, scopes=request.auth.scopes
        )
    except product.ProductNotFound:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Product not found."
        )

    await product.delete_tree(
        product=item,
        access_groups=request.user.groups,
        storage=request.app.storage,
        data=data,
        scopes=request.auth.scopes,
    )

    logger.info(
        "Successfully deleted product tree for {} (id: {}) including data"
        if data
        else "Successfully deleted product tree for {} (id: {}) excluding data",
        item.name,
        item.id,
    )
