"""
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
in .env → backend bootstrap on startup) — adding or removing one is a
docker-compose change. Sub-folders inside a source root can be created,
renamed, and deleted from the UI; those changes are mirrored to disk.
"""
import logging
import os
import shutil
from typing import Literal, Optional

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, update as sql_update, delete as sql_delete
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.models import Folder, SourceRoot, Photo
from app.models.user import User
from app.dependencies import get_current_user, get_user_folder

logger = logging.getLogger(__name__)

router = APIRouter()


class FolderRename(BaseModel):
    name: str


class FolderCreate(BaseModel):
    name: str
    parent_id: str  # Folder.id (NOT a SourceRoot id)


class FolderHide(BaseModel):
    hidden: bool


def _validate_folder_name(name: str) -> str:
    """Trim + sanity-check a folder name. Rejects names that contain a
    path separator or that resolve to a parent traversal — those would
    let the user escape the parent directory through this endpoint.
    """
    name = (name or '').strip()
    if not name:
        raise HTTPException(status_code=400, detail="Name cannot be empty")
    if '/' in name or '\\' in name or name in ('.', '..'):
        raise HTTPException(status_code=400, detail="Invalid folder name")
    return name

@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
    """Get all source folders"""
    # Get source roots instead of regular folders
    result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id))
    source_roots = result.scalars().all()
    
    folders_list = []
    for root in source_roots:
        # Get photo count for this source root
        folder_result = await db.execute(
            select(Folder).where(Folder.source_root_id == root.id)
        )
        folders = folder_result.scalars().all()
        photo_count = sum(f.photo_count for f in folders)
        
        folders_list.append({
            "id": root.id,
            "name": root.name or os.path.basename(root.path),
            "path": root.path,
            "photo_count": photo_count
        })
    
    return {"folders": folders_list}

@router.get("/tree")
async def get_folder_tree(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
    """Recursive folder tree, one root per active SourceRoot. The tree
    starts at the Folder row matching the SourceRoot.path (the scanner
    creates one for every walked directory), with the SourceRoot's
    display name overlaid so the top-level entry reads as "Library"
    instead of "/photos".

    Returns a list of root nodes; each node has:
        { id, name, path, photo_count, children: [...] }

    photo_count is **recursive** — every node reports the total non-
    discarded photos in its own subtree, so the badge matches what the
    user sees when they click the row (which also filters recursively).

    The stored Folder.photo_count column is intentionally NOT trusted;
    the scanner's bookkeeping for that field has historically been
    wrong (it leaks the global total into whichever folder os.walk
    visited last). We compute counts here from the photos table.

    Sub-folders that physically belong to the same source root but
    weren't created on disk (e.g. the / row the scanner sometimes
    creates as a parent walk) are skipped via path-prefix filtering.
    """
    sr_result = await db.execute(
        select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id)  # noqa: E712
    )
    source_roots = sr_result.scalars().all()

    out = []
    for sr in source_roots:
        # Folders physically inside this source root, by path prefix.
        prefix = os.path.normpath(sr.path).rstrip(os.sep)
        f_result = await db.execute(
            select(Folder).where(
                Folder.source_root_id == sr.id,
                # Either the folder IS the source root, or it sits beneath it.
                (Folder.path == prefix) | (Folder.path.like(prefix + os.sep + '%'))
            )
        )
        folders = f_result.scalars().all()
        if not folders:
            continue

        # Direct (non-recursive) photo counts per folder, computed from
        # the photos table. Excludes discarded AND hidden photos so the
        # sidebar badge matches the "All Photos"-style cross-cutting
        # views. Users can still click into a hidden folder and see its
        # contents; the badge count simply won't reflect those photos.
        folder_ids = [f.id for f in folders]
        direct_counts: dict[str, int] = {}
        if folder_ids:
            count_result = await db.execute(
                select(Photo.folder_id, func.count(Photo.id))
                .where(
                    Photo.is_discarded == False,  # noqa: E712
                    Photo.is_hidden == False,  # noqa: E712
                    Photo.folder_id.in_(folder_ids),
                )
                .group_by(Photo.folder_id)
            )
            direct_counts = {row[0]: int(row[1]) for row in count_result.all()}

        # Build a path → node map so we can attach children regardless of
        # parent_id consistency. We populate photo_count with the direct
        # count first, then accumulate descendants in a post-order pass.
        # `is_hidden` on each node carries the user-set folder flag (NOT
        # the effective ancestry flag) so the frontend can render the
        # hidden icon on the exact folder the user toggled.
        nodes = {
            f.path: {
                "id": f.id,
                "name": f.name or os.path.basename(f.path),
                "path": f.path,
                "photo_count": direct_counts.get(f.id, 0),
                "is_hidden": bool(f.is_hidden),
                "children": [],
            }
            for f in folders
        }

        root_node = None
        for f in folders:
            node = nodes[f.path]
            if f.path == prefix:
                root_node = node
                # Override the display name with the source root's label.
                node["name"] = sr.name or node["name"]
                continue
            parent_path = os.path.normpath(os.path.dirname(f.path))
            parent = nodes.get(parent_path)
            if parent is not None:
                parent["children"].append(node)
            # If parent isn't in the set (orphan from a partial scan), drop
            # the node — it can't be rendered consistently.

        if root_node is not None:
            # Sort children alphabetically at every level.
            def sort_recursive(n):
                n["children"].sort(key=lambda c: c["name"].lower())
                for c in n["children"]:
                    sort_recursive(c)
            sort_recursive(root_node)

            # Post-order: each node's recursive count is its own direct
            # count plus the sum of every descendant's recursive count.
            def accumulate(n) -> int:
                total = n["photo_count"]
                for c in n["children"]:
                    total += accumulate(c)
                n["photo_count"] = total
                return total
            accumulate(root_node)

            out.append(root_node)

    return out


@router.patch("/{folder_id}")
async def rename_folder(
    folder_id: str,
    body: FolderRename,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """Rename a folder. Two cases:

      - SourceRoot id  → just change the display label. The on-disk path
                         is owned by the docker mount and never moves.
      - Folder id      → rename the directory on disk AND update every
                         descendant Folder.path + Photo.filepath that
                         lived under the old prefix. Refuses to rename
                         the source-root folder itself (= the row that
                         matches the SourceRoot.path) because that would
                         require renaming the docker mount.
    """
    name = _validate_folder_name(body.name)

    # Try SourceRoot first (display-only rename).
    sr_result = await db.execute(
        select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
    )
    source_root = sr_result.scalar_one_or_none()
    if source_root:
        source_root.name = name
        await db.commit()
        return {
            "id": source_root.id,
            "name": source_root.name,
            "path": source_root.path,
        }

    # Otherwise it's a Folder row.
    folder = await get_user_folder(folder_id, current_user, db)

    # Refuse to rename the bare source root mount through here.
    sr_check = await db.execute(
        select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
    )
    sr = sr_check.scalar_one_or_none()
    if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
        raise HTTPException(
            status_code=400,
            detail="Cannot rename the source root mount; rename the docker mount instead.",
        )

    old_path = os.path.normpath(folder.path).rstrip(os.sep)
    parent_dir = os.path.dirname(old_path)
    new_path = os.path.join(parent_dir, name)

    if os.path.exists(new_path):
        raise HTTPException(
            status_code=400,
            detail=f"A folder named '{name}' already exists here",
        )

    try:
        shutil.move(old_path, new_path)
    except OSError as e:
        raise HTTPException(status_code=500, detail=f"Rename failed: {e}")

    # Update folder paths: this row + every descendant. SQLite REPLACE
    # rewrites the prefix; we use the trailing separator on the LIKE
    # pattern so a folder named "foo" doesn't accidentally match "foobar".
    await db.execute(
        sql_update(Folder)
        .where(Folder.id == folder.id)
        .values(path=new_path, name=name)
    )
    descendant_prefix = old_path + os.sep
    descendants = await db.execute(
        select(Folder).where(Folder.path.like(descendant_prefix + '%'))
    )
    for d in descendants.scalars().all():
        d.path = new_path + d.path[len(old_path):]

    # Update every photo whose filepath lives under the old prefix.
    photos_result = await db.execute(
        select(Photo).where(Photo.filepath.like(descendant_prefix + '%'))
    )
    for p in photos_result.scalars().all():
        p.filepath = new_path + p.filepath[len(old_path):]
    # Photos directly inside this folder (not in a subdir) won't match
    # the descendant_prefix LIKE if their old path was old_path + '/file'
    # — actually they DO match, since 'oldpath/file' starts with
    # 'oldpath/'. So the loop above already covers them.

    await db.commit()
    return {
        "id": folder.id,
        "name": folder.name,
        "path": folder.path,
    }


@router.post("", status_code=201)
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
    """Create a new sub-folder under an existing Folder. Mirrors the
    create to disk so the next scan sees it. Body: { name, parent_id }.
    parent_id MUST be an existing Folder row id (any descendant of a
    source root); creating a brand-new top-level mount is a docker
    operation, not a UI one.
    """
    name = _validate_folder_name(body.name)

    parent = await get_user_folder(body.parent_id, current_user, db)

    new_path = os.path.join(parent.path, name)
    if os.path.exists(new_path):
        raise HTTPException(
            status_code=400,
            detail=f"A folder named '{name}' already exists here",
        )

    try:
        os.makedirs(new_path, exist_ok=False)
    except OSError as e:
        raise HTTPException(status_code=500, detail=f"Create failed: {e}")

    new_folder = Folder(
        name=name,
        path=new_path,
        source_root_id=parent.source_root_id,
        user_id=current_user.id,
        photo_count=0,
    )
    db.add(new_folder)
    await db.commit()
    await db.refresh(new_folder)
    return {
        "id": new_folder.id,
        "name": new_folder.name,
        "path": new_folder.path,
        "parent_id": parent.id,
    }


@router.delete("/{folder_id}")
async def delete_folder(
    folder_id: str,
    mode: Literal['discard', 'permanent'] = Query('discard'),
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """Delete a folder. Behavior depends on mode:

      - mode=discard (default): mark every photo whose filepath lives
        under this folder as is_discarded=true. The folder row, its
        descendant rows, and the on-disk directory are LEFT INTACT —
        the user can still recover photos from the discard pile, and
        a re-scan won't double-import them.

      - mode=permanent: unlink every photo file under this folder,
        remove the photo + folder rows from the DB, and rmtree the
        on-disk directory. Irreversible.

    Refuses to delete the bare source-root mount in either mode (deleting
    the docker mount through the UI would be a footgun).
    """
    folder = await get_user_folder(folder_id, current_user, db)

    sr_check = await db.execute(
        select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id)
    )
    sr = sr_check.scalar_one_or_none()
    if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
        raise HTTPException(
            status_code=400,
            detail="Cannot delete the source root mount through the UI",
        )

    folder_path = os.path.normpath(folder.path).rstrip(os.sep)
    descendant_prefix = folder_path + os.sep

    # Collect every photo under this folder OR any descendant. We match
    # by filepath prefix instead of folder_id because that catches photos
    # in nested subfolders without a recursive folder walk.
    photos_result = await db.execute(
        select(Photo).where(
            (Photo.filepath == folder_path)
            | (Photo.filepath.like(descendant_prefix + '%'))
        )
    )
    photos = photos_result.scalars().all()

    if mode == 'discard':
        from datetime import datetime
        now = datetime.utcnow()
        for p in photos:
            p.is_discarded = True
            p.discarded_at = now
        await db.commit()
        return {
            "status": "success",
            "mode": "discard",
            "discarded": len(photos),
        }

    # mode == 'permanent'
    file_errors = 0
    for p in photos:
        try:
            if p.filepath and os.path.exists(p.filepath):
                os.unlink(p.filepath)
        except OSError as e:
            file_errors += 1
            logger.error(f"Failed to unlink {p.filepath}: {e}")
        await db.delete(p)

    # Delete this folder + every descendant Folder row.
    await db.execute(
        sql_delete(Folder).where(
            (Folder.id == folder.id)
            | (Folder.path.like(descendant_prefix + '%'))
        )
    )

    try:
        if os.path.isdir(folder_path):
            shutil.rmtree(folder_path)
    except OSError as e:
        logger.error(f"Failed to rmtree {folder_path}: {e}")
        # Don't raise — DB rows are already gone, leaving an orphan
        # directory is the lesser evil.

    await db.commit()
    return {
        "status": "success",
        "mode": "permanent",
        "deleted_photos": len(photos),
        "file_errors": file_errors,
    }


async def _recompute_photo_hidden_flags(db: AsyncSession) -> None:
    """Rematerialize photos.is_hidden from the full folder ancestry.

    `photos.is_hidden` is true iff any ancestor folder in the photo's
    folder chain (including the folder the photo is directly in) has
    `folders.is_hidden = true`. Rather than do a recursive walk in
    Python, we lean on Postgres's WITH RECURSIVE to compute each
    folder's effective hidden state in a single query, then join on
    photos to bulk-update the flag.

    Called after any folders.is_hidden toggle AND after moving photos
    between folders, since the photo's effective-hidden state can
    change even when no folder flag changes. Cheap — one O(folders)
    CTE + one O(photos) UPDATE. On a 13k-photo library this runs in
    under 50ms.
    """
    from sqlalchemy import text as _text

    await db.execute(
        _text("""
            WITH RECURSIVE folder_chain AS (
                -- Base: source-root folders (no parent_id). Their own
                -- is_hidden is the starting effective value.
                SELECT id, is_hidden AS effective_hidden
                FROM folders
                WHERE parent_id IS NULL
                UNION ALL
                -- Step: a child folder inherits from its parent. The
                -- effective flag is true if the parent's effective flag
                -- is true OR the child's own flag is true. Short-circuit
                -- would be nice but a plain OR does the job.
                SELECT f.id, (f.is_hidden OR fc.effective_hidden) AS effective_hidden
                FROM folders f
                JOIN folder_chain fc ON f.parent_id = fc.id
            )
            UPDATE photos p
            SET is_hidden = fc.effective_hidden
            FROM folder_chain fc
            WHERE p.folder_id = fc.id
              AND p.is_hidden IS DISTINCT FROM fc.effective_hidden
        """)
    )


@router.post("/{folder_id}/hide")
async def set_folder_hidden(
    folder_id: str,
    body: FolderHide,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """Toggle the "hide from views" flag on a folder or source root.

    A hidden folder's photos are excluded from every cross-cutting view
    (All Photos, Map, Tags, People, Search, sidebar counts, duplicates)
    but remain fully indexed and visible when the user navigates
    directly into the folder. The flag cascades to every descendant
    folder via the photos.is_hidden recompute — the child folder's own
    `is_hidden` column stays where the user set it, but a photo under a
    hidden ancestor will still be marked hidden.

    Accepts both Folder ids and SourceRoot ids. For a SourceRoot, we
    look up the root Folder row (the one matching source_root.path) and
    flip that — source roots themselves don't carry the column because
    the whole subtree lives on a single Folder row anyway.
    """
    # SourceRoot path — resolve to the Folder row at the mount point.
    sr_result = await db.execute(
        select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)
    )
    source_root = sr_result.scalar_one_or_none()

    folder: Optional[Folder]
    if source_root:
        root_folder_result = await db.execute(
            select(Folder).where(
                Folder.source_root_id == source_root.id,
                Folder.user_id == current_user.id,
                Folder.path == os.path.normpath(source_root.path),
            )
        )
        folder = root_folder_result.scalar_one_or_none()
        if folder is None:
            raise HTTPException(
                status_code=404,
                detail="Source root has no indexed Folder row yet; scan first.",
            )
    else:
        folder = await get_user_folder(folder_id, current_user, db)

    folder.is_hidden = bool(body.hidden)
    await db.flush()

    # Rematerialize photos.is_hidden across the whole tree. Cheap
    # enough (tens of ms on a typical library) that we don't need to
    # scope the update to just this folder's subtree — doing it
    # globally also fixes any drift introduced by earlier moves.
    await _recompute_photo_hidden_flags(db)
    await db.commit()

    return {
        "id": folder.id,
        "name": folder.name,
        "path": folder.path,
        "is_hidden": folder.is_hidden,
    }


@router.post("/{folder_id}/scan")
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
    """Trigger manual re-scan of source root folder"""
    from app.tasks.celery import celery_app

    result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id))
    source_root = result.scalar_one_or_none()
    
    if not source_root:
        raise HTTPException(status_code=404, detail="Source folder not found")
    
    # Queue scan task using the task name defined in the decorator
    task = celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
    return {"status": "success", "message": f"Scan queued for {source_root.path}", "task_id": task.id}