import asyncio
import logging
from pathlib import Path
from typing import Any
from urllib.parse import unquote_plus

from aiohttp import web
from aiohttp.web import Request, Response

from app.library.cache import Cache
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.ffprobe import ffprobe
from app.library.router import route
from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type

LOG: logging.Logger = logging.getLogger(__name__)


@route("GET", "api/file/ffprobe/{file:.*}", "ffprobe")
async def get_ffprobe(request: Request, config: Config, encoder: Encoder, app: web.Application) -> Response:
    """
    Get the ffprobe data.

    Args:
        request (Request): The request object.
        config (Config): The configuration object.
        encoder (Encoder): The encoder object.
        app (web.Application): The aiohttp application object.

    Returns:
        Response: The response object.

    """
    file: str = request.match_info.get("file")
    if not file:
        return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)

    try:
        realFile, status = get_file(download_path=config.download_path, file=file)
        if web.HTTPFound.status_code == status:
            return Response(
                status=web.HTTPFound.status_code,
                headers={
                    "Location": str(
                        app.router["ffprobe"].url_for(
                            file=str(realFile.relative_to(config.download_path).as_posix()).strip("/")
                        )
                    ),
                },
            )

        if web.HTTPNotFound.status_code == status:
            return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)

        return web.json_response(data=await ffprobe(realFile), status=web.HTTPOk.status_code, dumps=encoder.encode)
    except Exception as e:
        return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)


@route("GET", "api/file/info/{file:.*}", "file_info")
async def get_file_info(request: Request, config: Config, encoder: Encoder, app: web.Application) -> Response:
    """
    Get file info

    Args:
        request (Request): The request object.
        config (Config): The configuration object.
        encoder (Encoder): The encoder object.
        app (web.Application): The aiohttp application object.

    Returns:
        Response: The response object.

    """
    file: str = request.match_info.get("file")
    if not file:
        return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)

    try:
        realFile, status = get_file(download_path=config.download_path, file=file)
        if web.HTTPFound.status_code == status:
            return Response(
                status=web.HTTPFound.status_code,
                headers={
                    "Location": str(
                        app.router["file_info"].url_for(
                            file=str(realFile.relative_to(config.download_path).as_posix()).strip("/")
                        )
                    ),
                },
            )

        if web.HTTPNotFound.status_code == status:
            return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)

        ff_info = await ffprobe(realFile)

        response = {
            "title": realFile.stem,
            "ffprobe": ff_info,
            "mimetype": get_mime_type(ff_info.get("metadata", {}), realFile),
            "sidecar": get_file_sidecar(realFile),
        }

        for key in response["sidecar"]:
            for i, f in enumerate(response["sidecar"][key]):
                response["sidecar"][key][i]["file"] = str(
                    realFile.with_name(f["file"].name).relative_to(config.download_path)
                ).strip("/")

        return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
    except Exception as e:
        LOG.exception(e)
        return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)


@route("GET", "api/file/browser/{path:.*}", "file_browser")
async def file_browser(request: Request, config: Config, encoder: Encoder) -> Response:
    """
    Get the file browser.

    Args:
        request (Request): The request object.
        config (Config): The configuration object.
        encoder (Encoder): The encoder object.

    Returns:
        Response: The response object.

    """
    req_path: str = request.match_info.get("path")
    req_path: str = "/" if not req_path else unquote_plus(req_path)

    # Normalize requested path to always be inside download root.
    raw_req: str = (req_path or "").strip()
    root_dir: Path = Path(config.download_path).resolve()
    if raw_req in ("", "/"):
        test: Path = root_dir
        rel_for_listing = "/"
    else:
        # Strip leading slash so joinpath doesn't ignore the base path.
        test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False)
        rel_for_listing = raw_req.lstrip("/")

    try:
        test.relative_to(root_dir)
    except Exception:
        return web.json_response(
            data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
        )

    if not test.exists():
        return web.json_response(
            data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
        )

    if not test.is_dir() and not test.is_symlink():
        return web.json_response(
            data={"error": f"path '{req_path}' is not a directory."}, status=web.HTTPBadRequest.status_code
        )

    try:
        return web.json_response(
            data={
                "path": rel_for_listing,
                "contents": get_files(base_path=root_dir, dir=rel_for_listing),
            },
            status=web.HTTPOk.status_code,
            dumps=encoder.encode,
        )
    except OSError as e:
        LOG.exception(e)
        return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)


@route("POST", "api/file/actions", "browser.file.actions")
async def path_actions(request: Request, config: Config) -> Response:
    """
    Browser actions.

    Args:
        request (Request): The request object.
        config (Config): The configuration object.

    Returns:
        Response: The response object.

    """
    if not config.browser_control_enabled:
        return web.json_response(
            data={"error": "File browser actions is disabled."}, status=web.HTTPForbidden.status_code
        )

    rootPath: Path = Path(config.download_path)

    try:
        actions = await request.json()
        if not actions or not isinstance(actions, list):
            return web.json_response(
                data={"error": "Invalid parameters expecting list of dicts."}, status=web.HTTPBadRequest.status_code
            )
    except Exception as e:
        LOG.exception(e)
        return web.json_response(data={"error": "Invalid JSON."}, status=web.HTTPBadRequest.status_code)

    # validate each action before performing any operations
    for params in actions:
        action = params.get("action").lower()
        if not action or action not in ["rename", "delete", "move", "directory"]:
            return web.json_response(
                data={"error": f"Invalid action '{action}'. Must be one of rename, delete, move, directory."},
                status=web.HTTPBadRequest.status_code,
            )

        if "rename" == action and not params.get("new_name"):
            return web.json_response(
                data={"error": "New name is required for rename action."}, status=web.HTTPBadRequest.status_code
            )

        if "move" == action and not params.get("new_path"):
            return web.json_response(
                data={"error": "New path is required for move action."}, status=web.HTTPBadRequest.status_code
            )

        if "directory" == action and not params.get("new_dir"):
            return web.json_response(
                data={"error": "New directory name is required for directory action."},
                status=web.HTTPBadRequest.status_code,
            )

    operations_status: list[dict[str, Any]] = []

    def record(
        op_path: str | Path,
        *,
        ok: bool,
        error: str | None = None,
        action: str | None = None,
        extra: dict | None = None,
    ) -> None:
        try:
            if isinstance(op_path, Path):
                rel: str = str(op_path.relative_to(rootPath)).strip("/")
            else:
                rel: str = str(op_path).strip("/")
        except Exception:
            rel = str(op_path)
        if not rel or rel == ".":
            rel = "/"

        entry: dict = {"path": rel, "status": ok, "error": error}

        if action:
            entry["action"] = action

        if extra:
            norm_extra: dict = {}
            for k, v in extra.items():
                if isinstance(v, Path):
                    try:
                        norm_extra[k] = str(v.relative_to(rootPath)).strip("/") or "/"
                    except Exception:
                        norm_extra[k] = str(v)
                else:
                    norm_extra[k] = v
            entry.update(norm_extra)

        operations_status.append(entry)

    # perform each action
    for params in actions:
        req_path: str = params.get("path")
        if not req_path:
            record("no_path", ok=False, error="Path is required.", extra={"item": params})
            continue

        action: str = params.get("action", "").lower()
        if not action:
            record(req_path, ok=False, error="Action is required.", extra={"item": params})
            continue

        req_path: str = "/" if not req_path else unquote_plus(req_path)

        test: Path = Path(config.download_path)
        if req_path and "/" != req_path:
            test = test.joinpath(req_path)

        if not test.exists():
            record(req_path, ok=False, error=f"path '{req_path}' does not exist.", action=action)
            continue

        try:
            path, status = get_file(
                download_path=config.download_path, file=str(test.relative_to(config.download_path))
            )
            if web.HTTPOk.status_code != status:
                record(req_path, ok=False, error="Invalid path.", action=action)
                continue
            if not path.is_relative_to(rootPath):
                record(req_path, ok=False, error="Path outside download root.", action=action)
                continue
        except Exception as e:
            LOG.exception(e)
            record(req_path, ok=False, error=str(e), action=action, extra={"item": params})
            continue

        if "directory" != action:
            if path == rootPath:
                record(path, ok=False, error="Cannot operate on root directory.", action=action)
                continue

            if not path.is_relative_to(rootPath):
                record(path, ok=False, error="Path outside download root.", action=action)
                continue

        if "directory" == action:
            new_dir = params.get("new_dir").lstrip("/").strip()
            if not new_dir:
                record(path, ok=False, error="New directory name is required.", action=action)
                continue

            new_path = path.joinpath(*new_dir.split("/"))
            try:
                new_path = new_path.resolve(strict=False)
            except Exception:
                record(path, ok=False, error="Invalid directory path.", action=action, extra={"new_dir": new_dir})
                continue

            root_real = Path(config.download_path).resolve()
            if not new_path.is_relative_to(root_real):
                record(path, ok=False, error="Destination outside root.", action=action, extra={"new_dir": new_path})
                continue

            if new_path.exists():
                dst = new_path.relative_to(config.download_path)
                record(path, ok=False, error="Directory already exists.", action=action, extra={"new_dir": dst})
                continue

            try:
                new_path.mkdir(parents=True, exist_ok=True)
                record(path, ok=True, action=action, extra={"new_dir": new_path.relative_to(config.download_path)})
                LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
            except OSError as e:
                LOG.exception(e)
                record(path, ok=False, error=str(e), action=action, extra={"item": params})
                continue

        if "rename" == action:
            new_name: str = params.get("new_name")
            if not new_name:
                record(path, ok=False, error="New name is required for rename action.", action=action)
                continue

            new_path: Path = path.parent.joinpath(new_name)
            if new_path.exists():
                record(
                    new_path, ok=False, error="Destination already exists.", action=action, extra={"new_path": new_path}
                )
                continue

            try:
                renamed = path.rename(new_path)
                LOG.info(
                    f"Renamed '{path.relative_to(config.download_path)}' to '{renamed.relative_to(config.download_path)}'"
                )
            except OSError as e:
                LOG.exception(e)
                record(path, ok=False, error=str(e), action=action, extra={"item": params})
                continue
            else:
                record(path, ok=True, action=action, extra={"new_path": renamed})

        if "delete" == action:
            try:
                if not path.exists():
                    record(path, ok=False, error="Path does not exist.", action=action)
                    continue

                if path.is_dir():
                    delete_dir(path)
                else:
                    path.unlink(missing_ok=True)

                LOG.info(f"Deleted '{path.relative_to(config.download_path)}'")
            except OSError as e:
                LOG.exception(e)
                record(path, ok=False, error=str(e), action=action, extra={"item": params})
                continue
            else:
                record(path, ok=True, action=action, extra={"deleted": True})

        if "move" == action:
            new_path = params.get("new_path")
            if not new_path:
                record(path, ok=False, error="New path is required for move.", action=action, extra={"item": params})
                continue

            raw_new: str = unquote_plus(str(new_path)).strip()
            target_dir = (
                Path(config.download_path)
                if not raw_new or raw_new in ("/", ".")
                else Path(config.download_path).joinpath(raw_new.lstrip("/"))
            )

            try:
                target_dir = target_dir.resolve()
            except Exception:
                record(
                    path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
                )
                continue

            root_real = Path(config.download_path).resolve()
            if not target_dir.exists() or not target_dir.is_dir():
                record(
                    path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
                )
                continue

            if not target_dir.is_relative_to(root_real):
                record(
                    path,
                    ok=False,
                    error="Destination outside download root.",
                    action=action,
                    extra={"new_path": target_dir},
                )
                continue

            if path.parent == target_dir:
                record(path, ok=False, error="Source and destination are the same.", action=action)
                continue

            try:
                dest = target_dir.joinpath(path.name)
                path.rename(dest)
            except OSError as e:
                LOG.exception(e)
                record(path, ok=False, error=str(e), action=action, extra={"item": params})
                continue
            else:
                record(path, ok=True, action=action, extra={"new_path": dest})

    return web.json_response(data=operations_status, status=web.HTTPOk.status_code)


@route("POST", "api/file/download", "browser.download.prepare")
async def prepare_zip_file(request: Request, config: Config, cache: Cache):
    json = await request.json()
    if not json or not isinstance(json, list):
        return web.json_response({"error": "Invalid parameters."}, status=400)

    files: list[str] = []
    for f in json:
        if not isinstance(f, str):
            continue

        ref, status = get_file(download_path=config.download_path, file=f)
        if status == web.HTTPNotFound.status_code:
            continue
        files.append(ref)

        sc: list[dict] = get_file_sidecar(ref)
        if sc:
            for side in sc:
                for scf in sc[side]:
                    if isinstance(scf, dict) and "file" in scf:
                        files.append(scf["file"])  # noqa: PERF401

    if not files:
        return web.json_response({"error": "No valid files."}, status=400)

    import uuid

    token = str(uuid.uuid4())

    cache.set(f"download:{token}", files, ttl=600)

    return web.json_response(
        data={"token": token, "files": [str(f.relative_to(config.download_path)) for f in files]},
        status=web.HTTPOk.status_code,
    )


@route("GET", "api/file/download/{token}", "browser.download.stream")
async def stream_zip_download(request: Request, config: Config, cache: Cache) -> Response | web.StreamResponse:
    token: str | None = request.match_info.get("token")
    if not token:
        return web.json_response({"error": "Download token is required."}, status=web.HTTPBadRequest.status_code)

    files: Any | None = cache.get(f"download:{token}")

    if not files or not isinstance(files, list):
        return web.json_response({"error": "Invalid or expired download token."}, status=web.HTTPBadRequest.status_code)

    files: list[Path] = [p for p in files if p.is_file() and p.exists()]

    if len(files) < 1:
        return web.json_response({"error": "No valid files."}, status=web.HTTPBadRequest.status_code)

    from zipstream import ZipStream

    rootPath = Path(config.download_path).resolve()
    zs = ZipStream()
    for file in files:
        zs.add_path(str(file), str(file.relative_to(rootPath)))

    response = web.StreamResponse(
        status=200,
        headers={
            "Content-Type": "application/zip",
            "Content-Disposition": f'attachment; filename="{token}.zip"',
        },
    )
    await response.prepare(request)

    try:
        LOG.info(f"Streaming zip download for token: '{token}', files: {len(files)}")
        for chunk in zs:
            if request.transport is None or request.transport.is_closing():
                LOG.info("Client disconnected, aborting zip download.")
                break
            await response.write(chunk)
        await response.write_eof()
    except asyncio.CancelledError:
        LOG.info("Download cancelled by client.")
    except Exception as e:
        LOG.error(f"Streaming zip download error. {type(e).__name__}: {e}")
    finally:
        return response  # noqa: B012
