# retoor <retoor@molodetz.nl>

from __future__ import annotations

from .spec import Action, Catalog, Param


def path(name: str, description: str, required: bool = True) -> Param:
    return Param(name=name, location="path", description=description, required=required)


def query(name: str, description: str, required: bool = False) -> Param:
    return Param(
        name=name, location="query", description=description, required=required
    )


def body(name: str, description: str, required: bool = False) -> Param:
    return Param(name=name, location="body", description=description, required=required)


def upload(name: str, description: str) -> Param:
    return Param(name=name, location="file", description=description, required=True)


def confirm() -> Param:
    return Param(
        name="confirm",
        location="body",
        description=(
            "Set to true ONLY after the user has explicitly confirmed this irreversible action. "
            "Leave it unset on the first call: the tool refuses and tells you to confirm with the "
            "user, and only a repeat call with confirm=true actually performs it."
        ),
        required=False,
        type="boolean",
    )


ATTACHMENTS = "Comma separated attachment uids returned by upload_file."
TARGET_TYPE = "Target type, e.g. post, comment, project, gist."

ACTIONS: tuple[Action, ...] = (
    Action(
        name="auth_status",
        method="GET",
        path="",
        summary="Report whether the current session is authenticated",
        handler="status",
        requires_auth=False,
    ),
    Action(
        name="login",
        method="POST",
        path="/auth/login",
        summary="Authenticate with email and password and start a session",
        handler="login",
        requires_auth=False,
        params=(
            body("email", "Account email address.", required=True),
            body("password", "Account password.", required=True),
            body("remember_me", "Keep the session alive longer ('on' or '')."),
        ),
    ),
    Action(
        name="logout",
        method="GET",
        path="/auth/logout",
        summary="End the current session",
        handler="logout",
        requires_auth=True,
    ),
    Action(
        name="signup",
        method="POST",
        path="/auth/signup",
        summary="Create a new account",
        requires_auth=False,
        params=(
            body("username", "Desired username.", required=True),
            body("email", "Email address.", required=True),
            body("password", "Password (minimum six characters).", required=True),
            body("confirm_password", "Password confirmation.", required=True),
        ),
    ),
    Action(
        name="forgot_password",
        method="POST",
        path="/auth/forgot-password",
        summary="Request a password reset email",
        requires_auth=False,
        params=(body("email", "Account email address.", required=True),),
    ),
    Action(
        name="reset_password",
        method="POST",
        path="/auth/reset-password/{token}",
        summary="Reset a password using a reset token",
        requires_auth=False,
        params=(
            path("token", "Reset token from the email link."),
            body("password", "New password.", required=True),
            body("confirm_password", "New password confirmation.", required=True),
        ),
    ),
    Action(
        name="view_feed",
        method="GET",
        path="/feed",
        summary="View the activity feed",
        requires_auth=False,
        params=(
            query("tab", "Feed tab to view."),
            query("topic", "Filter by topic."),
            query("search", "Search post title and content."),
            query("before", "Pagination cursor."),
        ),
    ),
    Action(
        name="create_post",
        method="POST",
        path="/posts/create",
        summary="Create a new post",
        params=(
            body("content", "Post body text.", required=True),
            body("title", "Optional post title."),
            body("topic", "Optional topic."),
            body("project_uid", "Attach the post to a project uid."),
            body("attachment_uids", ATTACHMENTS),
            body("poll_question", "Optional poll question."),
            body(
                "poll_options",
                "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created.",
            ),
        ),
    ),
    Action(
        name="view_post",
        method="GET",
        path="/posts/{post_slug}",
        summary="View a single post by slug",
        requires_auth=False,
        params=(
            path(
                "post_slug",
                "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
            ),
        ),
    ),
    Action(
        name="edit_post",
        method="POST",
        path="/posts/edit/{post_slug}",
        summary="Edit an existing post",
        params=(
            path(
                "post_slug",
                "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
            ),
            body("content", "Updated post body.", required=True),
            body("title", "Updated title."),
            body("topic", "Updated topic."),
            body(
                "poll_question",
                "Optional poll question. Adds a poll to a post that does not already have one.",
            ),
            body(
                "poll_options",
                "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created.",
            ),
        ),
    ),
    Action(
        name="delete_post",
        method="POST",
        path="/posts/delete/{post_slug}",
        summary="Delete a post (your own, or any post when you are an administrator). Soft delete, confirmation required",
        params=(
            path(
                "post_slug",
                "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
            ),
            confirm(),
        ),
    ),
    Action(
        name="create_comment",
        method="POST",
        path="/comments/create",
        summary="Create a comment on a post or other target",
        params=(
            body("content", "Comment body.", required=True),
            body("post_uid", "Uid of the post being commented on."),
            body("target_uid", "Uid of the target when not a post."),
            body("target_type", TARGET_TYPE),
            body("parent_uid", "Parent comment uid for replies."),
            body("attachment_uids", ATTACHMENTS),
        ),
    ),
    Action(
        name="edit_comment",
        method="POST",
        path="/comments/edit/{comment_uid}",
        summary="Edit the body of one of your own comments",
        params=(
            path("comment_uid", "Uid of the comment."),
            body("content", "New comment body, 3-1000 characters.", required=True),
        ),
    ),
    Action(
        name="delete_comment",
        method="POST",
        path="/comments/delete/{comment_uid}",
        summary="Delete a comment (your own, or any comment when you are an administrator). Soft delete, confirmation required",
        params=(path("comment_uid", "Uid of the comment."), confirm()),
    ),
    Action(
        name="list_projects",
        method="GET",
        path="/projects",
        summary="List projects",
        requires_auth=False,
        params=(
            query("tab", "Projects tab."),
            query("search", "Search query."),
            query("user_uid", "Filter by owner uid."),
            query("project_type", "Filter by project type."),
            query("before", "Pagination cursor."),
        ),
    ),
    Action(
        name="view_project",
        method="GET",
        path="/projects/{project_slug}",
        summary="View a project by slug",
        requires_auth=False,
        params=(
            path(
                "project_slug",
                "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
            ),
        ),
    ),
    Action(
        name="create_project",
        method="POST",
        path="/projects/create",
        summary="Create a new project",
        params=(
            body("title", "Project title.", required=True),
            body("description", "Project description.", required=True),
            body("release_date", "Release date."),
            body("demo_date", "Demo date."),
            body("project_type", "Project type."),
            body("platforms", "Supported platforms."),
            body("status", "Project status."),
            body("attachment_uids", ATTACHMENTS),
        ),
    ),
    Action(
        name="edit_project",
        method="POST",
        path="/projects/edit/{project_slug}",
        summary="Edit an existing project",
        params=(
            path(
                "project_slug",
                "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
            ),
            body("title", "Updated project title.", required=True),
            body("description", "Updated project description.", required=True),
            body("release_date", "Updated release date."),
            body("demo_date", "Updated demo date."),
            body("project_type", "Updated project type."),
            body("platforms", "Updated supported platforms."),
            body("status", "Updated project status."),
        ),
    ),
    Action(
        name="delete_project",
        method="POST",
        path="/projects/delete/{project_slug}",
        summary="Delete a project and all its files (your own, or any project when you are an administrator). Soft delete, confirmation required",
        params=(
            path(
                "project_slug",
                "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
            ),
            confirm(),
        ),
    ),
    Action(
        name="project_set_private",
        method="POST",
        path="/projects/{project_slug}/private",
        summary="Mark a project private (owner-only) or public",
        description=(
            "Set value=true to hide the project from everyone except its owner (and administrators), "
            "or value=false to make it public again. This is a significant change: you MUST ask the "
            "user for explicit confirmation BEFORE calling it, and only pass confirm=true once they "
            "have agreed. Only the project owner may change this."
        ),
        params=(
            path("project_slug", "Project slug or uid."),
            body(
                "value",
                "true to make the project private, false to make it public.",
                required=True,
            ),
            body(
                "confirm",
                "Must be true, set only after the user has explicitly confirmed.",
                required=True,
            ),
        ),
    ),
    Action(
        name="project_set_readonly",
        method="POST",
        path="/projects/{project_slug}/readonly",
        summary="Mark a project read-only (immutable files) or writable again",
        description=(
            "Set value=true to make every file in the project immutable - no writes, edits, line "
            "edits, moves, deletes, or uploads succeed afterwards, from anyone including you - or "
            "value=false to allow changes again. This is a significant change: you MUST ask the user "
            "for explicit confirmation BEFORE calling it, and only pass confirm=true once they have "
            "agreed. Only the project owner may change this."
        ),
        params=(
            path("project_slug", "Project slug or uid."),
            body(
                "value",
                "true to make the project read-only, false to make it writable.",
                required=True,
            ),
            body(
                "confirm",
                "Must be true, set only after the user has explicitly confirmed.",
                required=True,
            ),
        ),
    ),
    Action(
        name="project_list_files",
        method="GET",
        path="/projects/{project_slug}/files",
        summary="List every file and directory in a project filesystem",
        description="Returns the flat list of nodes (path, type, size, mime). Use it to inspect the project tree before reading or writing files.",
        params=(path("project_slug", "Project slug or uid."),),
        requires_auth=False,
    ),
    Action(
        name="project_read_file",
        method="GET",
        path="/projects/{project_slug}/files/raw",
        summary="Read one file from a project filesystem",
        description="Returns the file metadata plus the text content. Binary files return a url instead of content.",
        params=(
            path("project_slug", "Project slug or uid."),
            query(
                "path",
                "Relative file path inside the project, e.g. src/main.py.",
                required=True,
            ),
        ),
        requires_auth=False,
    ),
    Action(
        name="project_write_file",
        method="POST",
        path="/projects/{project_slug}/files/write",
        summary="Create or overwrite a whole text file in a project (parent directories are created automatically)",
        description=(
            "Replaces the ENTIRE file with the content you send. Creating a new file needs no prior "
            "read, but overwriting an existing one requires reading it first (project_read_file). "
            "For an existing file prefer the surgical line tools (project_replace_lines, "
            "project_insert_lines, project_delete_lines, project_append_file) instead of rewriting it, "
            "and never batch several writes in one turn. Use this only to create a new file or fully "
            "rewrite a small one. Missing parent directories are created recursively."
        ),
        params=(
            path("project_slug", "Project slug or uid."),
            body("path", "Relative file path, e.g. src/app/main.py.", required=True),
            body("content", "Full file content.", required=True),
        ),
    ),
    Action(
        name="project_read_lines",
        method="GET",
        path="/projects/{project_slug}/files/lines",
        summary="Read a 1-indexed line range of a text file in a project",
        description=(
            "Returns {path, start, end, total_lines, lines, content} for the requested range. Use it "
            "to inspect part of a large file before editing, and to learn total_lines so you can target "
            "the right range with the line-edit tools."
        ),
        params=(
            path("project_slug", "Project slug or uid."),
            query("path", "Relative file path inside the project.", required=True),
            query("start", "First line to read (1-indexed, default 1)."),
            query("end", "Last line to read (inclusive); omit for end of file."),
        ),
        requires_auth=False,
    ),
    Action(
        name="project_replace_lines",
        method="POST",
        path="/projects/{project_slug}/files/replace-lines",
        summary="Replace an inclusive 1-indexed line range of a text file with new content",
        description=(
            "Surgically rewrites lines start..end (inclusive) with content (which may be any number of "
            "lines, or empty to delete the range). The preferred way to edit an existing file: it leaves "
            "the rest of the file untouched and never overflows the model output limit."
        ),
        params=(
            path("project_slug", "Project slug or uid."),
            body("path", "Relative file path.", required=True),
            body("start", "First line to replace (1-indexed).", required=True),
            body("end", "Last line to replace (inclusive).", required=True),
            body("content", "Replacement text for those lines (empty deletes them)."),
        ),
    ),
    Action(
        name="project_insert_lines",
        method="POST",
        path="/projects/{project_slug}/files/insert-lines",
        summary="Insert content before a 1-indexed line of a text file",
        description=(
            "Inserts content before line 'at' without touching existing lines. Use at=1 to prepend and "
            "at=total_lines+1 to insert at the end."
        ),
        params=(
            path("project_slug", "Project slug or uid."),
            body("path", "Relative file path.", required=True),
            body(
                "at",
                "Insert before this 1-indexed line (1 prepends, total+1 appends).",
                required=True,
            ),
            body("content", "Text to insert.", required=True),
        ),
    ),
    Action(
        name="project_delete_lines",
        method="POST",
        path="/projects/{project_slug}/files/delete-lines",
        summary="Delete an inclusive 1-indexed line range from a text file",
        params=(
            path("project_slug", "Project slug or uid."),
            body("path", "Relative file path.", required=True),
            body("start", "First line to delete (1-indexed).", required=True),
            body("end", "Last line to delete (inclusive).", required=True),
        ),
    ),
    Action(
        name="project_append_file",
        method="POST",
        path="/projects/{project_slug}/files/append",
        summary="Append content to the end of a text file in a project",
        description="Adds content as new lines at the end of the file. Use it to grow a large file across turns without resending the whole thing.",
        params=(
            path("project_slug", "Project slug or uid."),
            body("path", "Relative file path.", required=True),
            body("content", "Text to append.", required=True),
        ),
    ),
    Action(
        name="project_upload_file",
        method="POST",
        path="/projects/{project_slug}/files/upload",
        summary="Upload a local file into a project directory (parents created automatically)",
        params=(
            path("project_slug", "Project slug or uid."),
            upload("file", "Local filesystem path of the file to upload."),
            body("path", "Target directory inside the project, empty for the root."),
        ),
    ),
    Action(
        name="project_make_dir",
        method="POST",
        path="/projects/{project_slug}/files/mkdir",
        summary="Create a directory (and parents) in a project filesystem",
        params=(
            path("project_slug", "Project slug or uid."),
            body(
                "path", "Relative directory path, e.g. src/components.", required=True
            ),
        ),
    ),
    Action(
        name="project_move_file",
        method="POST",
        path="/projects/{project_slug}/files/move",
        summary="Move or rename a file or directory within a project",
        params=(
            path("project_slug", "Project slug or uid."),
            body("from_path", "Existing path.", required=True),
            body("to_path", "New path.", required=True),
        ),
    ),
    Action(
        name="project_delete_file",
        method="POST",
        path="/projects/{project_slug}/files/delete",
        summary="Delete a file or directory (recursive) from a project filesystem (project owner, or any project when you are an administrator). Soft delete, confirmation required",
        params=(
            path("project_slug", "Project slug or uid."),
            body("path", "Relative path to delete.", required=True),
            confirm(),
        ),
    ),
    Action(
        name="zip_project",
        method="POST",
        path="/projects/{project_slug}/zip",
        summary="Build a downloadable zip archive of a whole project",
        description=(
            "Queues a background zip job and returns {uid, status_url}. Poll the status_url with "
            "zip_status until status is 'done', then give the user the download_url."
        ),
        params=(path("project_slug", "Project slug or uid."),),
        requires_auth=False,
    ),
    Action(
        name="project_files_zip",
        method="POST",
        path="/projects/{project_slug}/files/zip",
        summary="Build a downloadable zip archive of a project subdirectory",
        description=(
            "Queues a background zip job for a subpath inside the project and returns "
            "{uid, status_url}. Poll the status_url with zip_status until status is 'done', "
            "then give the user the download_url. When path is empty the whole project is zipped."
        ),
        params=(
            path("project_slug", "Project slug or uid."),
            query("path", "Relative subpath inside the project to zip; empty for the whole project."),
        ),
        requires_auth=False,
    ),
    Action(
        name="zip_status",
        method="GET",
        path="/zips/{uid}",
        summary="Check a zip job and obtain its download link once finished",
        description=(
            "Returns the job status and stats. When status is 'done', download_url points at the "
            "ready archive; while 'pending' or 'running', poll again shortly."
        ),
        params=(path("uid", "Zip job uid returned by zip_project."),),
        requires_auth=False,
    ),
    Action(
        name="fork_project",
        method="POST",
        path="/projects/{project_slug}/fork",
        summary="Fork a project into a new project owned by the current user",
        description=(
            "Queues a background fork job and returns {uid, status_url}. Poll the status_url with "
            "fork_status until status is 'done', then give the user the project_url of the new fork."
        ),
        params=(
            path("project_slug", "Slug or uid of the project to fork."),
            body("title", "Title for the new forked project.", required=True),
        ),
        requires_auth=True,
    ),
    Action(
        name="fork_status",
        method="GET",
        path="/forks/{uid}",
        summary="Check a fork job and obtain the new project once finished",
        description=(
            "Returns the job status. When status is 'done', project_url points at the new forked "
            "project; while 'pending' or 'running', poll again shortly."
        ),
        params=(path("uid", "Fork job uid returned by fork_project."),),
        requires_auth=False,
    ),
    Action(
        name="seo_diagnostics",
        method="POST",
        path="/tools/seo/run",
        summary="Run an SEO audit on a URL or sitemap",
        description=(
            "Queues a background SEO diagnostics job and returns {uid, status_url}. Poll the "
            "status_url with seo_status until status is 'done', then share the score, grade and "
            "report_url. mode='url' audits a single page; mode='sitemap' crawls up to max_pages."
        ),
        params=(
            body("url", "The page URL or sitemap.xml URL to audit.", required=True),
            body("mode", "'url' for a single page or 'sitemap' to crawl a sitemap."),
            body("max_pages", "Maximum pages to crawl in sitemap mode (1-50)."),
        ),
        requires_auth=False,
    ),
    Action(
        name="seo_status",
        method="GET",
        path="/tools/seo/{uid}",
        summary="Check an SEO audit and obtain its score once finished",
        description=(
            "Returns the audit status. When status is 'done', score, grade and report_url are "
            "populated; while 'pending' or 'running', poll again shortly."
        ),
        params=(path("uid", "SEO job uid returned by seo_diagnostics."),),
        requires_auth=False,
    ),
    Action(
        name="seo_report",
        method="GET",
        path="/tools/seo/{uid}/report",
        summary="Read a finished SEO audit report",
        description=(
            "Returns the full audit for a finished SEO job: overall score and grade, per-category "
            "scores, per-check results, per-page details, and site-wide checks. Use it after "
            "seo_status reports status 'done' to explain what passed, what failed, and how to improve."
        ),
        params=(path("uid", "SEO job uid returned by seo_diagnostics."),),
        requires_auth=False,
    ),
    Action(
        name="deepsearch",
        method="POST",
        path="/tools/deepsearch/run",
        summary="Start a deep multi-agent web research job",
        description=(
            "Queues a background DeepSearch job and returns {uid, status_url}. Poll the "
            "status_url with deepsearch_status until status is 'done', then share the "
            "score, confidence and session_url. depth (1-4) and max_pages (1-30) control "
            "how widely it crawls."
        ),
        params=(
            body("query", "The research question to investigate.", required=True),
            body("depth", "Research depth 1-4 (default 2)."),
            body("max_pages", "Maximum sources to crawl 1-30 (default 12)."),
        ),
        requires_auth=False,
    ),
    Action(
        name="deepsearch_status",
        method="GET",
        path="/tools/deepsearch/{uid}",
        summary="Check a DeepSearch job and obtain its metrics once finished",
        description=(
            "Returns the research status. When status is 'done', score, confidence, "
            "source_diversity and session_url are populated; otherwise poll again shortly."
        ),
        params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
        requires_auth=False,
    ),
    Action(
        name="deepsearch_session",
        method="GET",
        path="/tools/deepsearch/{uid}/session",
        summary="Read a finished DeepSearch report",
        description=(
            "Returns the full cited report for a finished DeepSearch: summary, findings, "
            "gaps, sources and metrics."
        ),
        params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
        requires_auth=False,
    ),
    Action(
        name="search_users",
        method="GET",
        path="/profile/search",
        summary="Search for users by name",
        params=(query("q", "Search query."),),
    ),
    Action(
        name="view_profile",
        method="GET",
        path="/profile/{username}",
        summary="View a user profile",
        requires_auth=False,
        params=(
            path("username", "Username to view."),
            query("tab", "Profile tab."),
        ),
    ),
    Action(
        name="update_profile",
        method="POST",
        path="/profile/update",
        summary="Update the current user's profile",
        params=(
            body("bio", "Profile biography."),
            body("location", "Location."),
            body("git_link", "Git profile link."),
            body("website", "Personal website."),
        ),
    ),
    Action(
        name="regenerate_api_key",
        method="POST",
        path="/profile/regenerate-api-key",
        summary="Issue a new API key and invalidate the current one",
        description=(
            "Returns the new api_key. Warning: this immediately invalidates any key currently "
            "used for authentication, so confirm with the user before calling it."
        ),
        params=(
            body(
                "confirm",
                "Must be true, set only after the user has explicitly confirmed.",
                required=True,
            ),
        ),
    ),
    Action(
        name="list_messages",
        method="GET",
        path="/messages",
        summary="View direct message conversations",
        params=(
            query("with_uid", "Open a conversation with a user uid."),
            query("search", "Search conversations."),
        ),
    ),
    Action(
        name="search_message_users",
        method="GET",
        path="/messages/search",
        summary="Search users to message",
        params=(query("q", "Search query."),),
    ),
    Action(
        name="send_message",
        method="POST",
        path="/messages/send",
        summary="Send a direct message",
        params=(
            body("content", "Message body.", required=True),
            body("receiver_uid", "Recipient user uid.", required=True),
            body("attachment_uids", ATTACHMENTS),
        ),
    ),
    Action(
        name="list_notifications",
        method="GET",
        path="/notifications",
        summary="List notifications",
        params=(query("before", "Pagination cursor."),),
    ),
    Action(
        name="notification_counts",
        method="GET",
        path="/notifications/counts",
        summary="Get unread notification and message counts",
        requires_auth=False,
    ),
    Action(
        name="open_notification",
        method="GET",
        path="/notifications/open/{notification_uid}",
        summary="Open a notification and follow it",
        params=(path("notification_uid", "Notification uid."),),
    ),
    Action(
        name="mark_notification_read",
        method="POST",
        path="/notifications/mark-read/{notification_uid}",
        summary="Mark a single notification read",
        params=(path("notification_uid", "Notification uid."),),
    ),
    Action(
        name="mark_all_notifications_read",
        method="POST",
        path="/notifications/mark-all-read",
        summary="Mark all notifications read",
    ),
    Action(
        name="vote",
        method="POST",
        path="/votes/{target_type}/{target_uid}",
        summary="Cast or toggle a vote on a target",
        description="Re-sending the same value removes the vote. Returns the net/up/down tally.",
        ajax=True,
        params=(
            path("target_type", TARGET_TYPE),
            path(
                "target_uid",
                "Uid of the target, copied from a listing response; do not invent it.",
            ),
            body(
                "value",
                "Vote value: 1 to upvote, -1 to downvote (re-send to remove).",
                required=True,
            ),
        ),
    ),
    Action(
        name="react",
        method="POST",
        path="/reactions/{target_type}/{target_uid}",
        summary="Toggle an emoji reaction on a target",
        description="Re-sending the same emoji removes it. Returns reaction counts.",
        ajax=True,
        params=(
            path("target_type", TARGET_TYPE),
            path(
                "target_uid",
                "Uid of the target, copied from a listing response; do not invent it.",
            ),
            body("emoji", "One of the allowed reaction emoji.", required=True),
        ),
    ),
    Action(
        name="list_bookmarks",
        method="GET",
        path="/bookmarks/saved",
        summary="List saved bookmarks",
        params=(query("before", "Pagination cursor."),),
    ),
    Action(
        name="toggle_bookmark",
        method="POST",
        path="/bookmarks/{target_type}/{target_uid}",
        summary="Toggle a bookmark on a target",
        description="Re-sending removes the bookmark. Returns {saved: true|false}.",
        ajax=True,
        params=(
            path("target_type", TARGET_TYPE),
            path(
                "target_uid",
                "Uid of the target, copied from a listing response; do not invent it.",
            ),
        ),
    ),
    Action(
        name="vote_poll",
        method="POST",
        path="/polls/{poll_uid}/vote",
        summary="Vote in a poll",
        description="Casts or changes your vote on a poll option. Returns the option tallies.",
        ajax=True,
        params=(
            path("poll_uid", "Poll uid."),
            body("option_uid", "Chosen option uid.", required=True),
        ),
    ),
    Action(
        name="follow_user",
        method="POST",
        path="/follow/{username}",
        summary="Follow a user",
        params=(path("username", "Username to follow."),),
    ),
    Action(
        name="unfollow_user",
        method="POST",
        path="/follow/unfollow/{username}",
        summary="Unfollow a user",
        params=(path("username", "Username to unfollow."),),
    ),
    Action(
        name="list_followers",
        method="GET",
        path="/profile/{username}/followers",
        summary="List the users who follow a profile",
        requires_auth=False,
        params=(
            path("username", "Username whose followers to list."),
            query("page", "Page number, 25 per page."),
        ),
    ),
    Action(
        name="list_following",
        method="GET",
        path="/profile/{username}/following",
        summary="List the users a profile follows",
        requires_auth=False,
        params=(
            path("username", "Username whose following to list."),
            query("page", "Page number, 25 per page."),
        ),
    ),
    Action(
        name="list_media",
        method="GET",
        path="/profile/{username}",
        summary="List a user's uploaded media, newest first",
        requires_auth=False,
        params=(
            path("username", "Username whose media to list."),
            query("tab", "Profile tab (defaults to media for this action)."),
            query("page", "Page number, 24 per page."),
        ),
    ),
    Action(
        name="delete_media",
        method="POST",
        path="/media/{uid}/delete",
        summary="Delete an uploaded media attachment (your own, or anyone's when you are an administrator)",
        description=(
            "Removes the attachment from the user's Media tab and from any post, project, or "
            "gist where it was attached. Soft delete (restorable from admin trash). Show the user "
            "the exact media, get explicit confirmation, then call again with confirm=true."
        ),
        requires_auth=True,
        params=(path("uid", "Attachment uid to delete."), confirm()),
    ),
    Action(
        name="view_leaderboard",
        method="GET",
        path="/leaderboard",
        summary="View the leaderboard",
        requires_auth=False,
    ),
    Action(
        name="list_issues",
        method="GET",
        path="/issues",
        summary="List issue tickets from the Gitea tracker",
        requires_auth=False,
        params=(
            query("state", "Filter by state: open, closed, or all."),
            query("page", "1-based page number."),
        ),
    ),
    Action(
        name="create_issue",
        method="POST",
        path="/issues/create",
        summary="Report an issue. It is rewritten by AI and filed on the tracker.",
        description=(
            "Queues a background issue creation job and returns {uid, status_url}. Poll the "
            "status_url with issue_job_status until status is 'done', then show the user the "
            "issue_url pointing at the filed ticket."
        ),
        params=(
            body("title", "Issue title.", required=True),
            body("description", "Issue description.", required=True),
        ),
    ),
    Action(
        name="planning_report_generate",
        method="POST",
        path="/issues/planning",
        summary="Queue an admin planning report of all open tickets.",
        description=(
            "Queues a background job that builds a grouped, ordered markdown planning report "
            "of every open ticket and returns {uid, status_url}. Poll the status_url until "
            "status is 'done', then show the markdown and the download_url. Admin only."
        ),
        params=(),
        requires_auth=True,
        requires_admin=True,
    ),
    Action(
        name="issue_job_status",
        method="GET",
        path="/issues/jobs/{uid}",
        summary="Poll an issue creation job and obtain the filed issue ticket URL once finished",
        description=(
            "Returns the job status. When status is 'done', issue_url and number are populated; "
            "while 'pending' or 'running', poll again shortly."
        ),
        params=(path("uid", "Issue job uid returned by create_issue."),),
        requires_auth=False,
    ),
    Action(
        name="view_issue",
        method="GET",
        path="/issues/{number}",
        summary="View an issue ticket and its developer updates",
        requires_auth=False,
        params=(path("number", "The issue ticket number from a /issues listing."),),
    ),
    Action(
        name="comment_issue",
        method="POST",
        path="/issues/{number}/comment",
        summary="Post a comment on an issue ticket, attributed to the current user",
        params=(
            path("number", "The issue ticket number."),
            body("body", "Comment text.", required=True),
        ),
    ),
    Action(
        name="set_issue_status",
        method="POST",
        path="/issues/{number}/status",
        summary="Change an issue ticket status (admin only)",
        requires_admin=True,
        params=(
            path("number", "The issue ticket number."),
            body("status", "New status: open or closed.", required=True),
        ),
    ),
    Action(
        name="list_gists",
        method="GET",
        path="/gists",
        summary="List gists",
        requires_auth=False,
        params=(
            query("language", "Filter by language."),
            query("user_uid", "Filter by owner uid."),
            query("search", "Search gist title and description."),
            query("before", "Pagination cursor."),
        ),
    ),
    Action(
        name="view_gist",
        method="GET",
        path="/gists/{gist_slug}",
        summary="View a gist by slug",
        requires_auth=False,
        params=(
            path(
                "gist_slug",
                "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
            ),
        ),
    ),
    Action(
        name="create_gist",
        method="POST",
        path="/gists/create",
        summary="Create a gist",
        params=(
            body("title", "Gist title.", required=True),
            body("source_code", "Gist source code.", required=True),
            body("description", "Gist description."),
            body("language", "Programming language."),
            body("attachment_uids", ATTACHMENTS),
        ),
    ),
    Action(
        name="edit_gist",
        method="POST",
        path="/gists/edit/{gist_slug}",
        summary="Edit a gist",
        params=(
            path(
                "gist_slug",
                "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
            ),
            body("title", "Gist title.", required=True),
            body("source_code", "Gist source code.", required=True),
            body("description", "Gist description."),
            body("language", "Programming language."),
        ),
    ),
    Action(
        name="delete_gist",
        method="POST",
        path="/gists/delete/{gist_slug}",
        summary="Delete a gist (your own, or any gist when you are an administrator). Soft delete, confirmation required",
        params=(
            path(
                "gist_slug",
                "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
            ),
            confirm(),
        ),
    ),
    Action(
        name="list_news",
        method="GET",
        path="/news",
        summary="List news articles",
        requires_auth=False,
        params=(query("before", "Pagination cursor."),),
    ),
    Action(
        name="view_news",
        method="GET",
        path="/news/{news_slug}",
        summary="View a news article by slug",
        requires_auth=False,
        params=(
            path(
                "news_slug",
                "Exact news slug copied from a /news/... link in a listing response; do not build it from the title.",
            ),
        ),
    ),
    Action(
        name="upload_file",
        method="POST",
        path="/uploads/upload",
        summary="Upload a local file and obtain its attachment uid",
        params=(upload("file", "Local filesystem path of the file to upload."),),
    ),
    Action(
        name="attach_url",
        method="POST",
        path="/uploads/upload-url",
        summary="Download a file from a public URL and store it as an attachment, returning its uid",
        description=(
            "Fetches the file at the given URL on the server (size-capped, SSRF-guarded) and stores it "
            "as an attachment exactly like an upload, returning {uid, url, mime_type, ...}. Pass the "
            "returned uid in attachment_uids when you create_post, create_project, create_gist, "
            "create_comment, create_issue, or send_message to attach it. The file type is taken from the "
            "URL or its Content-Type; supply 'filename' (with an allowed extension) when the URL has no "
            "usable name. Use this to attach an image or file straight from the internet."
        ),
        params=(
            body("url", "Public http(s) URL of the file to download and attach.", required=True),
            body(
                "filename",
                "Optional filename with an allowed extension, used when the URL has no clear name.",
            ),
        ),
    ),
    Action(
        name="delete_attachment",
        method="DELETE",
        path="/uploads/delete/{attachment_uid}",
        summary="Delete an uploaded attachment (your own, or anyone's when you are an administrator). Soft delete, confirmation required",
        params=(path("attachment_uid", "Uid of the attachment."), confirm()),
    ),
    Action(
        name="admin_overview",
        method="GET",
        path="/admin",
        summary="View the admin overview",
        requires_admin=True,
    ),
    Action(
        name="site_analytics",
        method="GET",
        path="/admin/analytics",
        summary="Site-wide aggregate analytics in one call (admin only)",
        description=(
            "Returns JSON: total members, active users in the last 24h/7d/30d, signed_in_now, "
            "new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
            "news), and top authors. Use this for any 'how many'/'how active'/count question "
            "instead of paging through admin_list_users. signed_in_now counts members holding an "
            "unexpired session (logged in within the session lifetime, default 7-30 days), NOT a "
            "real-time online count - see signed_in_definition in the response and never report it "
            "as 'currently online' or 'logged in right now'."
        ),
        params=(query("top_n", "How many top authors to include (1-50)."),),
        requires_admin=True,
    ),
    Action(
        name="ai_usage",
        method="GET",
        path="/admin/ai-usage/data",
        summary="AI gateway usage, cost, latency, and reliability metrics (admin only)",
        description=(
            "Returns JSON for a bounded window: request volume and throughput, token usage with "
            "averages and percentiles, latency (upstream, gateway overhead, queue wait, connect), "
            "error rates by category, cost in USD (per model, per caller, input vs output, projected "
            "monthly burn, caching savings), caller behavior, and an hourly breakdown. Use this for "
            "any question about AI spend, token consumption, gateway performance, or errors."
        ),
        params=(
            query("hours", "Lookback window in hours (1-168, default 48)."),
            query("top_n", "How many rows in each top-N breakdown (default 10)."),
        ),
        requires_admin=True,
    ),
    Action(
        name="audit_log",
        method="GET",
        path="/admin/audit-log",
        summary="Query the platform audit trail with filters (admin only)",
        description=(
            "Returns JSON: a paginated, newest-first list of audit events (every state-changing "
            "action on the platform, with actor, origin, target, old/new value, and result), plus "
            "pagination, the active filters, and an 'options' object listing every available value "
            "for each filter (category, event_key, actor_role, origin, result) so you can both "
            "filter and discover valid filter values from a single call. Use this for any audit, "
            "history, 'who did X', 'what changed', or 'show denied/failed actions' question. Filter "
            "by event_key (exact), category, actor_role, actor_uid, origin (web, api, devii, cli, "
            "service, scheduler), or result (success, failure, denied); narrow by date_from/date_to "
            "(ISO date bounds, inclusive) or free-text q over summary, event key, and target."
        ),
        params=(
            query("page", "Page number (1-based)."),
            query("event_key", "Filter by exact event key (e.g. admin.setting.update)."),
            query("category", "Filter by category (auth, content, admin, container, ...)."),
            query("actor_role", "Filter by actor role at action time."),
            query("actor_uid", "Filter by acting user uid."),
            query("origin", "Filter by origin (web, api, devii, cli, service, scheduler)."),
            query("result", "Filter by result (success, failure, denied)."),
            query("q", "Free-text search over summary, event key, and target."),
            query("date_from", "ISO date lower bound (inclusive)."),
            query("date_to", "ISO date upper bound (inclusive)."),
        ),
        requires_admin=True,
    ),
    Action(
        name="audit_event",
        method="GET",
        path="/admin/audit-log/{uid}",
        summary="Get one audit event with its related-object links (admin only)",
        description=(
            "Returns JSON: the full audit event row plus every related-object link (actor, target, "
            "parent, project, instance, setting, ...). Use after audit_log to inspect a single event "
            "in detail."
        ),
        params=(path("uid", "Audit event uid."),),
        requires_admin=True,
    ),
    Action(
        name="bot_monitor",
        method="GET",
        path="/admin/bots/data",
        summary="Live screenshot monitor of every running bot persona (admin only)",
        description=(
            "Returns JSON: one entry per running bot slot with its username, persona, current "
            "action and status text, last page url, frame age in seconds, whether it is active, "
            "and a frame_url to the latest low-quality screenshot. Use this to see what the bot "
            "fleet is doing right now. Frames live only in the worker running the Bots service."
        ),
        requires_admin=True,
    ),
    Action(
        name="admin_list_users",
        method="GET",
        path="/admin/users",
        summary="List users for administration",
        params=(query("page", "Page number."),),
        requires_admin=True,
    ),
    Action(
        name="admin_set_user_role",
        method="POST",
        path="/admin/users/{uid}/role",
        summary="Set a user's role",
        params=(
            path("uid", "User uid."),
            body("role", "New role.", required=True),
        ),
        requires_admin=True,
    ),
    Action(
        name="admin_set_user_password",
        method="POST",
        path="/admin/users/{uid}/password",
        summary="Set a user's password",
        params=(
            path("uid", "User uid."),
            body("password", "New password.", required=True),
        ),
        requires_admin=True,
    ),
    Action(
        name="admin_toggle_user",
        method="POST",
        path="/admin/users/{uid}/toggle",
        summary="Toggle a user's active state",
        params=(path("uid", "User uid."),),
        requires_admin=True,
    ),
    Action(
        name="admin_get_settings",
        method="GET",
        path="/admin/settings",
        summary="View site settings",
        requires_admin=True,
    ),
    Action(
        name="admin_save_settings",
        method="POST",
        path="/admin/settings",
        summary="Save site settings",
        params=(
            body("site_name", "Site name."),
            body("site_description", "Site description."),
            body("site_tagline", "Site tagline."),
            body("max_upload_size_mb", "Maximum upload size in megabytes."),
            body("allowed_file_types", "Allowed file types."),
            body("max_attachments_per_resource", "Maximum attachments per resource."),
            body("rate_limit_per_minute", "Rate limit per minute."),
            body("rate_limit_window_seconds", "Rate limit window in seconds."),
            body("session_max_age_days", "Session maximum age in days."),
            body("session_remember_days", "Remember-me duration in days."),
            body("registration_open", "Whether registration is open."),
            body("maintenance_mode", "Whether maintenance mode is on."),
            body("maintenance_message", "Maintenance message."),
        ),
        requires_admin=True,
    ),
    Action(
        name="admin_list_news",
        method="GET",
        path="/admin/news",
        summary="List news for administration",
        params=(query("page", "Page number."),),
        requires_admin=True,
    ),
    Action(
        name="admin_toggle_news",
        method="POST",
        path="/admin/news/{uid}/toggle",
        summary="Toggle a news article",
        params=(path("uid", "News uid."),),
        requires_admin=True,
    ),
    Action(
        name="admin_publish_news",
        method="POST",
        path="/admin/news/{uid}/publish",
        summary="Publish a news article",
        params=(path("uid", "News uid."),),
        requires_admin=True,
    ),
    Action(
        name="admin_landing_news",
        method="POST",
        path="/admin/news/{uid}/landing",
        summary="Set a news article as landing content",
        params=(path("uid", "News uid."),),
        requires_admin=True,
    ),
    Action(
        name="admin_delete_news",
        method="POST",
        path="/admin/news/{uid}/delete",
        summary="Delete a news article (admin only). Soft delete, confirmation required",
        params=(path("uid", "News uid."), confirm()),
        requires_admin=True,
    ),
    Action(
        name="admin_list_services",
        method="GET",
        path="/admin/services",
        summary="View managed services",
        requires_admin=True,
    ),
    Action(
        name="admin_services_data",
        method="GET",
        path="/admin/services/data",
        summary="Get live status, metrics, and log tail for every background service",
        requires_admin=True,
    ),
    Action(
        name="admin_service_status",
        method="GET",
        path="/admin/services/{name}/data",
        summary="Get live status, metrics, and log tail for one background service",
        params=(path("name", "Service name (e.g. news, bots, openai)."),),
        requires_admin=True,
    ),
    Action(
        name="admin_start_service",
        method="POST",
        path="/admin/services/{name}/start",
        summary="Start a managed service",
        params=(path("name", "Service name."),),
        requires_admin=True,
    ),
    Action(
        name="admin_stop_service",
        method="POST",
        path="/admin/services/{name}/stop",
        summary="Stop a managed service",
        params=(path("name", "Service name."),),
        requires_admin=True,
    ),
    Action(
        name="admin_run_service",
        method="POST",
        path="/admin/services/{name}/run",
        summary="Run a managed service once",
        params=(path("name", "Service name."),),
        requires_admin=True,
    ),
    Action(
        name="admin_clear_service_logs",
        method="POST",
        path="/admin/services/{name}/clear-logs",
        summary="Clear a managed service's logs",
        params=(path("name", "Service name."),),
        requires_admin=True,
    ),
    Action(
        name="admin_config_service",
        method="POST",
        path="/admin/services/{name}/config",
        summary="Update a managed service's configuration",
        params=(path("name", "Service name."),),
        freeform_body=True,
        requires_admin=True,
    ),
    Action(
        name="admin_user_ai_usage",
        method="GET",
        path="/admin/users/{uid}/ai-usage",
        summary="Get a user's AI gateway usage (admin only)",
        description="Returns per-user AI usage data including token counts, cost, and request volume for the given lookback window.",
        params=(
            path("uid", "User uid."),
            query("hours", "Lookback window in hours (default 24)."),
        ),
        requires_admin=True,
    ),
    Action(
        name="admin_reset_user_ai_quota",
        method="POST",
        path="/admin/users/{uid}/reset-ai-quota",
        summary="Reset a user's AI quota (admin only)",
        description="Clears the AI quota ledger for a specific user, allowing them to use AI features again.",
        params=(path("uid", "User uid."), confirm()),
        requires_admin=True,
    ),
    Action(
        name="admin_media_purge",
        method="POST",
        path="/admin/media/{uid}/purge",
        summary="Permanently remove soft-deleted media (admin only)",
        description="Permanently deletes a soft-deleted attachment from the database and filesystem.",
        params=(path("uid", "Attachment uid to purge."), confirm()),
        requires_admin=True,
    ),
    Action(
        name="admin_reset_guest_ai_quota",
        method="POST",
        path="/admin/ai-quota/reset-guests",
        summary="Reset all guest AI quotas (admin only)",
        description="Clears the AI quota ledger for all guest sessions.",
        params=(confirm(),),
        requires_admin=True,
    ),
    Action(
        name="admin_reset_all_ai_quota",
        method="POST",
        path="/admin/ai-quota/reset-all",
        summary="Reset ALL AI quotas including member quotas (admin only)",
        description="Clears the AI quota ledger for every user and guest. Use with caution.",
        params=(confirm(),),
        requires_admin=True,
    ),
    Action(
        name="backups_overview",
        method="GET",
        path="/admin/backups/data",
        summary="Backup dashboard: storage usage, backups, and schedules (admin only)",
        description=(
            "Returns JSON: per-path storage usage (database, uploads, attachments, project files, "
            "keys, zips, deepsearch, container workspaces, backups), total data-directory size, total "
            "stored-backup size and count, disk usage (total/used/free/percent), every backup archive "
            "(target, status, size, file count, sha256 checksum, created/completed time, download_url), "
            "and every configured backup schedule. Use this for any question about backups, data "
            "footprint, disk space, or what is scheduled."
        ),
        requires_admin=True,
    ),
    Action(
        name="backup_run",
        method="POST",
        path="/admin/backups/run",
        summary="Start a backup of a data target (admin only)",
        description=(
            "Enqueues an async backup job and returns its job uid and status_url. Target is one of: "
            "database (consistent SQLite snapshot plus Devii databases), uploads (attachments and "
            "project files), keys (VAPID keys and config), or full (database, uploads, and keys in one "
            "archive). Poll backup_status with the returned uid until status is done, then read the "
            "download_url. The job runs off the request path and never blocks the server."
        ),
        params=(
            body("target", "One of database, uploads, keys, full.", required=True),
        ),
        requires_admin=True,
    ),
    Action(
        name="backup_status",
        method="GET",
        path="/admin/backups/jobs/{uid}",
        summary="Check the status of a backup job (admin only)",
        description=(
            "Returns JSON for one backup job: status (pending, running, done, failed), target, "
            "backup_uid, download_url (when done), sha256, archive size, file count, and timestamps. "
            "Poll this after backup_run."
        ),
        params=(path("uid", "Backup job uid returned by backup_run."),),
        requires_admin=True,
    ),
    Action(
        name="backup_delete",
        method="POST",
        path="/admin/backups/{uid}/delete",
        summary="Permanently delete a backup archive (admin only)",
        description=(
            "Removes a backup archive file and its record. This is irreversible and reclaims disk "
            "space. The uid is a backup uid (from backups_overview), not a job uid."
        ),
        params=(path("uid", "Backup uid to delete."), confirm()),
        requires_admin=True,
    ),
    Action(
        name="backup_schedule_create",
        method="POST",
        path="/admin/backups/schedules/create",
        summary="Create a recurring backup schedule (admin only)",
        description=(
            "Creates a schedule that fires backups automatically. kind is 'interval' (use every_seconds, "
            "minimum 60) or 'cron' (use a 5-field cron expression in 'minute hour dom month dow'). "
            "keep_last rotates older backups of this schedule, keeping only the newest N (0 keeps all). "
            "target is one of database, uploads, keys, full."
        ),
        params=(
            body("name", "Human-readable schedule name.", required=True),
            body("target", "One of database, uploads, keys, full.", required=True),
            body("kind", "interval or cron.", required=True),
            body("every_seconds", "Seconds between runs when kind=interval (min 60)."),
            body("cron", "Cron expression when kind=cron, e.g. '0 3 * * *'."),
            body("keep_last", "Keep only the newest N backups of this schedule (0 = all)."),
        ),
        requires_admin=True,
    ),
    Action(
        name="backup_schedule_delete",
        method="POST",
        path="/admin/backups/schedules/{uid}/delete",
        summary="Delete a backup schedule (admin only)",
        description=(
            "Removes a backup schedule so it stops firing. Existing backup archives are kept; only the "
            "schedule is deleted."
        ),
        params=(path("uid", "Backup schedule uid."), confirm()),
        requires_admin=True,
    ),
    Action(
        name="restore_media",
        method="POST",
        path="/media/{uid}/restore",
        summary="Restore a soft-deleted media attachment (admin only)",
        description="Restores a previously soft-deleted attachment, making it visible again.",
        params=(path("uid", "Attachment uid to restore."),),
        requires_admin=True,
    ),
    Action(
        name="db_list_tables",
        method="GET",
        path="/dbapi/tables",
        summary="List database tables exposed by the database API (admin only)",
        description=(
            "Returns every table reachable through the database API with its row count and "
            "whether it uses soft deletes. Use this to discover what data exists before "
            "querying or designing a query."
        ),
        params=(),
        requires_admin=True,
    ),
    Action(
        name="db_table_schema",
        method="GET",
        path="/dbapi/{table}/schema",
        summary="Show a table's columns and types (admin only)",
        description="Returns the column names, types, row count, and soft-delete flag for one table.",
        params=(path("table", "Table name (from db_list_tables)."),),
        requires_admin=True,
    ),
    Action(
        name="db_list_rows",
        method="GET",
        path="/dbapi/{table}",
        summary="List rows of a table with keyset pagination (admin only)",
        description=(
            "Browses rows newest-first. Soft-deleted rows are excluded unless include_deleted is "
            "true. For filtered or joined questions prefer db_query or db_design_query."
        ),
        params=(
            path("table", "Table name."),
            query("limit", "Maximum rows (1-500, default 25)."),
            query("search", "Free-text search over common text columns."),
            query("before", "Keyset cursor: return rows older than this created_at/id value."),
            Param(
                name="include_deleted",
                location="query",
                description="Include soft-deleted rows.",
                required=False,
                type="boolean",
            ),
        ),
        requires_admin=True,
    ),
    Action(
        name="db_get_row",
        method="GET",
        path="/dbapi/{table}/{key}/{value}",
        summary="Fetch one row by a key column (admin only)",
        description="Returns a single row where key column equals value (key is usually 'uid').",
        params=(
            path("table", "Table name."),
            path("key", "Key column to match (usually 'uid')."),
            path("value", "Value of the key column."),
        ),
        requires_admin=True,
    ),
    Action(
        name="db_query",
        method="POST",
        path="/dbapi/query",
        summary="Run a read-only SQL SELECT and return rows (admin only)",
        description=(
            "Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
            "SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected (use db_insert_row, "
            "db_update_row, db_delete_row for changes). The response may include a 'suspicious' "
            "list (e.g. a SELECT with no WHERE/JOIN/LIMIT that scans a whole table); when present, "
            "surface that warning to the user before trusting the results."
        ),
        params=(
            body("sql", "A single SELECT statement.", required=True),
            body("dialect", "Optional source SQL dialect (default sqlite)."),
        ),
        requires_admin=True,
        read_only=True,
    ),
    Action(
        name="db_design_query",
        method="POST",
        path="/dbapi/nl",
        summary="Design a SQL SELECT from a natural-language question (admin only)",
        description=(
            "Turns a plain-language question about one table into a validated read-only SELECT. "
            "It auto-adds 'deleted_at IS NULL' for soft-delete tables unless apply_soft_delete is "
            "false. By default it only returns the SQL; pass execute=true to also run it read-only "
            "and return rows. Show the user the SQL and any 'suspicious' notes."
        ),
        params=(
            body("question", "The natural-language request.", required=True),
            body("table", "Target table the question is about.", required=True),
            Param(
                name="apply_soft_delete",
                location="body",
                description="Add deleted_at IS NULL for soft-delete tables (default true).",
                required=False,
                type="boolean",
            ),
            body("dialect", "Optional target SQL dialect (default sqlite)."),
            Param(
                name="execute",
                location="body",
                description="Also run the validated query read-only and return rows.",
                required=False,
                type="boolean",
            ),
        ),
        requires_admin=True,
        read_only=True,
    ),
    Action(
        name="db_insert_row",
        method="POST",
        path="/dbapi/{table}",
        summary="Insert a row into a table (admin only, confirmation required)",
        description=(
            "Inserts a new row. Pass the column values as a JSON object string in values_json. "
            "Soft-delete columns and uid/created_at are filled automatically. Requires confirmation."
        ),
        params=(
            path("table", "Table name."),
            body("values_json", "JSON object of column:value pairs for the new row.", required=True),
            confirm(),
        ),
        requires_admin=True,
    ),
    Action(
        name="db_update_row",
        method="PATCH",
        path="/dbapi/{table}/{key}/{value}",
        summary="Update a row in a table (admin only, confirmation required)",
        description=(
            "Updates the row where key equals value. Pass the changed columns as a JSON object "
            "string in values_json. uid and id cannot be changed. Requires confirmation."
        ),
        params=(
            path("table", "Table name."),
            path("key", "Key column to match (usually 'uid')."),
            path("value", "Value of the key column."),
            body("values_json", "JSON object of column:value pairs to change.", required=True),
            confirm(),
        ),
        requires_admin=True,
    ),
    Action(
        name="db_delete_row",
        method="DELETE",
        path="/dbapi/{table}/{key}/{value}",
        summary="Delete a row from a table (admin only, confirmation required)",
        description=(
            "Soft-deletes the row where key equals value (restorable). Pass hard=true to "
            "PERMANENTLY purge it (or for tables without soft delete). Requires confirmation."
        ),
        params=(
            path("table", "Table name."),
            path("key", "Key column to match (usually 'uid')."),
            path("value", "Value of the key column."),
            Param(
                name="hard",
                location="query",
                description="Permanently purge instead of soft delete.",
                required=False,
                type="boolean",
            ),
            confirm(),
        ),
        requires_admin=True,
    ),
)

PLATFORM_CATALOG = Catalog(actions=ACTIONS)
