from __future__ import annotations

import json
import mimetypes
import os
import traceback
from pathlib import Path
from typing import Any

try:
    import gradio as gr
    from fastapi import HTTPException, Request
    from fastapi.responses import FileResponse
    from modules import script_callbacks, shared

    try:
        from modules.options import OptionInfo
    except Exception:  # pragma: no cover - older WebUI variants expose it via shared.
        OptionInfo = shared.OptionInfo
except Exception:
    gr = None
    HTTPException = None
    Request = Any  # type: ignore[assignment]
    FileResponse = None
    script_callbacks = None
    shared = None
    OptionInfo = None
    print("[Sound Player] WebUI modules are not available:\n" + traceback.format_exc())


EXTENSION_NAME = "Sound Player"
EXTENSION_DIR = Path(__file__).resolve().parents[1]
DATA_DIR = EXTENSION_DIR / "data"
DEFAULT_AUDIO_DIR = DATA_DIR / "audio"
STATE_PATH = DATA_DIR / "player_state.json"
CONFIG_ROUTE = "/sd-webui-sound-player/config"
STATE_ROUTE = "/sd-webui-sound-player/state"
AUDIO_ROUTE = "/sd-webui-sound-player/audio"
BROWSE_ROUTE = "/sd-webui-sound-player/browse-folder"
ALLOWED_EXTENSIONS = {".mp3", ".wav"}
MAX_SCAN_FILES = 3000


def _warn(message: str) -> None:
    print(f"[{EXTENSION_NAME}] {message}")


def _ensure_data_dirs() -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    DEFAULT_AUDIO_DIR.mkdir(parents=True, exist_ok=True)


def _option_value(key: str, default: Any) -> Any:
    try:
        return getattr(shared.opts, key, default)
    except Exception:
        return default


def _expand_path(value: Any) -> Path:
    text = str(value or "").strip()
    if not text:
        text = str(DEFAULT_AUDIO_DIR)
    expanded = os.path.expandvars(os.path.expanduser(text))
    return Path(expanded).resolve()


def _audio_dir() -> Path:
    return _expand_path(_option_value("fmp_audio_dir", str(DEFAULT_AUDIO_DIR)))


def _recursive_scan() -> bool:
    return bool(_option_value("fmp_scan_recursive", True))


def _path_is_relative_to(path: Path, parent: Path) -> bool:
    try:
        path.relative_to(parent)
        return True
    except ValueError:
        return False


def _safe_audio_path(relative_file: str) -> Path:
    root = _audio_dir()
    if not root.is_dir():
        raise HTTPException(status_code=404, detail="Audio folder is not available")

    if not relative_file or "\x00" in relative_file:
        raise HTTPException(status_code=400, detail="Invalid audio file")

    if Path(relative_file).is_absolute() or ":" in relative_file.replace("\\", "/").split("/", 1)[0]:
        raise HTTPException(status_code=400, detail="Audio file must be relative")

    candidate = (root / relative_file).resolve()
    root_resolved = root.resolve()
    if not _path_is_relative_to(candidate, root_resolved):
        raise HTTPException(status_code=403, detail="Audio file is outside the configured folder")

    if candidate.suffix.lower() not in ALLOWED_EXTENSIONS or not candidate.is_file():
        raise HTTPException(status_code=404, detail="Audio file was not found")

    return candidate


def _list_audio_files() -> list[dict[str, Any]]:
    root = _audio_dir()
    if not root.is_dir():
        return []

    files: list[dict[str, Any]] = []
    iterator = root.rglob("*") if _recursive_scan() else root.glob("*")
    root_resolved = root.resolve()

    for path in iterator:
        if len(files) >= MAX_SCAN_FILES:
            break
        try:
            if not path.is_file() or path.suffix.lower() not in ALLOWED_EXTENSIONS:
                continue
            resolved = path.resolve()
            if not _path_is_relative_to(resolved, root_resolved):
                continue
            relative = resolved.relative_to(root_resolved).as_posix()
            stat = resolved.stat()
            files.append(
                {
                    "value": relative,
                    "label": relative,
                    "name": resolved.name,
                    "size": stat.st_size,
                    "mtime": stat.st_mtime,
                }
            )
        except Exception:
            _warn(f"Skipped unreadable audio file: {path}")

    files.sort(key=lambda item: str(item["label"]).casefold())
    return files


def _default_state() -> dict[str, Any]:
    return {
        "slots": [
            {
                "id": "slot-1",
                "file": "",
                "loop": True,
                "volume": 0.75,
                "currentTime": 0,
            }
        ],
        "panel": {
            "open": False,
            "left": None,
            "top": None,
            "dragged": False,
        },
    }


def _sanitize_slot(slot: Any, index: int) -> dict[str, Any]:
    if not isinstance(slot, dict):
        slot = {}

    slot_id = str(slot.get("id") or f"slot-{index + 1}")[:80]
    file_value = str(slot.get("file") or "").replace("\\", "/")[:1000]
    loop = bool(slot.get("loop", True))

    try:
        volume = float(slot.get("volume", 0.75))
    except Exception:
        volume = 0.75
    volume = max(0.0, min(1.0, volume))

    try:
        current_time = float(slot.get("currentTime", 0))
    except Exception:
        current_time = 0
    current_time = max(0.0, min(60 * 60 * 24, current_time))

    return {
        "id": slot_id,
        "file": file_value,
        "loop": loop,
        "volume": volume,
        "currentTime": current_time,
    }


def _sanitize_state(state: Any) -> dict[str, Any]:
    if not isinstance(state, dict):
        state = {}

    slots = state.get("slots")
    if not isinstance(slots, list) or not slots:
        slots = _default_state()["slots"]
    sanitized_slots = [_sanitize_slot(slot, index) for index, slot in enumerate(slots[:8])]

    panel = state.get("panel")
    if not isinstance(panel, dict):
        panel = {}

    def number_or_none(value: Any) -> float | None:
        try:
            number = float(value)
        except Exception:
            return None
        if number < 0 or number > 10000:
            return None
        return number

    return {
        "slots": sanitized_slots,
        "panel": {
            "open": bool(panel.get("open", False)),
            "left": number_or_none(panel.get("left")),
            "top": number_or_none(panel.get("top")),
            "dragged": bool(panel.get("dragged", False)),
        },
    }


def _read_state() -> dict[str, Any]:
    _ensure_data_dirs()
    if not STATE_PATH.exists():
        return _default_state()
    try:
        return _sanitize_state(json.loads(STATE_PATH.read_text(encoding="utf-8")))
    except Exception:
        _warn("Could not read saved player state:\n" + traceback.format_exc())
        return _default_state()


def _write_state(state: dict[str, Any]) -> None:
    _ensure_data_dirs()
    STATE_PATH.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")


def get_config() -> dict[str, Any]:
    audio_dir = _audio_dir()
    audio_dir_valid = audio_dir.is_dir()
    files = _list_audio_files()
    return {
        "audio_dir": str(audio_dir),
        "audio_dir_valid": audio_dir_valid,
        "recursive": _recursive_scan(),
        "allowed_extensions": sorted(ALLOWED_EXTENSIONS),
        "files": files,
        "truncated": len(files) >= MAX_SCAN_FILES,
        "state": _read_state(),
    }


def _save_audio_options(audio_dir: str, recursive: bool) -> None:
    shared.opts.data["fmp_audio_dir"] = str(_expand_path(audio_dir))
    shared.opts.data["fmp_scan_recursive"] = recursive
    if hasattr(shared.opts, "save") and hasattr(shared, "config_filename"):
        shared.opts.save(shared.config_filename)


async def save_config(request: Request) -> dict[str, Any]:
    try:
        payload = await request.json()
    except Exception:
        payload = {}

    audio_dir = str(payload.get("audio_dir") or "").strip()
    recursive = bool(payload.get("recursive", True))

    if not audio_dir:
        audio_dir = str(DEFAULT_AUDIO_DIR)

    try:
        _save_audio_options(audio_dir, recursive)
    except Exception:
        _warn("Could not save sound player settings:\n" + traceback.format_exc())

    return get_config()


async def browse_folder(request: Request) -> dict[str, Any]:
    try:
        payload = await request.json()
    except Exception:
        payload = {}

    recursive = bool(payload.get("recursive", _recursive_scan()))
    initial_dir = str(payload.get("audio_dir") or _audio_dir())

    try:
        import tkinter as tk
        from tkinter import filedialog

        initial_path = _expand_path(initial_dir)
        if not initial_path.is_dir():
            initial_path = Path.home()

        root = tk.Tk()
        try:
            root.withdraw()
            root.attributes("-topmost", True)
            selected = filedialog.askdirectory(
                title="Sound Player audio folder",
                initialdir=str(initial_path),
                mustexist=True,
            )
        finally:
            root.destroy()
    except Exception:
        _warn("Could not open folder picker:\n" + traceback.format_exc())
        result = get_config()
        result["folder_picker_error"] = True
        return result

    if not selected:
        result = get_config()
        result["folder_picker_canceled"] = True
        return result

    try:
        _save_audio_options(selected, recursive)
    except Exception:
        _warn("Could not save selected sound folder:\n" + traceback.format_exc())

    return get_config()


async def save_state(request: Request) -> dict[str, Any]:
    try:
        payload = await request.json()
    except Exception:
        payload = {}

    state = _sanitize_state(payload)
    _write_state(state)
    return {"ok": True, "state": state}


def stream_audio(file: str) -> Any:
    path = _safe_audio_path(file)
    media_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
    return FileResponse(path, media_type=media_type, filename=path.name)


def _option_info(default: Any, label: str, component: Any, component_args: dict[str, Any] | None, info_text: str):
    kwargs = {
        "component": component,
        "component_args": component_args or {},
        "section": ("sd-webui-sound-player", "Sound Player"),
        "category_id": "ui",
    }

    try:
        info = OptionInfo(default, label, **kwargs)
    except TypeError:
        kwargs.pop("category_id", None)
        info = OptionInfo(default, label, **kwargs)

    if hasattr(info, "info"):
        info.info(info_text)
    else:
        info.comment_after += f"<span class='info'>({info_text})</span>"

    return info


def on_ui_settings() -> None:
    if gr is None or shared is None or OptionInfo is None:
        return

    try:
        shared.opts.add_option(
            "fmp_audio_dir",
            _option_info(
                str(DEFAULT_AUDIO_DIR),
                "Sound player audio folder",
                gr.Textbox,
                {"placeholder": r"G:\Data\Music", "lines": 1},
                "mp3 / wav files in this folder are shown in the floating music player.",
            ),
        )
        shared.opts.add_option(
            "fmp_scan_recursive",
            _option_info(
                True,
                "Sound player scans subfolders",
                gr.Checkbox,
                {},
                "When enabled, files in subfolders are shown with their relative paths.",
            ),
        )
    except Exception:
        _warn("Failed to register settings:\n" + traceback.format_exc())


def _route_exists(app: Any, path: str, methods: set[str]) -> bool:
    for route in getattr(app, "routes", []):
        if getattr(route, "path", None) != path:
            continue
        route_methods = set(getattr(route, "methods", []) or [])
        if methods.issubset(route_methods):
            return True
    return False


def on_app_started(_demo: Any, app: Any) -> None:
    if HTTPException is None or FileResponse is None:
        return

    try:
        _ensure_data_dirs()
        if not _route_exists(app, CONFIG_ROUTE, {"GET"}):
            app.add_api_route(CONFIG_ROUTE, get_config, methods=["GET"])
        if not _route_exists(app, CONFIG_ROUTE, {"POST"}):
            app.add_api_route(CONFIG_ROUTE, save_config, methods=["POST"])
        if not _route_exists(app, STATE_ROUTE, {"POST"}):
            app.add_api_route(STATE_ROUTE, save_state, methods=["POST"])
        if not _route_exists(app, AUDIO_ROUTE, {"GET"}):
            app.add_api_route(AUDIO_ROUTE, stream_audio, methods=["GET"])
        if not _route_exists(app, BROWSE_ROUTE, {"POST"}):
            app.add_api_route(BROWSE_ROUTE, browse_folder, methods=["POST"])
    except Exception:
        _warn("Failed to register API routes:\n" + traceback.format_exc())


if script_callbacks is not None:
    try:
        try:
            script_callbacks.on_ui_settings(on_ui_settings, name="sound_player_settings")
            script_callbacks.on_app_started(on_app_started, name="sound_player_api")
        except TypeError:
            script_callbacks.on_ui_settings(on_ui_settings)
            script_callbacks.on_app_started(on_app_started)
    except Exception:
        _warn("Failed to register callbacks:\n" + traceback.format_exc())
