#!/usr/bin/env python3
"""Project tools — the agent's INTENTIONAL handle on first-class Projects (per-profile
``projects.db``, the desktop sidebar's named workspaces). Creating/switching is an explicit
tool call, never a side effect of ``cd``. GUI-only: the `project` toolset stays off
``_HERMES_CORE_TOOLS``; the desktop/TUI gateway folds it in and wires
``set_project_workspace_callback`` so the live session's cwd and sidebar follow. A live
session create/switch re-anchors only that session; it must not move the profile-global
Desktop selection shared by concurrent chats."""

import json
import os
from typing import Callable, Optional

from tools.registry import registry

# Set by the GUI gateway: ``(task_id, primary_path, project_name)`` re-anchors that session's
# workspace. ``None`` in CLI/messaging — the DB write still happens, nothing to move.
_workspace_callback: Optional[Callable[[str, str, str], None]] = None


def set_project_workspace_callback(fn: Optional[Callable[[str, str, str], None]]) -> None:
    global _workspace_callback
    _workspace_callback = fn


def _primary_path(proj) -> Optional[str]:
    if getattr(proj, "primary_path", None):
        return proj.primary_path
    for folder in proj.folders:
        if folder.is_primary:
            return folder.path
    return proj.folders[0].path if proj.folders else None


def _moves_session(task_id: Optional[str], path: Optional[str]) -> bool:
    """True when a live GUI session's workspace will follow the project — ``_apply_workspace``'s gate."""
    return bool(_workspace_callback and task_id and path)


def _apply_workspace(task_id: Optional[str], path: Optional[str], name: str) -> None:
    cb = _workspace_callback
    if cb and task_id and path:
        try:
            cb(task_id, path, name)
        except Exception:
            pass


def _resolve(conn, token: str):
    from hermes_cli import projects_db as pdb
    token = (token or "").strip()
    if not token:
        return None
    projects = pdb.list_projects(conn, include_archived=True)
    # Exact id / slug / name first, then case-insensitive slug / name.
    for proj in projects:
        if token in (proj.id, proj.slug) or proj.name == token:
            return proj
    low = token.lower()
    for proj in projects:
        if proj.slug.lower() == low or proj.name.lower() == low:
            return proj
    return None


def _activated(proj, task_id: Optional[str]) -> str:
    primary = _primary_path(proj)
    _apply_workspace(task_id, primary, proj.name)
    return json.dumps({
        "success": True, "id": proj.id, "slug": proj.slug, "name": proj.name,
        "primary_path": primary})


def _calling_session_project_id(conn, task_id: Optional[str]) -> tuple[bool, Optional[str]]:
    """``(scoped, project_id)`` for the calling session. The GUI gateway registers each session's
    workspace (``cwd_source``) in the terminal override table; a caller it never registered (CLI,
    scripts) is unscoped and falls back to the profile-global pointer."""
    from hermes_cli import projects_db as pdb
    from tools.terminal_tool import resolve_task_overrides
    overrides = resolve_task_overrides(task_id) if task_id else {}
    if "cwd_source" not in overrides:
        return False, None
    cwd = overrides.get("cwd") if overrides["cwd_source"] == "session" else None
    project = pdb.project_for_path(conn, cwd) if cwd else None
    return True, project.id if project else None


def project_list(task_id: Optional[str] = None) -> str:
    from hermes_cli import projects_db as pdb
    with pdb.connect_closing() as conn:
        # Another tab's switch moves the profile-global pointer; this chat's project is its own cwd.
        scoped, active = _calling_session_project_id(conn, task_id)
        if not scoped:
            active = pdb.get_active_id(conn)
        projects = pdb.list_projects(conn)
    return json.dumps({
        "active_id": active,
        "projects": [
            {
                "id": p.id, "slug": p.slug, "name": p.name,
                "primary_path": _primary_path(p), "active": p.id == active}
            for p in projects]})


def project_create(name: str, path: Optional[str] = None, task_id: Optional[str] = None) -> str:
    name = (name or "").strip()
    if not name:
        return json.dumps({"success": False, "error": "name is required"})
    from hermes_cli import projects_db as pdb
    folder = (path or "").strip()
    if folder:
        folder = os.path.abspath(os.path.expanduser(folder))
    try:
        with pdb.connect_closing() as conn:
            existing = pdb.find_by_primary_path(conn, folder) if folder else None
            if existing is not None:
                # Idempotent create: the folder already belongs to a project. Reusing it beats minting
                # a duplicate — duplicated projects render N identical sidebar subtrees (#75820).
                proj = existing
            else:
                pid = pdb.create_project(conn, name=name, folders=[folder] if folder else [], primary_path=folder or None)
                proj = pdb.get_project(conn, pid)
            # A live session that moves owns its workspace, so it leaves the profile-global pointer
            # alone (a background chat would otherwise redirect the sidebar). Anything that can't
            # move — CLI/messaging, a pathless project — switches through the pointer instead.
            if proj is not None and not _moves_session(task_id, _primary_path(proj)):
                pdb.set_active(conn, proj.id)
    except ValueError as exc:
        return json.dumps({"success": False, "error": str(exc)})
    if proj is None:
        return json.dumps({"success": False, "error": "project vanished after create"})
    return _activated(proj, task_id)


def project_switch(project: str, task_id: Optional[str] = None) -> str:
    from hermes_cli import projects_db as pdb
    with pdb.connect_closing() as conn:
        proj = _resolve(conn, project)
        if proj is None:
            return json.dumps({"success": False, "error": f"no project matching '{project}'"})
        # Same rule as create.
        if not _moves_session(task_id, _primary_path(proj)):
            pdb.set_active(conn, proj.id)
    return _activated(proj, task_id)


_ACTIONS = {
    "list": lambda args, tid: project_list(task_id=tid),
    "create": lambda args, tid: project_create(
        name=args.get("name", ""), path=args.get("path"), task_id=tid),
    "switch": lambda args, tid: project_switch(project=args.get("name", ""), task_id=tid)}


def _handle_project(args, **kw):
    action = _ACTIONS.get((args.get("action") or "").strip())
    if action is None:
        return json.dumps({"success": False, "error": "action must be one of: create, switch, list."})
    return action(args, kw.get("task_id"))


# One action enum instead of three tools: each re-taught "desktop Projects" (244 -> ~145 tok).
# Consolidated (#95681, maintainer-directed): project_list/create/switch each re-taught "desktop Projects
# (named workspaces)"; one action enum says it once (244 -> ~145 tok).
registry.register(
    name="desktop_project",
    toolset="project",
    schema={
        "name": "desktop_project",
        "description": (
            "Create or switch desktop Projects (named workspaces). create: one and switch "
            "this chat into it — pass path to anchor it to a repo/folder (the "
            "chat's workspace moves there, the sidebar follows). switch: move "
            "this chat into an existing project by name/slug/id — the "
            "intentional way to move the session, not `cd`. list: all projects + which one this chat is in."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "action": {"type": "string", "enum": ["create", "switch", "list"]},
                "name": {"type": "string", "description": "create: human name. switch: name, slug, or id."},
                "path": {"type": "string", "description": "create: repo/folder to anchor to."},
            },
            "required": ["action"],
        },
    },
    handler=_handle_project,
)
