openapi: 3.0.3
info:
  title: Tanabata File Manager API
  description: |
    REST API for Tanabata File Manager — a multi-user, tag-based web file manager.

    ## Versioning
    The `version` field above is the product release (tracks the app's 3.0.0).
    The URL path version (`/api/v1`) is the API compatibility version and changes
    only on a breaking contract change — the two are intentionally independent.

    ## Authentication
    All endpoints require a Bearer JWT token in the `Authorization` header,
    except `POST /auth/login`, `POST /auth/refresh` (which carry their own
    credentials) and `GET /health`.

    ## Pagination
    - **Files**: cursor-based (`cursor` parameter, returned in `next_cursor`).
    - **All other lists**: offset-based (`offset` + `limit`).

    ## Error format
    ```json
    {
      "code": "string",
      "message": "string",
      "details": [{ "field": "string", "message": "string" }]
    }
    ```

    ## File filter DSL
    Files can be filtered via a DSL in the `filter` query parameter.
    Tokens are comma-separated inside braces: `{token1,token2,...}`.

    Operators: `(`, `)`, `&` (AND), `|` (OR), `!` (NOT).
    Conditions: `t=<tag_uuid>` (has tag), `t=00000000-0000-0000-0000-000000000000` (untagged),
    `m=<mime_id>` (exact MIME), `m~<pattern>` (MIME LIKE pattern, e.g. `m~image%`),
    `r=1` (needs review / not yet tagged-done), `r=0` (review done).

    Example: `{t=uuid1,&,!,t=uuid2}` → has tag1 AND NOT tag2.
    Example: `{r=1,&,m~image%}` → needs review AND is an image.
  version: 3.0.0
  license:
    name: AGPL-3.0-or-later
    url: https://www.gnu.org/licenses/agpl-3.0.html

servers:
  - url: /api/v1

security:
  - bearerAuth: []

tags:
  - name: Auth
    description: Authentication and session management
  - name: Files
    description: File management, upload, trash
  - name: Tags
    description: Tag CRUD and tag rules
  - name: Categories
    description: Category CRUD
  - name: Pools
    description: Pool CRUD, file ordering
  - name: ACL
    description: Access control
  - name: Users
    description: User management (admin)
  - name: Audit
    description: Audit log (admin)
  - name: System
    description: Service health and liveness

# ===========================================================================
# Paths
# ===========================================================================
paths:

  # -------------------------------------------------------------------------
  # System
  # -------------------------------------------------------------------------
  /health:
    # Served at the server root, outside the /api/v1 prefix — override the
    # global server so the documented path is /health, not /api/v1/health.
    servers:
      - url: /
    get:
      tags: [System]
      summary: Health check
      description: |
        Liveness probe. Requires no authentication and is used by the container
        HEALTHCHECK. Always returns 200 while the process is serving.
      security: []
      responses:
        '200':
          description: Service is healthy.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    example: ok

  # -------------------------------------------------------------------------
  # Auth
  # -------------------------------------------------------------------------
  /auth/login:
    post:
      tags: [Auth]
      summary: Log in
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, password]
              properties:
                name:
                  type: string
                password:
                  type: string
      responses:
        '200':
          description: JWT token pair
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenPair'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /auth/refresh:
    post:
      tags: [Auth]
      summary: Refresh access token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token:
                  type: string
      responses:
        '200':
          description: New token pair
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenPair'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /auth/logout:
    post:
      tags: [Auth]
      summary: Log out (invalidate current session)
      responses:
        '204':
          description: Logged out

  /auth/sessions:
    get:
      tags: [Auth]
      summary: List my active sessions
      parameters:
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Session list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionList'

  /auth/sessions/{session_id}:
    delete:
      tags: [Auth]
      summary: Terminate a session
      parameters:
        - name: session_id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '204':
          description: Session terminated
        '404':
          $ref: '#/components/responses/NotFound'

  # -------------------------------------------------------------------------
  # Files
  # -------------------------------------------------------------------------
  /files:
    get:
      tags: [Files]
      summary: List files (cursor-based pagination)
      parameters:
        - name: cursor
          in: query
          description: Cursor from previous response's `next_cursor` or `prev_cursor`
          schema:
            type: string
        - name: direction
          in: query
          description: Pagination direction relative to cursor
          schema:
            type: string
            enum: [forward, backward]
            default: forward
        - name: anchor
          in: query
          description: |
            File UUID to anchor the listing around. The response will
            include this file and its neighbors. Use with `direction`
            and `limit` to load items before/after the anchor.
            Mutually exclusive with `cursor`.
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
        - name: sort
          in: query
          description: Sort field
          schema:
            type: string
            enum: [content_datetime, created, original_name, mime]
            default: created
        - name: order
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: desc
        - name: filter
          in: query
          description: Filter DSL expression (see API description)
          schema:
            type: string
        - name: search
          in: query
          description: Search by original_name (substring match)
          schema:
            type: string
        - name: trash
          in: query
          description: If true, return only soft-deleted files (trash)
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: Paginated file list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileCursorPage'

    post:
      tags: [Files]
      summary: Upload a new file
      description: |
        Upload a file via multipart form. If `content_datetime` is not provided,
        it is extracted from EXIF data (DateTimeOriginal). EXIF metadata is
        automatically extracted and stored in the `exif` field (immutable).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                content_datetime:
                  type: string
                  format: date-time
                notes:
                  type: string
                metadata:
                  type: string
                  description: JSON string of key-value pairs
                is_public:
                  type: boolean
                  default: false
                tag_ids:
                  type: string
                  description: Comma-separated tag UUIDs to assign
      responses:
        '201':
          description: File created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/File'
        '400':
          $ref: '#/components/responses/ValidationError'
        '415':
          description: Unsupported MIME type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /files/{file_id}:
    get:
      tags: [Files]
      summary: Get file metadata
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '200':
          description: File metadata
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/File'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [Files]
      summary: Update file metadata
      parameters:
        - $ref: '#/components/parameters/file_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FileUpdate'
      responses:
        '200':
          description: Updated file
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/File'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [Files]
      summary: Soft-delete file (move to trash)
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '204':
          description: Moved to trash
        '404':
          $ref: '#/components/responses/NotFound'

  /files/{file_id}/content:
    get:
      tags: [Files]
      summary: Download file content
      description: >
        Returns the original file bytes. Served as an attachment (download) by
        default; pass inline=1 to serve it for in-tab viewing
        (Content-Disposition: inline). For browser navigation/new-tab opens that
        can't send the Authorization header, the access token may be supplied as
        the access_token query parameter (GET only).
      parameters:
        - $ref: '#/components/parameters/file_id'
        - name: inline
          in: query
          required: false
          schema:
            type: string
            enum: ['1']
          description: When '1', serve inline (view) instead of as a download.
        - name: access_token
          in: query
          required: false
          schema:
            type: string
          description: >
            Access token or a file-scoped content token (obtained from POST
            /files/{file_id}/content-token), as an alternative to the
            Authorization header (GET only). A content token outlives the
            access token, so long media keeps streaming past access-token expiry.
      responses:
        '200':
          description: File binary
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [Files]
      summary: Replace file content (keep same ID)
      parameters:
        - $ref: '#/components/parameters/file_id'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '200':
          description: File replaced
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/File'
        '415':
          description: Unsupported MIME type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /files/{file_id}/content-token:
    post:
      tags: [Files]
      summary: Mint a content token for opening/streaming the original by URL
      description: >
        Returns a short-lived, single-file capability token to place in the
        access_token query parameter of GET /files/{file_id}/content. Unlike the
        access token it is scoped to this one file and is session-independent, so
        it survives access-token expiry and refresh rotation — letting a long
        video opened in a new tab keep streaming. Requires view permission on the
        file. The token is a bearer credential for that file until it expires.
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '200':
          description: Content token
          content:
            application/json:
              schema:
                type: object
                required: [token, expires_in]
                properties:
                  token:
                    type: string
                    description: Capability token for the access_token query parameter.
                  expires_in:
                    type: integer
                    description: Token lifetime in seconds.
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /files/{file_id}/thumbnail:
    get:
      tags: [Files]
      summary: Get file thumbnail
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '200':
          description: Thumbnail image
          content:
            image/jpeg:
              schema:
                type: string
                format: binary
        '404':
          $ref: '#/components/responses/NotFound'

  /files/{file_id}/preview:
    get:
      tags: [Files]
      summary: Get file preview (larger than thumbnail)
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '200':
          description: Preview image
          content:
            image/jpeg:
              schema:
                type: string
                format: binary
        '404':
          $ref: '#/components/responses/NotFound'

  /files/{file_id}/views:
    post:
      tags: [Files]
      summary: Record that the current user viewed the file
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '204':
          description: View recorded
        '404':
          $ref: '#/components/responses/NotFound'

  /files/{file_id}/restore:
    post:
      tags: [Files]
      summary: Restore file from trash
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '200':
          description: File restored
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/File'
        '404':
          $ref: '#/components/responses/NotFound'

  /files/{file_id}/permanent:
    delete:
      tags: [Files]
      summary: Permanently delete file (from trash only)
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '204':
          description: Permanently deleted
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: File is not in trash
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # --- File-Tag relations ---
  /files/{file_id}/tags:
    get:
      tags: [Files, Tags]
      summary: List tags assigned to a file
      parameters:
        - $ref: '#/components/parameters/file_id'
      responses:
        '200':
          description: Tag list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Tag'

    put:
      tags: [Files, Tags]
      summary: Set tags on a file (replaces all)
      parameters:
        - $ref: '#/components/parameters/file_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tag_ids]
              properties:
                tag_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
      responses:
        '200':
          description: Updated tag list (including auto-applied tags)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Tag'

  /files/{file_id}/tags/{tag_id}:
    put:
      tags: [Files, Tags]
      summary: Add a tag to a file
      parameters:
        - $ref: '#/components/parameters/file_id'
        - $ref: '#/components/parameters/tag_id'
      responses:
        '200':
          description: Tags after addition (including auto-applied)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Tag'

    delete:
      tags: [Files, Tags]
      summary: Remove a tag from a file
      parameters:
        - $ref: '#/components/parameters/file_id'
        - $ref: '#/components/parameters/tag_id'
      responses:
        '204':
          description: Tag removed

  # --- Bulk file operations ---
  /files/bulk/tags:
    post:
      tags: [Files, Tags]
      summary: Bulk add/remove tags on multiple files
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_ids, action, tag_ids]
              properties:
                file_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
                action:
                  type: string
                  enum: [add, remove]
                tag_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
      responses:
        '200':
          description: Result with all applied tag IDs (including auto-applied, for add)
          content:
            application/json:
              schema:
                type: object
                properties:
                  applied_tag_ids:
                    type: array
                    items:
                      type: string
                      format: uuid

  /files/bulk/delete:
    post:
      tags: [Files]
      summary: Bulk soft-delete files
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_ids]
              properties:
                file_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
      responses:
        '204':
          description: Files moved to trash

  /files/bulk/review:
    post:
      tags: [Files]
      summary: Set the review status on one or more files
      description: >-
        Marks the given files as needing review (`needs_review=true`) or as
        review-done (`false`). A single-file toggle is just a one-element list.
        Files the caller cannot edit are silently skipped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_ids, needs_review]
              properties:
                file_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
                needs_review:
                  type: boolean
      responses:
        '204':
          description: Review status updated

  /files/bulk/common-tags:
    post:
      tags: [Files, Tags]
      summary: Get tags for multiple files, split into common (all) and partial (some)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_ids]
              properties:
                file_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
      responses:
        '200':
          description: Tags grouped by coverage
          content:
            application/json:
              schema:
                type: object
                properties:
                  common_tag_ids:
                    type: array
                    description: Tags present on ALL specified files
                    items:
                      type: string
                      format: uuid
                  partial_tag_ids:
                    type: array
                    description: Tags present on SOME but not all specified files
                    items:
                      type: string
                      format: uuid

  # --- Duplicate detection ---
  /files/duplicates:
    get:
      tags: [Files]
      summary: List duplicate clusters
      description: >-
        Groups of perceptually similar files (within the server's hash-distance
        threshold), read from a precomputed pairs table — this never compares all
        files on each call. Pairs are (re)built offline by the dedup tool, so the
        result reflects state as of the last rescan. Only files the caller may view
        are included; dismissed and trashed pairs are excluded.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 50
          description: Maximum number of clusters to return
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        '200':
          description: A page of duplicate clusters
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DuplicateClusterPage'

  /files/duplicates/dismiss:
    post:
      tags: [Files]
      summary: Mark two files as not duplicates
      description: >-
        Records a global "not a duplicate" decision so the pair stops appearing in
        the duplicates view (it survives future rescans). The caller must be able
        to view both files.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_id_a, file_id_b]
              properties:
                file_id_a:
                  type: string
                  format: uuid
                file_id_b:
                  type: string
                  format: uuid
      responses:
        '204':
          description: Pair dismissed

  /files/duplicates/resolve:
    post:
      tags: [Files]
      summary: Resolve a duplicate by merging two files
      description: >-
        Keeps one file and folds the chosen fields in from the other, then (by
        default) trashes the other. The caller must be able to edit both files. To
        simply delete one/both or to keep both, use the bulk-delete and dismiss
        endpoints instead. Returns the updated survivor.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DuplicateResolve'
      responses:
        '200':
          description: The updated surviving file
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/File'

  # --- File import ---
  /files/import:
    post:
      tags: [Files]
      summary: Import files from a server directory
      description: >
        Admin only. Ingests supported files from the server's configured import
        directory (optionally a subfolder of it). Subdirectories are skipped and
        not recursed. A successfully imported file is removed from the import
        folder. For files without an EXIF date, the source file's modified time
        is used as content_datetime.


        Progress is streamed as newline-delimited JSON (`application/x-ndjson`):
        a single `start` event, one `file` event per directory entry as it is
        processed, and a final `done` event with the tallies. A validation error
        raised before any file is touched (e.g. import disabled, bad path) is
        instead returned as a normal JSON error with a 4xx/5xx status.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: Server directory path (uses user's configured import path if omitted)
      responses:
        '200':
          description: >
            A stream of newline-delimited JSON import progress events. The
            schema below describes a single event line; the response body is one
            such object per line.
          content:
            application/x-ndjson:
              schema:
                type: object
                required: [type]
                properties:
                  type:
                    type: string
                    enum: [start, file, done, error]
                    description: Event discriminator.
                  total:
                    type: integer
                    description: Entries to process (start) or processed (done).
                  index:
                    type: integer
                    description: 1-based position of this entry (file events).
                  filename:
                    type: string
                    description: Entry name (file events).
                  status:
                    type: string
                    enum: [imported, skipped, error]
                    description: Outcome for this entry (file events).
                  reason:
                    type: string
                    description: Detail for a skipped/error/warning entry.
                  imported:
                    type: integer
                    description: Total imported (done event).
                  skipped:
                    type: integer
                    description: Total skipped (done event).
                  errors:
                    type: integer
                    description: Total errors (done event).

  # -------------------------------------------------------------------------
  # Tags
  # -------------------------------------------------------------------------
  /tags:
    get:
      tags: [Tags]
      summary: List tags
      parameters:
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/limit'
        - name: sort
          in: query
          schema:
            type: string
            enum: [name, color, category_name, created]
            default: created
        - name: order
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: desc
        - name: search
          in: query
          description: Search by name (substring)
          schema:
            type: string
      responses:
        '200':
          description: Tag list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagOffsetPage'

    post:
      tags: [Tags]
      summary: Create a tag
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TagCreate'
      responses:
        '201':
          description: Tag created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tag'
        '400':
          $ref: '#/components/responses/ValidationError'
        '409':
          description: Tag name already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /tags/{tag_id}:
    get:
      tags: [Tags]
      summary: Get a tag
      parameters:
        - $ref: '#/components/parameters/tag_id'
      responses:
        '200':
          description: Tag
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tag'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [Tags]
      summary: Update a tag
      parameters:
        - $ref: '#/components/parameters/tag_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TagUpdate'
      responses:
        '200':
          description: Updated tag
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tag'

    delete:
      tags: [Tags]
      summary: Delete a tag
      parameters:
        - $ref: '#/components/parameters/tag_id'
      responses:
        '204':
          description: Tag deleted

  /tags/{tag_id}/files:
    get:
      tags: [Tags, Files]
      summary: List files with this tag
      parameters:
        - $ref: '#/components/parameters/tag_id'
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
      responses:
        '200':
          description: File list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileCursorPage'

  # --- Tag rules ---
  /tags/{tag_id}/rules:
    get:
      tags: [Tags]
      summary: List tag rules for a tag (when this tag is applied, which tags follow)
      parameters:
        - $ref: '#/components/parameters/tag_id'
      responses:
        '200':
          description: Tag rules
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TagRule'

    post:
      tags: [Tags]
      summary: Add a tag rule
      parameters:
        - $ref: '#/components/parameters/tag_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [then_tag_id]
              properties:
                then_tag_id:
                  type: string
                  format: uuid
                is_active:
                  type: boolean
                  default: true
                apply_to_existing:
                  type: boolean
                  default: true
                  description: Apply rule retroactively to files already tagged
      responses:
        '201':
          description: Rule created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagRule'

  /tags/{tag_id}/rules/{then_tag_id}:
    patch:
      tags: [Tags]
      summary: Update a tag rule (activate / deactivate)
      parameters:
        - $ref: '#/components/parameters/tag_id'
        - name: then_tag_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [is_active]
              properties:
                is_active:
                  type: boolean
                apply_to_existing:
                  type: boolean
                  default: false
                  description: When activating, apply rule retroactively to files already tagged
      responses:
        '200':
          description: Rule updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagRule'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      tags: [Tags]
      summary: Remove a tag rule
      parameters:
        - $ref: '#/components/parameters/tag_id'
        - name: then_tag_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Rule removed

  # -------------------------------------------------------------------------
  # Categories
  # -------------------------------------------------------------------------
  /categories:
    get:
      tags: [Categories]
      summary: List categories
      parameters:
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/limit'
        - name: sort
          in: query
          schema:
            type: string
            enum: [name, created]
            default: created
        - name: order
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: desc
        - name: search
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Category list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryOffsetPage'

    post:
      tags: [Categories]
      summary: Create a category
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryCreate'
      responses:
        '201':
          description: Category created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Category'
        '409':
          description: Name already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /categories/{category_id}:
    get:
      tags: [Categories]
      summary: Get a category
      parameters:
        - $ref: '#/components/parameters/category_id'
      responses:
        '200':
          description: Category
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Category'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [Categories]
      summary: Update a category
      parameters:
        - $ref: '#/components/parameters/category_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryUpdate'
      responses:
        '200':
          description: Updated category
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Category'

    delete:
      tags: [Categories]
      summary: Delete a category
      parameters:
        - $ref: '#/components/parameters/category_id'
      responses:
        '204':
          description: Category deleted

  /categories/{category_id}/tags:
    get:
      tags: [Categories, Tags]
      summary: List tags in a category
      parameters:
        - $ref: '#/components/parameters/category_id'
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Tag list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagOffsetPage'

  # -------------------------------------------------------------------------
  # Pools
  # -------------------------------------------------------------------------
  /pools:
    get:
      tags: [Pools]
      summary: List pools
      parameters:
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/limit'
        - name: sort
          in: query
          schema:
            type: string
            enum: [name, created]
            default: created
        - name: order
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: desc
        - name: search
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Pool list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PoolOffsetPage'

    post:
      tags: [Pools]
      summary: Create a pool
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PoolCreate'
      responses:
        '201':
          description: Pool created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pool'

  /pools/{pool_id}:
    get:
      tags: [Pools]
      summary: Get a pool
      parameters:
        - $ref: '#/components/parameters/pool_id'
      responses:
        '200':
          description: Pool
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pool'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [Pools]
      summary: Update a pool
      parameters:
        - $ref: '#/components/parameters/pool_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PoolUpdate'
      responses:
        '200':
          description: Updated pool
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pool'

    delete:
      tags: [Pools]
      summary: Delete a pool
      parameters:
        - $ref: '#/components/parameters/pool_id'
      responses:
        '204':
          description: Pool deleted

  /pools/{pool_id}/views:
    post:
      tags: [Pools]
      summary: Record that the current user viewed the pool
      parameters:
        - $ref: '#/components/parameters/pool_id'
      responses:
        '204':
          description: View recorded
        '404':
          $ref: '#/components/responses/NotFound'

  /pools/{pool_id}/files:
    get:
      tags: [Pools, Files]
      summary: List files in a pool (ordered by position)
      parameters:
        - $ref: '#/components/parameters/pool_id'
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
        - name: filter
          in: query
          description: Filter DSL (same syntax as /files)
          schema:
            type: string
      responses:
        '200':
          description: Ordered file list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PoolFileCursorPage'

    post:
      tags: [Pools, Files]
      summary: Add files to a pool
      parameters:
        - $ref: '#/components/parameters/pool_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_ids]
              properties:
                file_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
                position:
                  type: integer
                  description: Insert position for the first file (rest follow sequentially; appended at end if omitted)
      responses:
        '201':
          description: Files added to pool

  /pools/{pool_id}/files/remove:
    post:
      tags: [Pools, Files]
      summary: Remove files from a pool
      parameters:
        - $ref: '#/components/parameters/pool_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_ids]
              properties:
                file_ids:
                  type: array
                  items:
                    type: string
                    format: uuid
      responses:
        '204':
          description: Files removed from pool

  /pools/{pool_id}/files/reorder:
    put:
      tags: [Pools]
      summary: Reorder files in a pool
      parameters:
        - $ref: '#/components/parameters/pool_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_ids]
              properties:
                file_ids:
                  type: array
                  description: File UUIDs in desired order
                  items:
                    type: string
                    format: uuid
      responses:
        '204':
          description: Reordered

  # -------------------------------------------------------------------------
  # ACL
  # -------------------------------------------------------------------------
  /acl/{object_type}/{object_id}:
    get:
      tags: [ACL]
      summary: List permissions for an object
      parameters:
        - name: object_type
          in: path
          required: true
          schema:
            type: string
            enum: [file, tag, category, pool]
        - name: object_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Permission list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Permission'

    put:
      tags: [ACL]
      summary: Set permissions for an object (replaces all)
      parameters:
        - name: object_type
          in: path
          required: true
          schema:
            type: string
            enum: [file, tag, category, pool]
        - name: object_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [permissions]
              properties:
                permissions:
                  type: array
                  items:
                    type: object
                    required: [user_id]
                    properties:
                      user_id:
                        type: integer
                      can_view:
                        type: boolean
                        default: true
                      can_edit:
                        type: boolean
                        default: false
      responses:
        '200':
          description: Updated permissions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Permission'

  # -------------------------------------------------------------------------
  # Users (admin)
  # -------------------------------------------------------------------------
  /users:
    get:
      tags: [Users]
      summary: List users (admin only)
      parameters:
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: User list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserOffsetPage'
        '403':
          $ref: '#/components/responses/Forbidden'

    post:
      tags: [Users]
      summary: Create a user (admin only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserCreate'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '403':
          $ref: '#/components/responses/Forbidden'

  /users/me:
    get:
      tags: [Users]
      summary: Get current user profile
      responses:
        '200':
          description: Current user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

    patch:
      tags: [Users]
      summary: Update current user profile (name, password)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                password:
                  type: string
      responses:
        '200':
          description: Updated user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

  /users/{user_id}:
    get:
      tags: [Users]
      summary: Get a user (admin only)
      parameters:
        - $ref: '#/components/parameters/user_id'
      responses:
        '200':
          description: User
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '403':
          $ref: '#/components/responses/Forbidden'

    patch:
      tags: [Users]
      summary: Update user role/status (admin only)
      parameters:
        - $ref: '#/components/parameters/user_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                is_admin:
                  type: boolean
                can_create:
                  type: boolean
                is_blocked:
                  type: boolean
      responses:
        '200':
          description: Updated user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '403':
          $ref: '#/components/responses/Forbidden'

    delete:
      tags: [Users]
      summary: Delete a user (admin only)
      parameters:
        - $ref: '#/components/parameters/user_id'
      responses:
        '204':
          description: User deleted
        '403':
          $ref: '#/components/responses/Forbidden'

  # -------------------------------------------------------------------------
  # Audit log (admin)
  # -------------------------------------------------------------------------
  /audit:
    get:
      tags: [Audit]
      summary: Query audit log (admin only)
      parameters:
        - $ref: '#/components/parameters/offset'
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
        - name: user_id
          in: query
          schema:
            type: integer
        - name: action
          in: query
          description: Filter by action type name
          schema:
            type: string
        - name: object_type
          in: query
          schema:
            type: string
            enum: [file, tag, category, pool]
        - name: object_id
          in: query
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Audit log entries
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditLogOffsetPage'
        '403':
          $ref: '#/components/responses/Forbidden'

# ===========================================================================
# Components
# ===========================================================================
components:

  # -------------------------------------------------------------------------
  # Security schemes
  # -------------------------------------------------------------------------
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  # -------------------------------------------------------------------------
  # Parameters
  # -------------------------------------------------------------------------
  parameters:
    file_id:
      name: file_id
      in: path
      required: true
      schema:
        type: string
        format: uuid

    tag_id:
      name: tag_id
      in: path
      required: true
      schema:
        type: string
        format: uuid

    category_id:
      name: category_id
      in: path
      required: true
      schema:
        type: string
        format: uuid

    pool_id:
      name: pool_id
      in: path
      required: true
      schema:
        type: string
        format: uuid

    user_id:
      name: user_id
      in: path
      required: true
      schema:
        type: integer

    offset:
      name: offset
      in: query
      schema:
        type: integer
        default: 0
        minimum: 0

    limit:
      name: limit
      in: query
      schema:
        type: integer
        default: 50
        minimum: 1
        maximum: 200

  # -------------------------------------------------------------------------
  # Responses
  # -------------------------------------------------------------------------
  responses:
    Unauthorized:
      description: Authentication required or token invalid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    Forbidden:
      description: Insufficient permissions
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    ValidationError:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  # -------------------------------------------------------------------------
  # Schemas
  # -------------------------------------------------------------------------
  schemas:

    # --- Error ---
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          example: not_found
        message:
          type: string
          example: File not found
        details:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              message:
                type: string

    # --- Auth ---
    TokenPair:
      type: object
      properties:
        access_token:
          type: string
        refresh_token:
          type: string
        expires_in:
          type: integer
          description: Access token TTL in seconds

    Session:
      type: object
      properties:
        id:
          type: integer
        user_agent:
          type: string
        started_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
        last_activity:
          type: string
          format: date-time
        is_current:
          type: boolean

    SessionList:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Session'
        total:
          type: integer

    # --- File ---
    File:
      type: object
      required:
        - id
        - mime_type
        - mime_extension
        - content_datetime
        - exif
        - creator_id
        - creator_name
        - is_public
        - is_deleted
        - needs_review
        - created_at
        - tags
      properties:
        id:
          type: string
          format: uuid
        original_name:
          type: string
          nullable: true
        mime_type:
          type: string
          example: image/jpeg
        mime_extension:
          type: string
          example: jpg
        content_datetime:
          type: string
          format: date-time
        notes:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
        exif:
          type: object
          readOnly: true
          description: EXIF data extracted at upload time (immutable, empty object if no EXIF)
        phash:
          type: integer
          format: int64
          nullable: true
        creator_id:
          type: integer
        creator_name:
          type: string
        is_public:
          type: boolean
        is_deleted:
          type: boolean
        needs_review:
          type: boolean
          description: >-
            True until the file's tagging is explicitly marked done. New uploads
            and imports start true; cleared via POST /files/bulk/review. Filter
            with `r=1` (needs review) / `r=0` (done).
        created_at:
          type: string
          format: date-time
          description: Extracted from UUID v7
        tags:
          type: array
          description: Tags assigned to the file
          items:
            $ref: '#/components/schemas/Tag'

    FileUpdate:
      type: object
      properties:
        original_name:
          type: string
        content_datetime:
          type: string
          format: date-time
        notes:
          type: string
        metadata:
          type: object
        is_public:
          type: boolean

    FileCursorPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/File'
        next_cursor:
          type: string
          nullable: true
          description: Cursor for loading next (forward) page; null if no more items ahead
        prev_cursor:
          type: string
          nullable: true
          description: Cursor for loading previous (backward) page; null if at the beginning

    # --- Duplicates ---
    DuplicateCluster:
      type: object
      properties:
        files:
          type: array
          description: Two or more mutually similar files
          items:
            $ref: '#/components/schemas/File'
        distances:
          type: array
          description: >-
            Stored perceptual-hash (Hamming) distances between pairs of files in
            the cluster. A pair linked only transitively (through an intermediate
            file) has no stored distance and is omitted.
          items:
            $ref: '#/components/schemas/DuplicatePairDistance'

    DuplicatePairDistance:
      type: object
      required: [a, b, distance]
      properties:
        a:
          type: string
          format: uuid
        b:
          type: string
          format: uuid
        distance:
          type: integer
          description: Hamming distance (0–64) between the two files' perceptual hashes

    DuplicateClusterPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/DuplicateCluster'
        total:
          type: integer
          description: Total number of clusters (not files)
        offset:
          type: integer
        limit:
          type: integer

    MergeScalarChoice:
      type: string
      enum: [keep, discard]
      default: keep
      description: Take this field's value from the kept file or the discarded one

    MergeRelationChoice:
      type: string
      enum: [keep, both]
      default: keep
      description: Keep only the survivor's relations, or union both files' relations

    DuplicateResolve:
      type: object
      required: [keep, discard]
      properties:
        keep:
          type: string
          format: uuid
          description: The file to keep (the survivor)
        discard:
          type: string
          format: uuid
          description: The other file in the pair
        delete_discarded:
          type: boolean
          default: true
          description: Move the discarded file to trash after merging
        fields:
          type: object
          description: Per-field source for the merge; omitted fields default to "keep"
          properties:
            original_name:
              $ref: '#/components/schemas/MergeScalarChoice'
            notes:
              $ref: '#/components/schemas/MergeScalarChoice'
            content_datetime:
              $ref: '#/components/schemas/MergeScalarChoice'
            is_public:
              $ref: '#/components/schemas/MergeScalarChoice'
            metadata:
              type: string
              enum: [keep, discard, merge]
              default: keep
              description: >-
                Keep or take the discarded file's metadata object, or shallow-merge
                them with the survivor winning on key conflicts
            tags:
              $ref: '#/components/schemas/MergeRelationChoice'
            pools:
              $ref: '#/components/schemas/MergeRelationChoice'

    # --- Tag ---
    Tag:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        notes:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
          example: "5DCAA5"
        category_id:
          type: string
          format: uuid
          nullable: true
        category_name:
          type: string
          nullable: true
        category_color:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
        creator_id:
          type: integer
        creator_name:
          type: string
        is_public:
          type: boolean
        created_at:
          type: string
          format: date-time

    TagCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
        notes:
          type: string
        color:
          type: string
          pattern: '^[A-Fa-f0-9]{6}$'
        category_id:
          type: string
          format: uuid
        metadata:
          type: object
        is_public:
          type: boolean
          default: false

    TagUpdate:
      type: object
      properties:
        name:
          type: string
        notes:
          type: string
        color:
          type: string
          nullable: true
          description: Hex color or null to clear
        category_id:
          type: string
          format: uuid
          nullable: true
          description: Category UUID or null to unassign
        metadata:
          type: object
        is_public:
          type: boolean

    TagOffsetPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Tag'
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer

    # --- Tag rule ---
    TagRule:
      type: object
      properties:
        when_tag_id:
          type: string
          format: uuid
        then_tag_id:
          type: string
          format: uuid
        then_tag_name:
          type: string
        is_active:
          type: boolean

    # --- Category ---
    Category:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        notes:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
        creator_id:
          type: integer
        creator_name:
          type: string
        is_public:
          type: boolean
        created_at:
          type: string
          format: date-time

    CategoryCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
        notes:
          type: string
        color:
          type: string
          pattern: '^[A-Fa-f0-9]{6}$'
        metadata:
          type: object
        is_public:
          type: boolean
          default: false

    CategoryUpdate:
      type: object
      properties:
        name:
          type: string
        notes:
          type: string
        color:
          type: string
          nullable: true
        metadata:
          type: object
        is_public:
          type: boolean

    CategoryOffsetPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Category'
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer

    # --- Pool ---
    Pool:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        notes:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
        creator_id:
          type: integer
        creator_name:
          type: string
        is_public:
          type: boolean
        sort_key:
          type: string
          enum: [manual, content_datetime, created, original_name]
          description: >-
            How the pool's files are ordered. "manual" keeps the user-defined
            order (drag-to-reorder); any other key sorts the files automatically
            by that file field and disables manual reordering.
        sort_order:
          type: string
          enum: [asc, desc]
        file_count:
          type: integer
        created_at:
          type: string
          format: date-time

    PoolCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
        notes:
          type: string
        metadata:
          type: object
        is_public:
          type: boolean
          default: false

    PoolUpdate:
      type: object
      properties:
        name:
          type: string
        notes:
          type: string
        metadata:
          type: object
        is_public:
          type: boolean
        sort_key:
          type: string
          enum: [manual, content_datetime, created, original_name]
        sort_order:
          type: string
          enum: [asc, desc]

    PoolOffsetPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Pool'
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer

    PoolFile:
      type: object
      allOf:
        - $ref: '#/components/schemas/File'
        - type: object
          properties:
            position:
              type: integer

    PoolFileCursorPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/PoolFile'
        next_cursor:
          type: string
          nullable: true

    # --- ACL ---
    Permission:
      type: object
      properties:
        user_id:
          type: integer
        user_name:
          type: string
        can_view:
          type: boolean
        can_edit:
          type: boolean

    # --- User ---
    User:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        is_admin:
          type: boolean
        can_create:
          type: boolean
        is_blocked:
          type: boolean

    UserCreate:
      type: object
      required: [name, password]
      properties:
        name:
          type: string
        password:
          type: string
        is_admin:
          type: boolean
          default: false
        can_create:
          type: boolean
          default: false

    UserOffsetPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/User'
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer

    # --- Audit ---
    AuditLogEntry:
      type: object
      properties:
        id:
          type: integer
          format: int64
        user_id:
          type: integer
        user_name:
          type: string
        action:
          type: string
          example: file_create
        object_type:
          type: string
          nullable: true
        object_id:
          type: string
          format: uuid
          nullable: true
        details:
          type: object
          nullable: true
        performed_at:
          type: string
          format: date-time

    AuditLogOffsetPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/AuditLogEntry'
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer
