"""
Folder Library plugin — routes.py

Surfaces the DLC folder structure as a navigable tree and provides in-app
folder management (create / rename / delete) and song moves. Every filesystem
mutation is confined to DLC_DIR and validated against path traversal.
"""

from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
import shutil
import re


# ── Pure, testable helpers ─────────────────────────────────────────────────

_UNSAFE_NAME_RE = re.compile(r'[\\/:*?"<>|]')


def _safe_name(name: str) -> bool:
    """A single path segment is safe: no separators, no traversal dot-names,
    no surrounding whitespace, no characters illegal across filesystems."""
    if not name or name.strip() != name:
        return False
    if _UNSAFE_NAME_RE.search(name):
        return False
    if name in (".", ".."):
        return False
    return True


def _safe_path(path_str: str) -> bool:
    """A slash-separated path is safe iff every segment is a safe name."""
    if not path_str:
        return False
    return all(_safe_name(p) for p in path_str.split("/"))


def _is_within(root: Path, candidate: Path) -> bool:
    """True iff ``candidate`` resolves to a location inside ``root`` (after
    normalising ``..`` and symlinks). Containment backstop for file moves so a
    crafted filename can't escape DLC_DIR even past the segment validator."""
    try:
        candidate.resolve().relative_to(root.resolve())
        return True
    except (ValueError, OSError):
        return False


def _path_to_dir(root: Path, folder_path: str) -> Path:
    """Resolve a slash-separated folder path relative to ``root``."""
    result = root
    for part in folder_path.split("/"):
        result = result / part
    return result


def _load_is_loose_song():
    """The host's authoritative loose-folder predicate (lib/loosefolder.py),
    imported lazily so the plugin still loads if it's ever unavailable. A
    loose-folder song is a directory carrying audio + an arrangement XML rather
    than a ``.sloppak`` bundle, so the plain suffix check below misses it."""
    try:
        from loosefolder import is_loose_song
        return is_loose_song
    except Exception:
        return None


_IS_LOOSE_SONG = _load_is_loose_song()


def _is_song(p: Path) -> bool:
    """A song carrier is a ``.sloppak`` / ``.feedpak`` file or directory-form
    bundle (extension on the leaf name), or a host-recognised loose-folder song
    directory — so loose-folder charts surface in the tree like any other song
    instead of being walked into as if they were ordinary folders."""
    if p.suffix.lower() in (".sloppak", ".feedpak"):
        return True
    if _IS_LOOSE_SONG is not None and p.is_dir():
        try:
            return bool(_IS_LOOSE_SONG(p))
        except Exception:
            return False
    return False


def setup(app, context):
    log = context["log"]
    router = APIRouter(prefix="/api/plugins/folder_library")

    # ── Two-level cache ────────────────────────────────────────────────
    # _meta_cache  — expensive extract_meta() results keyed by abs path
    #                (as_posix() string).  Never cleared; keys are updated
    #                in-place when files are moved so the data stays valid.
    # _cache       — tree structure ("folders" / "root_songs").  Cleared on
    #                every mutation so the next /tree request rebuilds it —
    #                but that rebuild is now fast because _meta_cache is warm.
    _cache      = {}   # "tree" → JSONResponse-ready dict
    _meta_cache = {}   # abs_posix_path → extracted meta (no filename/added)

    def _invalidate():
        """Clear the tree structure cache only.  _meta_cache is preserved."""
        _cache.clear()

    def _dlc_root() -> Path | None:
        try:
            return Path(context["get_dlc_dir"]())
        except Exception:
            return None

    def _scan_root(dlc: Path) -> Path:
        sloppak = dlc / "sloppak"
        return sloppak if sloppak.exists() else dlc

    def _meta(p: Path, dlc: Path) -> dict:
        # filename and added are always computed fresh — they change when files move.
        try:
            filename = "/".join(p.relative_to(dlc).parts)
        except ValueError:
            filename = p.name
        added = None
        try:
            added = p.stat().st_mtime
        except Exception:
            pass

        # Return cached extracted metadata if available.
        cache_key = p.as_posix()
        if cache_key in _meta_cache:
            m = dict(_meta_cache[cache_key])   # shallow copy
            m["filename"] = filename
            m["added"]    = added
            return m

        # Cache miss — run the expensive extract.
        m = {"title": None, "artist": None, "album": None, "duration": None,
             "year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False}
        try:
            raw = context["extract_meta"](p)
            if raw:
                m["title"]    = raw.get("title")    or raw.get("name")
                m["artist"]   = raw.get("artist")   or raw.get("artistName")
                m["album"]    = raw.get("album")     or raw.get("albumName")
                m["duration"] = raw.get("duration")
                m["year"]     = raw.get("year")
                m["tuning"]   = raw.get("tuning")

                # arrangements — objects with a "name" key e.g. [{name:"Lead",...}, ...]
                raw_arr = raw.get("arrangements") or []
                if isinstance(raw_arr, (list, tuple)):
                    m["arrangements"] = [
                        a["name"] if isinstance(a, dict) else str(a)
                        for a in raw_arr
                        if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
                    ]

                # stems — may also be objects with a "name" key, same as arrangements
                raw_stems = raw.get("stems") or []
                for _key in ("stems", "stem_types", "available_stems", "stemTypes"):
                    _v = raw.get(_key)
                    if _v:
                        raw_stems = _v
                        break
                if isinstance(raw_stems, (list, tuple)):
                    m["stems"] = [
                        a["name"] if isinstance(a, dict) else str(a)
                        for a in raw_stems
                        if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
                    ]

                # lyrics — try common key variants
                for _key in ("lyrics", "hasLyrics", "has_lyrics", "lyric", "hasLyric"):
                    _val = raw.get(_key)
                    if _val is not None:
                        if isinstance(_val, str):
                            m["lyrics"] = _val.lower() not in ("", "false", "no", "0")
                        else:
                            m["lyrics"] = bool(_val)
                        break
        except Exception as exc:
            log.debug("meta failed for %s: %s", p.name, exc)
        if not m["title"]:
            m["title"] = p.stem

        _meta_cache[cache_key] = m      # store without filename/added
        result = dict(m)
        result["filename"] = filename
        result["added"]    = added
        return result

    def _scan_dir(path: Path, root: Path, dlc: Path) -> dict:
        """Recursively scan a directory and return a folder node."""
        songs = []
        children = []
        try:
            for entry in sorted(path.iterdir(), key=lambda p: p.name.lower()):
                if entry.name.startswith("."):
                    continue
                if _is_song(entry):
                    songs.append(_meta(entry, dlc))
                elif entry.is_dir():
                    children.append(_scan_dir(entry, root, dlc))
        except PermissionError:
            log.warning("permission denied: %s", path)
        try:
            rel = path.relative_to(root)
            folder_path = "/".join(rel.parts)
        except ValueError:
            folder_path = path.name
        return {
            "name": path.name,
            "path": folder_path,
            "songs": songs,
            "children": children,
        }

    def _apply_tree_filters(tree, arrangements_has="", arrangements_lacks="",
                            stems_has="", stems_lacks="", has_lyrics="", tunings=""):
        """Filter a cached tree dict by arrangement/stem/lyrics/tuning params.
        The cache always holds the full unfiltered tree; this is applied per-request."""
        def _split(s):
            return [x.strip().lower() for x in s.split(",") if x.strip()] if s else []

        arr_has   = _split(arrangements_has)
        arr_lacks = _split(arrangements_lacks)
        st_has    = _split(stems_has)
        st_lacks  = _split(stems_lacks)
        tun_set   = set(_split(tunings))
        lyr       = None if has_lyrics == "" else (has_lyrics == "1")

        if not any([arr_has, arr_lacks, st_has, st_lacks, tun_set, lyr is not None]):
            return tree  # no filters active — return as-is

        def _song_ok(s):
            arrs = [a.lower() for a in (s.get("arrangements") or [])]
            stms = [x.lower() for x in (s.get("stems") or [])]
            if arr_has   and not any(a in arrs for a in arr_has):   return False
            if arr_lacks and     any(a in arrs for a in arr_lacks): return False
            if st_has    and not any(x in stms for x in st_has):    return False
            if st_lacks  and     any(x in stms for x in st_lacks):  return False
            if lyr is not None and bool(s.get("lyrics")) != lyr:    return False
            if tun_set and (s.get("tuning") or "").lower() not in tun_set: return False
            return True

        def _filter_node(node):
            return {
                "name": node["name"],
                "path": node["path"],
                "songs": [s for s in node["songs"] if _song_ok(s)],
                "children": [_filter_node(c) for c in node.get("children", [])],
            }

        return {
            "folders": [_filter_node(f) for f in tree["folders"]],
            "root_songs": [s for s in tree["root_songs"] if _song_ok(s)],
        }

    @router.get("/tree")
    def get_tree(
        arrangements_has:   str = "",
        arrangements_lacks: str = "",
        stems_has:          str = "",
        stems_lacks:        str = "",
        has_lyrics:         str = "",
        tunings:            str = "",
    ):
        if "tree" not in _cache:
            dlc = _dlc_root()
            if not dlc or not dlc.exists():
                return JSONResponse({"folders": [], "root_songs": [],
                                     "error": "DLC directory not found"})
            root = _scan_root(dlc)
            log.info("folder_library: scanning %s", root)
            folders = []
            root_songs = []
            try:
                for entry in sorted(root.iterdir(), key=lambda p: p.name.lower()):
                    if entry.name.startswith("."):
                        continue
                    if _is_song(entry):
                        root_songs.append(_meta(entry, dlc))
                    elif entry.is_dir():
                        folders.append(_scan_dir(entry, root, dlc))
            except PermissionError:
                return JSONResponse({"folders": [], "root_songs": [],
                                     "error": "Permission denied"})
            _cache["tree"] = {"folders": folders, "root_songs": root_songs}

        result = _apply_tree_filters(
            _cache["tree"], arrangements_has, arrangements_lacks,
            stems_has, stems_lacks, has_lyrics, tunings,
        )
        return JSONResponse(result)

    @router.post("/folder/create")
    async def create_folder(request: Request):
        body = await request.json()
        name = (body.get("name") or "").strip()
        parent = (body.get("parent") or "").strip()
        if not _safe_name(name):
            return JSONResponse({"error": "Invalid folder name"}, status_code=400)
        if parent and not _safe_path(parent):
            return JSONResponse({"error": "Invalid parent path"}, status_code=400)
        dlc = _dlc_root()
        if not dlc:
            return JSONResponse({"error": "DLC dir not found"}, status_code=500)
        root = _scan_root(dlc)
        parent_dir = _path_to_dir(root, parent) if parent else root
        if parent and not parent_dir.exists():
            return JSONResponse({"error": "Parent folder not found"}, status_code=404)
        target = parent_dir / name
        if target.exists():
            return JSONResponse({"error": "Folder already exists"}, status_code=400)
        try:
            target.mkdir(parents=False)
            _invalidate()
            return JSONResponse({"ok": True})
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    @router.post("/folder/rename")
    async def rename_folder(request: Request):
        body = await request.json()
        old = (body.get("old") or "").strip()
        new = (body.get("new") or "").strip()
        if not _safe_path(old) or not _safe_name(new):
            return JSONResponse({"error": "Invalid folder name"}, status_code=400)
        dlc = _dlc_root()
        if not dlc:
            return JSONResponse({"error": "DLC dir not found"}, status_code=500)
        root = _scan_root(dlc)
        src = _path_to_dir(root, old)
        dst = src.parent / new  # rename within the same parent
        if not src.exists():
            return JSONResponse({"error": "Folder not found"}, status_code=404)
        if dst.exists():
            return JSONResponse({"error": "Name already taken"}, status_code=400)
        try:
            # Pre-compute meta cache key updates (keys change because the
            # folder path changes — all files under src get a new prefix).
            old_prefix = src.as_posix() + "/"
            new_prefix = dst.as_posix() + "/"
            meta_updates = {
                key: new_prefix + key[len(old_prefix):]
                for key in list(_meta_cache)
                if key.startswith(old_prefix)
            }
            src.rename(dst)
            _invalidate()
            for old_key, new_key in meta_updates.items():
                if old_key in _meta_cache:
                    _meta_cache[new_key] = _meta_cache.pop(old_key)
            return JSONResponse({"ok": True})
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    @router.post("/folder/delete")
    async def delete_folder(request: Request):
        body = await request.json()
        name = (body.get("name") or "").strip()
        if not _safe_path(name):
            return JSONResponse({"error": "Invalid folder path"}, status_code=400)
        dlc = _dlc_root()
        if not dlc:
            return JSONResponse({"error": "DLC dir not found"}, status_code=500)
        root = _scan_root(dlc)
        target = _path_to_dir(root, name)
        if not target.exists():
            return JSONResponse({"error": "Folder not found"}, status_code=404)
        try:
            # Relocate every song (at any depth) up to the scan root BEFORE
            # removing the folder. Colliding filenames are de-duplicated so a
            # name clash never leaves a song behind to be destroyed by rmtree
            # (the folder is advertised as "moves its songs to Unsorted").
            for song_path in sorted(target.rglob("*")):
                if not song_path.exists():
                    continue  # a parent song-dir was already relocated
                if not _is_song(song_path):
                    continue
                old_key = song_path.as_posix()
                dest = root / song_path.name
                if dest.exists():
                    stem, suffix = song_path.stem, song_path.suffix
                    n = 1
                    while dest.exists():
                        dest = root / f"{stem} ({n}){suffix}"
                        n += 1
                song_path.rename(dest)
                if old_key in _meta_cache:
                    _meta_cache[dest.as_posix()] = _meta_cache.pop(old_key)
            shutil.rmtree(target)
            _invalidate()
            return JSONResponse({"ok": True})
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    @router.post("/song/move")
    async def move_song(request: Request):
        body = await request.json()
        filename = (body.get("filename") or "").strip()
        dest_folder = (body.get("folder") or "").strip()
        # Validate the source path like the folder ops, AND confirm it resolves
        # inside DLC_DIR — without this a filename such as "../../etc/passwd"
        # would be renamed (moved) into the served library and become readable.
        if not filename or not _safe_path(filename):
            return JSONResponse({"error": "Invalid filename"}, status_code=400)
        dlc = _dlc_root()
        if not dlc:
            return JSONResponse({"error": "DLC dir not found"}, status_code=500)
        src = dlc / Path(*filename.split("/"))
        if not _is_within(dlc, src):
            return JSONResponse({"error": "Invalid filename"}, status_code=400)
        if not src.exists():
            return JSONResponse({"error": "Song not found"}, status_code=404)
        root = _scan_root(dlc)
        if dest_folder:
            if not _safe_path(dest_folder):
                return JSONResponse({"error": "Invalid folder path"}, status_code=400)
            dst_dir = _path_to_dir(root, dest_folder)
            if not dst_dir.exists():
                return JSONResponse({"error": "Destination folder not found"}, status_code=404)
        else:
            dst_dir = root
        dst = dst_dir / src.name
        if dst.exists():
            return JSONResponse({"error": "File already exists at destination"}, status_code=400)
        try:
            old_key = src.as_posix()
            src.rename(dst)
            if old_key in _meta_cache:
                _meta_cache[dst.as_posix()] = _meta_cache.pop(old_key)
            _invalidate()
            return JSONResponse({"ok": True})
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    app.include_router(router)
    log.info("folder_library routes registered")
