/**
 * @file OpenAPI fragment for dashboard-managed git worktree lane provisioning
 * and the confirmed, preflight-guarded reset, remove, and purge lifecycle API.
 * It documents the asynchronous provisioning and destructive action contracts
 * for the built-in Swagger and ReDoc surfaces, plus the read-only
 * `LaneStageDetectionFields` schema for the stage-detection fields every lane
 * response carries, and the idempotent `POST /api/lanes/ensure` lookup the
 * Workspace page opens a directory with.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const tags = [
  {
    name: "Lanes",
    description: "Durable parallel-work lanes and dashboard-managed git worktrees",
  },
];

const schemas = {
  LaneWorktreeCreateRequest: {
    type: "object",
    required: ["sourceRepo"],
    properties: {
      sourceRepo: {
        type: "string",
        description: "Existing absolute path to the source git repository.",
        example: "/Users/me/src/project",
      },
      title: {
        type: "string",
        description: "Human-readable lane title.",
        example: "Criteria form",
      },
      base: {
        type: "string",
        description:
          "Preferred base branch. Defaults to the LANE_BASE_BRANCH env var, or `main` when that is also unset.",
        example: "main",
      },
      slug: {
        type: "string",
        description: "Optional branch/directory slug override.",
        example: "criteria-form",
      },
    },
  },
  LaneEnsureRequest: {
    type: "object",
    required: ["cwd"],
    properties: {
      cwd: {
        type: "string",
        description:
          "Absolute working directory to find or adopt a lane for. A lane whose own cwd is this path, or the closest path-boundary parent of it, is returned as-is.",
        example: "/Users/me/src/project/packages/app",
      },
      title: {
        type: "string",
        description:
          "Title for the lane if one has to be created; ignored when one already exists.",
        example: "App package",
      },
    },
  },
  LaneDestructiveActionRequest: {
    type: "object",
    required: ["confirm", "expect"],
    properties: {
      confirm: { type: "boolean", enum: [true] },
      force: {
        type: "boolean",
        description: "Required by reset/remove when unpushed commits exist.",
      },
      expect: {
        type: "object",
        description:
          "Required complete facts returned by the preceding preflight: head, dirty, untracked, unpushed for reset/remove; sessions, events, tokenRows for purge. Differences return 409 ESTALE without destructive work.",
        additionalProperties: true,
      },
    },
  },
  LaneStageDetectionFields: {
    type: "object",
    description:
      "Fields the server's stage-detection heuristic (server/lib/stage-detect.js) adds to every lane returned by GET /api/lanes and GET /api/lanes/:id. An inferred stage is never evidence and never renders as done — see docs/LANES.md#stage-detection.",
    properties: {
      detected_stage: {
        type: "string",
        nullable: true,
        description:
          "Stage id inferred from ingested tool events, or null if no signal has been seen. Independent of the agent's own declared `stage`.",
      },
      detected_signal: {
        type: "string",
        nullable: true,
        description:
          "The tool-event signal that produced detected_stage, capped at 120 characters; null when detected_stage is null.",
      },
      detected: {
        type: "boolean",
        description:
          "Present on each entry of pipeline_nodes. True for the inferred node and any node before it that carries no declared record; decorates that node's state without ever upgrading it to done.",
      },
    },
  },
};

const paths = {
  "/api/lanes/ensure": {
    post: {
      tags: ["Lanes"],
      summary: "Find or adopt the lane owning a working directory",
      description:
        "Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
      operationId: "ensureLane",
      requestBody: {
        required: true,
        content: {
          "application/json": { schema: { $ref: "#/components/schemas/LaneEnsureRequest" } },
        },
      },
      responses: {
        200: {
          description: "An existing lane already owns that cwd.",
          content: {
            "application/json": {
              schema: {
                type: "object",
                required: ["lane", "created"],
                properties: {
                  lane: { type: "object", additionalProperties: true },
                  created: { type: "boolean", enum: [false] },
                },
              },
            },
          },
        },
        201: {
          description: "No lane owned that cwd, so an adopted one was created.",
          content: {
            "application/json": {
              schema: {
                type: "object",
                required: ["lane", "created"],
                properties: {
                  lane: { type: "object", additionalProperties: true },
                  created: { type: "boolean", enum: [true] },
                },
              },
            },
          },
        },
        400: {
          description: "cwd is missing or not an absolute path (EBADCWD).",
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
          },
        },
        403: {
          description: "The browser request was not same-origin/loopback.",
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
          },
        },
      },
    },
  },
  "/api/lanes/worktree": {
    post: {
      tags: ["Lanes"],
      summary: "Provision a managed git worktree lane",
      description:
        "Validates the absolute source repository, creates a managed lane in `provisioning` state, and returns immediately. Worktree creation continues under the lane lock; the existing `lane_update` broadcast reports either `idle` or `failed` with git stderr in `notes`.",
      operationId: "createLaneWorktree",
      requestBody: {
        required: true,
        content: {
          "application/json": {
            schema: { $ref: "#/components/schemas/LaneWorktreeCreateRequest" },
          },
        },
      },
      responses: {
        202: {
          description: "Managed lane accepted for background provisioning.",
          content: {
            "application/json": {
              schema: {
                type: "object",
                required: ["lane"],
                properties: { lane: { type: "object", additionalProperties: true } },
              },
            },
          },
        },
        400: {
          description: "sourceRepo is relative, missing, or not a git repository.",
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
          },
        },
        403: {
          description: "The browser request was not same-origin/loopback.",
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
          },
        },
        409: {
          description:
            "The computed worktree directory already belongs to a lane, or no unique directory was available after 50 attempts.",
          content: {
            "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
          },
        },
      },
    },
  },
  "/api/lanes/{id}": {
    patch: {
      tags: ["Lanes"],
      summary: "Partially update a lane",
      description:
        "Updates lane fields, including run_id; browser requests must pass the loopback same-origin guard. The provisioning-time facts kind, source_repo, slug and base_branch are NOT patchable — kind is check 1 of the destroy guard — and are silently ignored here.",
      operationId: "updateLane",
      parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
      requestBody: {
        required: true,
        content: {
          "application/json": {
            schema: { type: "object", additionalProperties: true },
          },
        },
      },
      responses: {
        200: { description: "Updated lane." },
        400: { description: "kind was not one of adopted|managed (EBADKIND)." },
        403: { description: "The browser request was not same-origin/loopback." },
        404: { description: "Lane not found." },
      },
    },
  },
  "/api/lanes/{id}/preflight": {
    get: {
      tags: ["Lanes"],
      summary: "Count facts before a destructive lane action",
      operationId: "preflightLaneAction",
      parameters: [
        { name: "id", in: "path", required: true, schema: { type: "integer" } },
        {
          name: "action",
          in: "query",
          required: true,
          schema: { type: "string", enum: ["reset", "remove", "purge"] },
        },
      ],
      responses: {
        200: {
          description:
            "Current counted facts for the selected action. reset/remove additionally return blocked[] (hard blockers: adopted, missing, unreadable, unpushed-commits — the only one force overrides) and warnings[] (informational only, e.g. no-remote).",
        },
        400: { description: "Unknown action." },
        404: { description: "Lane not found." },
      },
    },
  },
  "/api/lanes/{id}/git": {
    get: {
      tags: ["Lanes"],
      summary: "A lane's working-copy facts",
      description:
        "Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
      operationId: "getLaneGitFacts",
      parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
      responses: {
        200: {
          description:
            "available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
        },
        404: { description: "Lane not found." },
      },
    },
  },
  "/api/lanes/{id}/runtime": {
    get: {
      tags: ["Lanes"],
      summary: "A lane's own application stack",
      description:
        "Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .ccam/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.",
      operationId: "getLaneRuntime",
      parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
      responses: {
        200: {
          description:
            "available:false (no profile); available:true with provisioned:false (no slot yet); or the full facts with slot, ports, services, up, healthy, steppedAside, logs and lastError.",
        },
        404: { description: "Lane not found." },
      },
    },
  },
  "/api/lanes/{id}/up": {
    post: {
      tags: ["Lanes"],
      summary: "Boot a lane's stack",
      description:
        "Runs the profile's boot then health hooks. Returns 202 and finishes in the background because a build can take minutes; progress streams as lane_hook_output and the attempt ends with a lane_runtime message. Does NOT run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running, because their logs are what identify the service that never came up. Writes only slot and ports on the lane row: never stage, status or notes.",
      operationId: "upLane",
      parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
      requestBody: {
        required: false,
        content: {
          "application/json": {
            schema: {
              type: "object",
              properties: {
                build: {
                  type: "boolean",
                  default: true,
                  description:
                    "false passes --no-build to the boot hook, reusing an existing build.",
                },
              },
            },
          },
        },
      },
      responses: {
        202: { description: "Accepted; the boot runs in the background." },
        400: { description: "ENOPROFILE — no .ccam/profile, with the paths searched." },
        403: { description: "The browser request was not same-origin/loopback." },
        404: { description: "Lane not found." },
        409: { description: "ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids)." },
      },
    },
  },
  "/api/lanes/{id}/down": {
    post: {
      tags: ["Lanes"],
      summary: "Stop a lane's stack",
      description:
        "Kills each recorded pid tree bottom-up (killing a parent first reparents its children to init, where nothing knows to look for them), then sweeps listeners on the lane's ports ONLY when a pid file existed — a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server the user started there. Idempotent, and a no-op for a lane that was never up.",
      operationId: "downLane",
      parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
      responses: {
        200: { description: "{ok, killed: number[], runtime}." },
        403: { description: "The browser request was not same-origin/loopback." },
        404: { description: "Lane not found." },
      },
    },
  },
  "/api/lanes/{id}/hook/{name}": {
    post: {
      tags: ["Lanes"],
      summary: "Run one of the lane profile's hooks",
      description:
        "The surface a driving session uses for ci-gate, e2e, migrate and friends. Returns 202; output streams as lane_hook_output and completion arrives as lane_hook_result with the exit code. The name is checked against a fixed allowlist BEFORE anything is spawned, and args travels as an array of strings straight into argv — neither is ever joined into a command string.",
      operationId: "runLaneHook",
      parameters: [
        { name: "id", in: "path", required: true, schema: { type: "integer" } },
        {
          name: "name",
          in: "path",
          required: true,
          schema: {
            type: "string",
            enum: [
              "bootstrap",
              "boot",
              "health",
              "migrate",
              "seed",
              "ci-gate",
              "e2e",
              "regen",
              "db-create",
              "db-drop",
            ],
          },
        },
      ],
      requestBody: {
        required: false,
        content: {
          "application/json": {
            schema: {
              type: "object",
              properties: { args: { type: "array", items: { type: "string" } } },
            },
          },
        },
      },
      responses: {
        202: { description: "Accepted; the hook runs in the background." },
        400: { description: "ENOHOOK (name outside the allowlist) or ENOPROFILE." },
        403: { description: "The browser request was not same-origin/loopback." },
        404: { description: "Lane not found." },
        409: { description: "ENOSLOT — the lane has no runtime yet; bring it up first." },
      },
    },
  },
  "/api/lanes/{id}/logs/{svc}": {
    get: {
      tags: ["Lanes"],
      summary: "Tail a lane's hook or service log",
      description:
        "The resolved path is confined to the lane's log directory after realpath, so a name from the request can never escape it. Read-only, so no same-origin guard.",
      operationId: "getLaneLog",
      parameters: [
        { name: "id", in: "path", required: true, schema: { type: "integer" } },
        { name: "svc", in: "path", required: true, schema: { type: "string" } },
        {
          name: "tail",
          in: "query",
          required: false,
          schema: { type: "integer", default: 65536, maximum: 1048576 },
          description: "Trailing bytes to return; capped at 1 MiB.",
        },
      ],
      responses: {
        200: {
          description:
            "{available, svc, size, truncated, text}, or {available:false} for a lane with no slot.",
        },
        404: { description: "Lane not found, or ENOLOG — no such log for this lane." },
      },
    },
  },
  "/api/lanes/branches": {
    get: {
      tags: ["Lanes"],
      summary: "A candidate source repo's local branches",
      description:
        "Feeds the Add-lane picker: given a repo path, returns its local branches (not origin/* refs — those aren't checkout-able into a new worktree without a fetch first) plus which one is currently checked out. Same repo validation as POST /api/lanes/worktree; a repo neither endpoint can resolve can't be provisioned from either. Read-only, no same-origin guard.",
      operationId: "listLaneBranches",
      parameters: [
        {
          name: "repo",
          in: "query",
          required: true,
          schema: { type: "string" },
          description: "Absolute path to an existing git repository.",
        },
      ],
      responses: {
        200: { description: "{branches: string[], current: string | null}." },
        400: { description: "repo is missing, relative, does not exist, or is not a git repo." },
      },
    },
  },
  "/api/lanes/{id}/{action}": {
    post: {
      tags: ["Lanes"],
      summary: "Confirm a reset, managed-worktree removal, or session purge",
      description:
        "Actions run under the lane lock after waiting for the lane child's actual exit. A failed spawn is already exited because no child started. Reset requires a live managed worktree; remove tears down a managed worktree, prunes git's stale record when the directory was deleted by hand, or only forgets an adopted-lane row without touching its directory. Reset/remove require force when managed work has unpushed commits.",
      operationId: "runLaneDestructiveAction",
      parameters: [
        { name: "id", in: "path", required: true, schema: { type: "integer" } },
        {
          name: "action",
          in: "path",
          required: true,
          schema: { type: "string", enum: ["reset", "remove", "purge"] },
        },
      ],
      requestBody: {
        required: true,
        content: {
          "application/json": {
            schema: { $ref: "#/components/schemas/LaneDestructiveActionRequest" },
          },
        },
      },
      responses: {
        200: { description: "Action completed; purge includes its deleted-row counts." },
        400: {
          description:
            "Confirmation or complete expect facts missing, or the managed-worktree guard refused the target.",
        },
        403: { description: "The browser request was not same-origin/loopback." },
        409: { description: "Preflight facts changed (ESTALE) or force is required (EUNPUSHED)." },
        500: {
          description:
            "Git, run-exit timeout, or internal failure; git failures include error.stderr.",
        },
      },
    },
  },
};

module.exports = { tags, schemas, paths };
