"""Bookmark, save/library, batch download, and blacklist operations."""

from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING

from ..core.cache import download_image

if TYPE_CHECKING:
    from .main_window import BooruApp

log = logging.getLogger("booru")


# Pure functions

def is_batch_message(msg: str) -> bool:
    """Detect batch progress messages like 'Saved 3/10 to Unfiled'."""
    return "/" in msg and any(c.isdigit() for c in msg.split("/")[0][-2:])

def is_in_library(path: Path, saved_root: Path) -> bool:
    """Check if path is inside the library root."""
    try:
        return path.is_relative_to(saved_root)
    except (TypeError, ValueError):
        return False


class PostActionsController:
    def __init__(self, app: BooruApp) -> None:
        self._app = app
        self._batch_dest: Path | None = None

    def on_bookmark_error(self, e: str) -> None:
        self._app._status.showMessage(f"Error: {e}")

    def is_post_saved(self, post_id: int) -> bool:
        return self._app._db.is_post_in_library(post_id)

    def _maybe_unbookmark(self, post) -> None:
        """Remove the bookmark for *post* if the unbookmark-on-save setting is on.

        Handles DB removal, grid thumbnail dot, preview state, bookmarks
        tab refresh, and popout sync in one place so every save path
        (single, bulk, Save As, batch download) can call it.
        """
        if not self._app._db.get_setting_bool("unbookmark_on_save"):
            return
        site_id = (
            self._app._preview._current_site_id
            or self._app._site_combo.currentData()
        )
        if not site_id or not self._app._db.is_bookmarked(site_id, post.id):
            return
        self._app._db.remove_bookmark(site_id, post.id)
        # Update grid thumbnail bookmark dot
        for i, p in enumerate(self._app._posts):
            if p.id == post.id and i < len(self._app._grid._thumbs):
                self._app._grid._thumbs[i].set_bookmarked(False)
                break
        # Update preview and popout
        if (self._app._preview._current_post
                and self._app._preview._current_post.id == post.id):
            self._app._preview.update_bookmark_state(False)
        self._app._popout_ctrl.update_state()
        # Refresh bookmarks tab if visible
        if self._app._stack.currentIndex() == 1:
            self._app._bookmarks_view.refresh()

    def get_preview_post(self):
        idx = self._app._grid.selected_index
        if 0 <= idx < len(self._app._posts):
            return self._app._posts[idx], idx
        if self._app._preview._current_post:
            return self._app._preview._current_post, -1
        return None, -1

    def bookmark_from_preview(self) -> None:
        post, idx = self.get_preview_post()
        if not post:
            return
        site_id = self._app._preview._current_site_id or self._app._site_combo.currentData()
        if not site_id:
            return
        if idx >= 0:
            self.toggle_bookmark(idx)
        else:
            if self._app._db.is_bookmarked(site_id, post.id):
                self._app._db.remove_bookmark(site_id, post.id)
            else:
                from ..core.cache import cached_path_for
                cached = cached_path_for(post.file_url)
                self._app._db.add_bookmark(
                    site_id=site_id, post_id=post.id,
                    file_url=post.file_url, preview_url=post.preview_url or "",
                    tags=post.tags, rating=post.rating, score=post.score,
                    source=post.source, cached_path=str(cached) if cached.exists() else None,
                    tag_categories=post.tag_categories,
                )
        bookmarked = bool(self._app._db.is_bookmarked(site_id, post.id))
        self._app._preview.update_bookmark_state(bookmarked)
        self._app._popout_ctrl.update_state()
        if self._app._stack.currentIndex() == 1:
            self._app._bookmarks_view.refresh()

    def bookmark_to_folder_from_preview(self, folder: str) -> None:
        """Bookmark the current preview post into a specific bookmark folder.

        Triggered by the toolbar Bookmark-as submenu, which only shows
        when the post is not yet bookmarked -- so this method only handles
        the create path, never the move/remove paths. Empty string means
        Unfiled. Brand-new folder names get added to the DB folder list
        first so the bookmarks tab combo immediately shows them.
        """
        post, idx = self.get_preview_post()
        if not post:
            return
        site_id = self._app._preview._current_site_id or self._app._site_combo.currentData()
        if not site_id:
            return
        target = folder if folder else None
        if target and target not in self._app._db.get_folders():
            try:
                self._app._db.add_folder(target)
            except ValueError as e:
                self._app._status.showMessage(f"Invalid folder name: {e}")
                return
        if idx >= 0:
            # In the grid -- go through toggle_bookmark so the grid
            # thumbnail's bookmark badge updates via on_bookmark_done.
            self.toggle_bookmark(idx, target)
        else:
            # Preview-only post (e.g. opened from the bookmarks tab while
            # browse is empty). Inline the add -- no grid index to update.
            from ..core.cache import cached_path_for
            cached = cached_path_for(post.file_url)
            self._app._db.add_bookmark(
                site_id=site_id, post_id=post.id,
                file_url=post.file_url, preview_url=post.preview_url or "",
                tags=post.tags, rating=post.rating, score=post.score,
                source=post.source,
                cached_path=str(cached) if cached.exists() else None,
                folder=target,
                tag_categories=post.tag_categories,
            )
            where = target or "Unfiled"
            self._app._status.showMessage(f"Bookmarked #{post.id} to {where}")
        self._app._preview.update_bookmark_state(True)
        self._app._popout_ctrl.update_state()
        # Refresh bookmarks tab if visible so the new entry appears.
        if self._app._stack.currentIndex() == 1:
            self._app._bookmarks_view.refresh()

    def save_from_preview(self, folder: str) -> None:
        post, idx = self.get_preview_post()
        if post:
            target = folder if folder else None
            self.save_to_library(post, target)

    def toggle_save_from_preview(self) -> None:
        """Toggle library save: unsave if already saved, save to Unfiled otherwise."""
        post, _ = self.get_preview_post()
        if not post:
            return
        if self.is_post_saved(post.id):
            self.unsave_from_preview()
        else:
            self.save_from_preview("")

    def unsave_from_preview(self) -> None:
        post, idx = self.get_preview_post()
        if not post:
            return
        # delete_from_library walks every library folder by post id and
        # deletes every match in one call -- no folder hint needed. Pass
        # db so templated filenames also get unlinked AND the meta row
        # gets cleaned up.
        from ..core.cache import delete_from_library
        deleted = delete_from_library(post.id, db=self._app._db)
        if deleted:
            self._app._status.showMessage(f"Removed #{post.id} from library")
            self._app._preview.update_save_state(False)
            # Update browse grid thumbnail saved dot
            for i, p in enumerate(self._app._posts):
                if p.id == post.id and i < len(self._app._grid._thumbs):
                    self._app._grid._thumbs[i].set_saved_locally(False)
                    break
            # Update bookmarks grid thumbnail
            bm_grid = self._app._bookmarks_view._grid
            for i, fav in enumerate(self._app._bookmarks_view._bookmarks):
                if fav.post_id == post.id and i < len(bm_grid._thumbs):
                    bm_grid._thumbs[i].set_saved_locally(False)
                    break
            # Refresh library tab if visible
            if self._app._stack.currentIndex() == 2:
                self._app._library_view.refresh()
        else:
            self._app._status.showMessage(f"#{post.id} not in library")
        self._app._popout_ctrl.update_state()

    def blacklist_tag_from_popout(self, tag: str) -> None:
        from PySide6.QtWidgets import QMessageBox
        reply = QMessageBox.question(
            self._app, "Blacklist Tag",
            f"Blacklist tag \"{tag}\"?\nPosts with this tag will be hidden.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if reply != QMessageBox.StandardButton.Yes:
            return
        self._app._db.add_blacklisted_tag(tag)
        self._app._db.set_setting("blacklist_enabled", "1")
        self._app._status.showMessage(f"Blacklisted: {tag}")
        self._app._search_ctrl.remove_blacklisted_from_grid(tag=tag)

    def blacklist_post_from_popout(self) -> None:
        post, idx = self.get_preview_post()
        if post:
            from PySide6.QtWidgets import QMessageBox
            reply = QMessageBox.question(
                self._app, "Blacklist Post",
                f"Blacklist post #{post.id}?\nThis post will be hidden from results.",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            )
            if reply != QMessageBox.StandardButton.Yes:
                return
            self._app._db.add_blacklisted_post(post.file_url)
            self._app._status.showMessage(f"Post #{post.id} blacklisted")
            self._app._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url)

    def toggle_bookmark(self, index: int, folder: str | None = None) -> None:
        """Toggle the bookmark state of post at `index`.

        When `folder` is given and the post is not yet bookmarked, the
        new bookmark is filed under that bookmark folder. The folder
        arg is ignored when removing -- bookmark folder membership is
        moot if the bookmark itself is going away.
        """
        post = self._app._posts[index]
        site_id = self._app._site_combo.currentData()
        if not site_id:
            return

        if self._app._db.is_bookmarked(site_id, post.id):
            self._app._db.remove_bookmark(site_id, post.id)
            self._app._status.showMessage(f"Unbookmarked #{post.id}")
            thumbs = self._app._grid._thumbs
            if 0 <= index < len(thumbs):
                thumbs[index].set_bookmarked(False)
        else:
            self._app._status.showMessage(f"Bookmarking #{post.id}...")

            async def _fav():
                try:
                    path = await download_image(post.file_url)
                    self._app._db.add_bookmark(
                        site_id=site_id,
                        post_id=post.id,
                        file_url=post.file_url,
                        preview_url=post.preview_url,
                        tags=post.tags,
                        rating=post.rating,
                        score=post.score,
                        source=post.source,
                        cached_path=str(path),
                        folder=folder,
                        tag_categories=post.tag_categories,
                    )
                    where = folder or "Unfiled"
                    self._app._signals.bookmark_done.emit(index, f"Bookmarked #{post.id} to {where}")
                except Exception as e:
                    self._app._signals.bookmark_error.emit(str(e))

            self._app._run_async(_fav)

    def bulk_bookmark(self, indices: list[int], posts: list) -> None:
        site_id = self._app._site_combo.currentData()
        if not site_id:
            return
        self._app._status.showMessage(f"Bookmarking {len(posts)}...")

        async def _do():
            for i, (idx, post) in enumerate(zip(indices, posts)):
                if self._app._db.is_bookmarked(site_id, post.id):
                    continue
                try:
                    path = await download_image(post.file_url)
                    self._app._db.add_bookmark(
                        site_id=site_id, post_id=post.id,
                        file_url=post.file_url, preview_url=post.preview_url,
                        tags=post.tags, rating=post.rating, score=post.score,
                        source=post.source, cached_path=str(path),
                        tag_categories=post.tag_categories,
                    )
                    self._app._signals.bookmark_done.emit(idx, f"Bookmarked {i+1}/{len(posts)}")
                except Exception as e:
                    log.warning(f"Operation failed: {e}")
            self._app._signals.batch_done.emit(f"Bookmarked {len(posts)} posts")

        self._app._run_async(_do)

    def bulk_save(self, indices: list[int], posts: list, folder: str | None) -> None:
        """Bulk-save the selected posts into the library, optionally inside a subfolder.

        Each iteration routes through save_post_file with a shared
        in_flight set so template-collision-prone batches (e.g.
        %artist% on a page that has many posts by the same artist) get
        sequential _1, _2, _3 suffixes instead of clobbering each other.
        """
        from ..core.config import saved_dir, saved_folder_dir
        from ..core.library_save import save_post_file

        where = folder or "Unfiled"
        self._app._status.showMessage(f"Saving {len(posts)} to {where}...")
        try:
            dest_dir = saved_folder_dir(folder) if folder else saved_dir()
        except ValueError as e:
            self._app._status.showMessage(f"Invalid folder name: {e}")
            return

        in_flight: set[str] = set()

        async def _do():
            fetcher = self._app._get_category_fetcher()
            for i, (idx, post) in enumerate(zip(indices, posts)):
                try:
                    src = Path(await download_image(post.file_url))
                    await save_post_file(src, post, dest_dir, self._app._db, in_flight, category_fetcher=fetcher)
                    self.copy_library_thumb(post)
                    self._app._signals.bookmark_done.emit(idx, f"Saved {i+1}/{len(posts)} to {where}")
                    self._maybe_unbookmark(post)
                except Exception as e:
                    log.warning(f"Bulk save #{post.id} failed: {e}")
            self._app._signals.batch_done.emit(f"Saved {len(posts)} to {where}")

        self._app._run_async(_do)

    def bulk_unsave(self, indices: list[int], posts: list) -> None:
        """Bulk-remove selected posts from the library.

        Mirrors `bulk_save` shape but synchronously -- `delete_from_library`
        is a filesystem op, no httpx round-trip needed. Touches only the
        library (filesystem); bookmarks are a separate DB-backed concept
        and stay untouched. The grid's saved-locally dot clears for every
        selection slot regardless of whether the file was actually present
        -- the user's intent is "make these not-saved", and a missing file
        is already not-saved.
        """
        from ..core.cache import delete_from_library
        for post in posts:
            delete_from_library(post.id, db=self._app._db)
        for idx in indices:
            if 0 <= idx < len(self._app._grid._thumbs):
                self._app._grid._thumbs[idx].set_saved_locally(False)
        self._app._grid._clear_multi()
        self._app._status.showMessage(f"Removed {len(posts)} from library")
        if self._app._stack.currentIndex() == 2:
            self._app._library_view.refresh()
        self._app._popout_ctrl.update_state()

    def ensure_bookmarked(self, post) -> None:
        """Bookmark a post if not already bookmarked."""
        site_id = self._app._site_combo.currentData()
        if not site_id or self._app._db.is_bookmarked(site_id, post.id):
            return

        async def _fav():
            try:
                path = await download_image(post.file_url)
                self._app._db.add_bookmark(
                    site_id=site_id,
                    post_id=post.id,
                    file_url=post.file_url,
                    preview_url=post.preview_url,
                    tags=post.tags,
                    rating=post.rating,
                    score=post.score,
                    source=post.source,
                    cached_path=str(path),
                )
            except Exception as e:
                log.warning(f"Operation failed: {e}")

        self._app._run_async(_fav)

    def batch_download_posts(self, posts: list, dest: str) -> None:
        """Multi-select Download All entry point. Delegates to
        batch_download_to so the in_flight set, library_meta write,
        and saved-dots refresh share one implementation."""
        self.batch_download_to(posts, Path(dest))

    def batch_download_to(self, posts: list, dest_dir: Path) -> None:
        """Download `posts` into `dest_dir`, routing each save through
        save_post_file with a shared in_flight set so collision-prone
        templates produce sequential _1, _2 suffixes within the batch.

        Stashes `dest_dir` on `self._batch_dest` so on_batch_progress
        and on_batch_done can decide whether the destination is inside
        the library and the saved-dots need refreshing. The library_meta
        write happens automatically inside save_post_file when dest_dir
        is inside saved_dir() -- fixes the v0.2.3 latent bug where batch
        downloads into a library folder left files unregistered.
        """
        from ..core.library_save import save_post_file

        self._batch_dest = dest_dir
        self._app._status.showMessage(f"Downloading {len(posts)} images...")
        in_flight: set[str] = set()

        async def _batch():
            fetcher = self._app._get_category_fetcher()
            for i, post in enumerate(posts):
                try:
                    src = Path(await download_image(post.file_url))
                    await save_post_file(src, post, dest_dir, self._app._db, in_flight, category_fetcher=fetcher)
                    self._app._signals.batch_progress.emit(i + 1, len(posts), post.id)
                    self._maybe_unbookmark(post)
                except Exception as e:
                    log.warning(f"Batch #{post.id} failed: {e}")
            self._app._signals.batch_done.emit(f"Downloaded {len(posts)} images to {dest_dir}")

        self._app._run_async(_batch)

    def batch_download(self) -> None:
        if not self._app._posts:
            self._app._status.showMessage("No posts to download")
            return
        from .dialogs import select_directory
        dest = select_directory(self._app, "Download to folder")
        if not dest:
            return
        self.batch_download_to(list(self._app._posts), Path(dest))

    def is_current_bookmarked(self, index: int) -> bool:
        site_id = self._app._site_combo.currentData()
        if not site_id or index < 0 or index >= len(self._app._posts):
            return False
        return self._app._db.is_bookmarked(site_id, self._app._posts[index].id)

    def copy_library_thumb(self, post) -> None:
        """Copy a post's browse thumbnail into the library thumbnail
        cache so the Library tab can paint it without re-downloading.
        No-op if there's no preview_url or the source thumb isn't cached."""
        if not post.preview_url:
            return
        from ..core.config import thumbnails_dir
        from ..core.cache import cached_path_for
        thumb_src = cached_path_for(post.preview_url, thumbnails_dir())
        if not thumb_src.exists():
            return
        lib_thumb_dir = thumbnails_dir() / "library"
        lib_thumb_dir.mkdir(parents=True, exist_ok=True)
        lib_thumb = lib_thumb_dir / f"{post.id}.jpg"
        if not lib_thumb.exists():
            import shutil
            shutil.copy2(thumb_src, lib_thumb)

    def save_to_library(self, post, folder: str | None) -> None:
        """Save a post into the library, optionally inside a subfolder.

        Routes through the unified save_post_file flow so the filename
        template, sequential collision suffixes, same-post idempotency,
        and library_meta write are all handled in one place. Re-saving
        the same post into the same folder is a no-op (idempotent);
        saving into a different folder produces a second copy without
        touching the first.
        """
        from ..core.config import saved_dir, saved_folder_dir
        from ..core.library_save import save_post_file

        self._app._status.showMessage(f"Saving #{post.id} to library...")
        try:
            dest_dir = saved_folder_dir(folder) if folder else saved_dir()
        except ValueError as e:
            self._app._status.showMessage(f"Invalid folder name: {e}")
            return

        async def _save():
            try:
                src = Path(await download_image(post.file_url))
                await save_post_file(src, post, dest_dir, self._app._db, category_fetcher=self._app._get_category_fetcher())
                self.copy_library_thumb(post)
                where = folder or "Unfiled"
                self._app._signals.bookmark_done.emit(
                    self._app._grid.selected_index,
                    f"Saved #{post.id} to {where}",
                )
                self._maybe_unbookmark(post)
            except Exception as e:
                self._app._signals.bookmark_error.emit(str(e))

        self._app._run_async(_save)

    def save_as(self, post) -> None:
        """Open a Save As dialog for a single post and write the file
        through the unified save_post_file flow.

        The default name in the dialog comes from rendering the user's
        library_filename_template against the post; the user can edit
        before confirming. If the chosen destination ends up inside
        saved_dir(), save_post_file registers a library_meta row --
        a behavior change from v0.2.3 (where Save As never wrote meta
        regardless of destination)."""
        from ..core.cache import cached_path_for
        from ..core.config import render_filename_template
        from ..core.library_save import save_post_file
        from .dialogs import save_file

        src = cached_path_for(post.file_url)
        if not src.exists():
            self._app._status.showMessage("Image not cached — double-click to download first")
            return
        ext = src.suffix
        template = self._app._db.get_setting("library_filename_template")
        default_name = render_filename_template(template, post, ext)
        dest = save_file(self._app, "Save Image", default_name, f"Images (*{ext})")
        if not dest:
            return
        dest_path = Path(dest)

        async def _do_save():
            try:
                actual = await save_post_file(
                    src, post, dest_path.parent, self._app._db,
                    explicit_name=dest_path.name,
                    category_fetcher=self._app._get_category_fetcher(),
                )
                self._app._signals.bookmark_done.emit(
                    self._app._grid.selected_index,
                    f"Saved to {actual}",
                )
                self._maybe_unbookmark(post)
            except Exception as e:
                self._app._signals.bookmark_error.emit(f"Save failed: {e}")

        self._app._run_async(_do_save)

    def on_bookmark_done(self, index: int, msg: str) -> None:
        self._app._status.showMessage(f"{len(self._app._posts)} results — {msg}")
        # Detect batch operations (e.g. "Saved 3/10 to Unfiled") -- skip heavy updates
        is_batch = is_batch_message(msg)
        thumbs = self._app._grid._thumbs
        if 0 <= index < len(thumbs):
            if "Saved" in msg:
                thumbs[index].set_saved_locally(True)
            if "Bookmarked" in msg:
                thumbs[index].set_bookmarked(True)
        if not is_batch:
            if "Bookmarked" in msg:
                self._app._preview.update_bookmark_state(True)
            if "Saved" in msg:
                self._app._preview.update_save_state(True)
                if self._app._stack.currentIndex() == 1:
                    bm_grid = self._app._bookmarks_view._grid
                    bm_idx = bm_grid.selected_index
                    if 0 <= bm_idx < len(bm_grid._thumbs):
                        bm_grid._thumbs[bm_idx].set_saved_locally(True)
                if self._app._stack.currentIndex() == 2:
                    self._app._library_view.refresh()
            self._app._popout_ctrl.update_state()

    def on_batch_progress(self, current: int, total: int, post_id: int) -> None:
        self._app._status.showMessage(f"Downloading {current}/{total}...")
        # Light the browse saved-dot for the just-finished post if the
        # batch destination is inside the library. Runs per-post on the
        # main thread (this is a Qt slot), so the dot appears as the
        # files land instead of all at once when the batch completes.
        dest = self._batch_dest
        if dest is None:
            return
        from ..core.config import saved_dir
        if not is_in_library(dest, saved_dir()):
            return
        for i, p in enumerate(self._app._posts):
            if p.id == post_id and i < len(self._app._grid._thumbs):
                self._app._grid._thumbs[i].set_saved_locally(True)
                break

    def on_batch_done(self, msg: str) -> None:
        self._app._status.showMessage(msg)
        self._app._popout_ctrl.update_state()
        if self._app._stack.currentIndex() == 1:
            self._app._bookmarks_view.refresh()
        if self._app._stack.currentIndex() == 2:
            self._app._library_view.refresh()
        # Saved-dot updates happen incrementally in on_batch_progress as
        # each file lands; this slot just clears the destination stash.
        self._batch_dest = None

    def on_library_files_deleted(self, post_ids: list) -> None:
        """Library deleted files -- clear saved dots on browse grid."""
        for i, p in enumerate(self._app._posts):
            if p.id in post_ids and i < len(self._app._grid._thumbs):
                self._app._grid._thumbs[i].set_saved_locally(False)

    def refresh_browse_saved_dots(self) -> None:
        """Bookmarks changed -- rescan saved state for all visible browse grid posts."""
        for i, p in enumerate(self._app._posts):
            if i < len(self._app._grid._thumbs):
                self._app._grid._thumbs[i].set_saved_locally(self.is_post_saved(p.id))
                site_id = self._app._site_combo.currentData()
                self._app._grid._thumbs[i].set_bookmarked(
                    bool(site_id and self._app._db.is_bookmarked(site_id, p.id))
                )
