openapi: 3.1.0
info:
  title: RailCore Developer API
  version: "1.0.0"
  summary: RailCore railway intelligence API for India.
  description: |
    RailCore is a normalized, live-data railway API for Indian Railways. All endpoints return the same envelope shape.
    Authentication is required on every endpoint. Send the API key in an `X-RailCore-Key` header, or as `Authorization: Bearer <key>`, or in an `X-Api-Key` header; two headers carrying different keys answer `401 AMBIGUOUS_API_KEY`. Growth, Scale and Enterprise keys must also sign every request with HMAC-SHA256 (`X-RailCore-Timestamp`, `X-RailCore-Nonce`, `X-RailCore-Signature`; see https://railcore.tech/docs/hmac). The signature's body hash is over the exact bytes of the request body as sent.

    ### Base URL
      * production — `https://ir.railcore.tech/v1`
      * sandbox    — not applicable in v1; production keys are the only tier.

    ### Envelope
    Every response is `application/json` with the following top-level shape:
    ```json
    {
      "success": true,
      "data": { ... },
      "meta": { "api_version": "v1", "request_id": "req_...", "trace_id": "trc_...", "timestamp": "...", "freshness": { "mode": "live", "retrieved_at": "..." } }
    }
    ```
    Errors:
    ```json
    { "success": false, "error": { "code": "STATION_NOT_FOUND", "message": "...", "category": "NOT_FOUND", "retryable": false }, "meta": { ... } }
    ```

    ### Freshness
    RailCore serves **live data**. Every response emits:
    ```
    Cache-Control: no-store, no-cache, must-revalidate, private
    Pragma: no-cache
    Expires: 0
    ```
    Do not cache RailCore responses.

    ### Rate limiting
    Every rate limit is per account: the per-minute budget, the Free plan's daily budget and the extra caps of 30 requests a minute on `/charts/*` and on `/availability/seats/batch` are shared by every key on the account, and creating a new key does not reset them. Every response carries `X-RailCore-RateLimit-Limit`, `X-RailCore-RateLimit-Remaining`, `X-RailCore-RateLimit-Reset` headers for the account's per-minute budget. `X-RailCore-RateLimit-Day-*` headers are sent only on keys with a daily cap (the Free plan); a key without a daily cap receives no `Day-*` headers and never a daily 429. When rate-limited, RailCore returns HTTP 429 with `Retry-After`. Limits per plan: https://railcore.tech/docs/rate-limits

    ### Requests
    Pricing is per request, and most operations count as 1 request; each operation's cost is in its `x-railcore-cost` field. Only successful (2xx) responses are counted: failed requests are free. A batch or comparison that answers 200 is counted at the size it asked for, even when some of its items fail inside the response. Every response carries `X-RailCore-Credits-Cost` (what this request counted as) and `X-RailCore-Credits-Remaining` (the requests left); one credit is one request. Plans and prices: https://railcore.tech/docs/credits

    ### Upstream failures
    When a live-data source fails or times out, RailCore returns HTTP 503 with `error.code` `UPSTREAM_UNAVAILABLE` or `UPSTREAM_TIMEOUT` and a `Retry-After` header. These are not counted. `METERING_UNAVAILABLE` is also a 503, so branch on `error.code`.

    ### Version sunset
    When an API major reaches its announced sunset date, requests return HTTP `410 Gone` with `error.code = "API_VERSION_SUNSET"`. Migrate to a supported major version instead of retrying unchanged.

  contact:
    name: RailCore Developer Support
    url: https://railcore.tech/docs
    email: hi@railcore.tech
  license:
    name: Proprietary
  termsOfService: https://railcore.tech/legal/api-terms

servers:
  - url: https://ir.railcore.tech/v1
    description: Production

security:
  - RailCoreKey: []
  - RailCoreBearer: []
  - RailCoreApiKey: []
  - RailCoreKeyWithHmac: []

paths:
  # ------------------------------ Stations ------------------------------
  /stations/search:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Search stations
      tags: [Stations]
      parameters:
        - $ref: '#/components/parameters/queryString'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: OK
          headers:
            $ref: '#/components/headers/AllStandard'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EnvelopeStationSearch' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamError' }

  /stations/{station_code}:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Get one station
      tags: [Stations]
      parameters:
        - $ref: '#/components/parameters/pathStationCode'
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeStation' }}}}
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/AuthError' }

  /stations/{station_code}/live:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Live station board — trains arriving/departing in a rolling window
      description: |
        Returns one canonical station board for trains arriving at, departing
        from, starting at, or terminating at the station. `NOT_STARTED` means
        the train has not departed its origin. Scheduled timestamps alone never
        imply movement; when the scheduled origin departure has passed without
        live movement evidence, status is `UNKNOWN` rather than `ON_ROUTE`.
        Internal data-source identities and routing details are not exposed.
      tags: [Stations, Live]
      parameters:
        - $ref: '#/components/parameters/pathStationCode'
        - name: window_hours
          in: query
          description: How far ahead the board looks, in hours. It lists trains due at the station from now until this many hours later. Any value other than 4 is read as 8; the response's `window_hours` says which was applied.
          schema: { type: integer, enum: [4, 8], default: 8 }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeStationBoard' }}}}
        '503': { $ref: '#/components/responses/UpstreamError' }

  # ------------------------------ Trains ------------------------------
  /trains/search:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Search trains by name or number
      description: >-
        Ranked search over RailCore's train catalog (about 13,700 trains, suburban locals
        included, refreshed every 15 minutes). Names are published title case, for example
        "New Delhi Tejas Rajdhani Express". A live provider is consulted only when the
        catalog has fewer matches than the limit.
      tags: [Trains]
      parameters:
        - $ref: '#/components/parameters/queryString'
        - $ref: '#/components/parameters/limit'
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeTrainSearch' }}}}

  /trains/{train_number}:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Train details
      tags: [Trains]
      parameters: [{ $ref: '#/components/parameters/pathTrainNumber' }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeTrain' }}}}
        '404': { $ref: '#/components/responses/NotFound' }

  /trains/{train_number}/schedule:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Full train schedule
      tags: [Trains]
      parameters:
        - { $ref: '#/components/parameters/pathTrainNumber' }
        - name: include_intermediate
          in: query
          schema: { type: boolean, default: false }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeTrainSchedule' }}}}
        '404': { $ref: '#/components/responses/NotFound' }

  /trains/{train_number}/stops:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Booking-relevant stops (subset of schedule where is_stop=true)
      tags: [Trains]
      parameters: [{ $ref: '#/components/parameters/pathTrainNumber' }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeTrainSchedule' }}}}

  /trains/{train_number}/route:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Full train route (including non-stop pass-through stations)
      tags: [Trains]
      parameters: [{ $ref: '#/components/parameters/pathTrainNumber' }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeTrainSchedule' }}}}

  /trains/{train_number}/coach-position:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Coach position from engine (composition)
      tags: [Trains]
      parameters: [{ $ref: '#/components/parameters/pathTrainNumber' }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeCoachPosition' }}}}

  /trains/{train_number}/live:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Live train status
      description: |
        Where a train is right now, how late it is, and its times at every
        station on the route. Returns one run of a train: its status and
        position, its delay, when it should reach its last station, and the
        scheduled, expected and actual times at every station on its route,
        including the ones it passes without stopping.

        A train reports about once a minute, so by default RailCore may answer
        from its own read of the same run made up to 10 seconds earlier. Such an
        answer says so: `freshness.railcore_cache` is `reused` and
        `freshness.retrieved_at` is when that read was made. Pass `max_age=0`
        for a fresh read. It counts as 1 request either way. Anything
        RailCore cannot establish for a run is null, never filled in from the
        timetable. The `Server-Timing` response header shows where the time went.

        This endpoint replaced `/trains/{train_number}/live-status` and
        `/trains/{train_number}/running` on 2026-11-03. Those paths answer
        `410 ENDPOINT_RETIRED` with `Deprecation`, `Sunset` and `Link` headers.
      tags: [Trains, Live]
      parameters:
        - { $ref: '#/components/parameters/pathTrainNumber' }
        - name: date
          in: query
          required: true
          description: The day the train leaves its first station, as YYYY-MM-DD in IST. A train that left late last night is still running under last night's date.
          schema: { type: string, format: date }
        - name: from
          in: query
          required: false
          description: The run's first station. Leave it out to use the train's own first station; needed only for a train missing from the train catalog.
          schema: { $ref: '#/components/schemas/StationCode' }
        - name: to
          in: query
          required: false
          description: The run's last station. Leave it out to use the train's own last station; needed only for a train missing from the train catalog.
          schema: { $ref: '#/components/schemas/StationCode' }
        - name: max_age
          in: query
          required: false
          description: The oldest RailCore read of this run, in seconds, you accept. 0 asks for a fresh read of the live feed.
          schema: { type: integer, minimum: 0, maximum: 10, default: 10 }
      responses:
        '200': { description: "The run's live status", content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeLiveTrainStatus' }}}}
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/AuthError' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamError' }

  # ------------------------------ Routes / journeys ------------------------------
  /routes/trains:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Trains between two stations on a date
      tags: [Journeys]
      parameters:
        - { name: from,  in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to,    in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: date,  in: query, required: true, schema: { type: string, format: date }}
        - { name: quota, in: query, schema: { $ref: '#/components/schemas/Quota' }}
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeRouteTrains' }}}}

  /journeys/search:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Journey search (same shape as /routes/trains, ranked for search UX)
      tags: [Journeys]
      parameters:
        - { name: from,  in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to,    in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: date,  in: query, required: true, schema: { type: string, format: date }}
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeRouteTrains' }}}}

  /journeys/compare:
    post:
      x-railcore-cost: "1 request per route."
      summary: Compare multiple journey queries in one request
      description: Counted at the number of routes in `journeys` when the request answers 200, even when some routes fail inside the response. A request refused as a whole (4xx or 5xx) is free.
      tags: [Journeys]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [journeys]
              properties:
                journeys:
                  type: array
                  minItems: 1
                  maxItems: 5
                  items:
                    type: object
                    required: [from, to, date]
                    properties:
                      from: { $ref: '#/components/schemas/StationCode' }
                      to:   { $ref: '#/components/schemas/StationCode' }
                      date: { type: string, format: date }
                      quota: { $ref: '#/components/schemas/Quota' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      results: { type: array, items: { $ref: '#/components/schemas/RouteTrains' }}
                  meta: { $ref: '#/components/schemas/Meta' }

  # ------------------------------ Availability ------------------------------
  /availability/seats:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Latest seat-availability snapshot for a single class and quota
      description: A fresh availability enquiry on every request, with no RailCore cache. The upstream can still answer with a figure it read from IRCTC earlier; `inventory_updated_at` and `classes[].last_updated_at` say when that figure was read, and `enquiry_checked_at` when RailCore asked. The payload is a timestamped availability snapshot, not a direct PRS connection. Upstream identity is intentionally not exposed.
      tags: [Availability]
      parameters:
        - { name: train_number, in: query, required: true, schema: { $ref: '#/components/schemas/TrainNumber' }}
        - { name: from,         in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to,           in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: date,         in: query, required: true, schema: { type: string, format: date }}
        - { name: class,        in: query, required: true, schema: { $ref: '#/components/schemas/ClassCode' }}
        - { name: quota,        in: query, schema: { $ref: '#/components/schemas/Quota' }}
        - { name: fresh,        in: query, deprecated: true, schema: { type: boolean }, description: "Ignored since 2026-11-03: every request is a fresh enquiry." }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeAvailability' }}}}

  /availability/seats/batch:
    post:
      x-railcore-cost: "1 request per distinct class per item (none or empty counts as 4)."
      summary: Seat availability for many train+class combinations
      description: Counted at one request per distinct class on each item (classes compare case-insensitively, so `["SL", "sl", "SL"]` counts once; an item with `classes` left out or empty counts as 4) when the request answers 200, even when some items fail inside the response. A request refused as a whole (4xx or 5xx) is free.
      tags: [Availability]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 25
                  items:
                    type: object
                    required: [train_number, from, to, date]
                    properties:
                      train_number: { $ref: '#/components/schemas/TrainNumber' }
                      from: { $ref: '#/components/schemas/StationCode' }
                      to: { $ref: '#/components/schemas/StationCode' }
                      date: { type: string, format: date }
                      classes: { type: array, maxItems: 8, items: { $ref: '#/components/schemas/ClassCode' }, description: "Up to 8 classes; a class repeated in one item is asked and counted once. Left out or empty, the 4 default classes (SL, 3A, 2A, 1A) are asked and counted." }
                      quota: { $ref: '#/components/schemas/Quota' }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeAvailabilityBatch' }}}}

  /availability/calendar:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Availability for each date the train runs in a window of days
      description: Finds the dates the train runs from `date` (default today in India) for `days` days, then makes one seat enquiry per running date, four at a time. Dates the train does not run on are left out, so a window with no runs answers an empty `days` list. The upstream can answer an enquiry from its own cache, so each day's `last_updated_at` says when its figure was read from IRCTC. A date whose own enquiry fails shows the upstream calendar listing's figure, with no fare and `last_updated_at` null; if every date fails, the request answers 503 and is not counted.
      tags: [Availability]
      parameters:
        - { name: train_number, in: query, required: true, schema: { $ref: '#/components/schemas/TrainNumber' }}
        - { name: from, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to,   in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: class, in: query, required: true, schema: { $ref: '#/components/schemas/ClassCode' }}
        - { name: quota, in: query, schema: { $ref: '#/components/schemas/Quota' }}
        - { name: date,  in: query, description: "First date of the window, today or later; defaults to today in India.", schema: { type: string, format: date }}
        - { name: days,  in: query, schema: { type: integer, minimum: 1, maximum: 30, default: 7 }}
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeAvailabilityCalendar' }}}}

  /availability/classes:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Classes offered for a given train on a route
      tags: [Availability]
      parameters:
        - { name: train_number, in: query, required: true, schema: { $ref: '#/components/schemas/TrainNumber' }}
        - { name: from, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: date, in: query, required: true, schema: { type: string, format: date }}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      train_number: { $ref: '#/components/schemas/TrainNumber' }
                      classes: { type: array, items: { $ref: '#/components/schemas/ClassCode' }}
                  meta: { $ref: '#/components/schemas/Meta' }

  # ------------------------------ Fares ------------------------------
  /fares/estimate:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Fare per class for a train on a route
      tags: [Fares]
      parameters:
        - { name: train_number, in: query, required: true, schema: { $ref: '#/components/schemas/TrainNumber' }}
        - { name: from, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to,   in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: class, in: query, schema: { $ref: '#/components/schemas/ClassCode' }}
        - { name: quota, in: query, schema: { $ref: '#/components/schemas/Quota' }}
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeFareEstimate' }}}}

  /fares/search:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Fares across trains on a route
      tags: [Fares]
      parameters:
        - { name: from, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to,   in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: date, in: query, required: true, schema: { type: string, format: date }}
        - { name: quota, in: query, schema: { $ref: '#/components/schemas/Quota' }}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      trains:
                        type: array
                        items:
                          type: object
                          properties:
                            train_number: { $ref: '#/components/schemas/TrainNumber' }
                            fares: { type: array, items: { $ref: '#/components/schemas/FareItem' }}
                  meta: { $ref: '#/components/schemas/Meta' }

  # ------------------------------ Charts ------------------------------
  /charts/status:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Chart preparation status
      tags: [Charts]
      parameters:
        - { name: train_number, in: query, required: true, schema: { $ref: '#/components/schemas/TrainNumber' }}
        - { name: date, in: query, required: true, schema: { type: string, format: date }}
        - { name: station, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeChartStatus' }}}}
        '409': { $ref: '#/components/responses/ChartNotReady' }

  /charts/vacant-berths:
    get:
      x-railcore-cost: "Counts as 3 requests."
      summary: Post-chart vacant berths per coach/class
      tags: [Charts]
      parameters:
        - { name: train_number, in: query, required: true, schema: { $ref: '#/components/schemas/TrainNumber' }}
        - { name: from, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to,   in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: date, in: query, required: true, schema: { type: string, format: date }}
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeVacantBerths' }}}}
        '409': { $ref: '#/components/responses/ChartNotReady' }

  /charts/vacant-berths/hopping:
    get:
      x-railcore-cost: "Counts as 4 requests."
      summary: Post-chart vacant berths + a hopping plan when no single berth covers the full segment
      description: |
        Same upstream chain as `/charts/vacant-berths`: `trainComposition` +
        one `vacantBerth` request per class. Additionally computes a **hopping
        plan** per class using a minimum-count interval-cover algorithm — when
        no single berth is vacant end-to-end, we stitch multiple consecutive
        vacant sub-segments on the same train into a chain that covers the
        caller's `from → to`, minimizing seat changes.

        `via=CODE,CODE` (comma-separated or repeated query) pins intermediate
        stations that the chain must pass through, in journey order.
        `max_hops` (default 6, max 12) caps chain length.

        A response with `hopping.disabled=true, disabled_reason="SCHEDULE_UNAVAILABLE"`
        indicates the train schedule could not be fetched — direct-only
        results are still returned. A per-class option with `is_covered=false`
        includes a machine-readable `reason` field (`NO_VACANT_BERTHS`,
        `GAP_UNREACHABLE`, `MAX_HOPS_EXCEEDED`, `VIA_GAP_UNREACHABLE`) plus
        the earliest unreachable stop so the caller can suggest a change.
      tags: [Charts]
      parameters:
        - { name: train_number, in: query, required: true, schema: { $ref: '#/components/schemas/TrainNumber' }}
        - { name: from, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to,   in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: date, in: query, required: true, schema: { type: string, format: date }}
        - { name: via, in: query, required: false,
            schema: { type: string },
            description: "Comma-separated pinned intermediate station codes, in journey order (e.g. `via=JL,AN`)." }
        - { name: max_hops, in: query, required: false,
            schema: { type: integer, minimum: 1, maximum: 12, default: 6 },
            description: "Cap on chain length. Chains exceeding this are reported as `MAX_HOPS_EXCEEDED`." }
        - name: prefer_berth
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated preferred berth codes. Accepts canonical
            (`L`, `M`, `U`, `SL`, `SU`, `R`, `P`, `W`, `A`) or aliases
            (`LB`, `MB`, `UB`, `SLB`, `SUB`, `LOWER`, `MIDDLE`, `UPPER`,
            `SIDE-LOWER`, `SIDE-UPPER`, `CABIN`, `COUPE`, `WINDOW`,
            `AISLE`, `ROOMETTE`). Hard-filters `direct.options[].berths`
            to only these codes; used as a soft tie-break bias in the
            hopping algorithm (never sacrifices reach, so a covering
            plan is still returned even if all berths are non-preferred).
            Unknown tokens are silently dropped. Example: `LB,SLB`.
        - name: avoid_berth
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated berth codes to avoid. Same alias set as
            `prefer_berth`. Hard-filtered out of `direct.options`;
            treated as a soft negative bias (-5) in the hopping tie-break
            — an avoided berth is still picked when it is the only way
            to cover the segment. If a code appears in both `prefer_berth`
            and `avoid_berth`, avoid wins. Example: `UB,SUB`.
        - name: prefer_class
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated preferred class codes (`SL`, `3A`, `2A`,
            `1A`, `3E`, `CC`, `EC`, `2S`, `FC`). Reorders the fan-out so
            preferred classes are polled first (results surface earlier
            under bounded concurrency) and reorders `hopping.options[]`
            and `direct.options[]` so preferred classes appear first.
            Unknown tokens are silently dropped. Example: `SL,3E`.
        - name: avoid_class
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated class codes to skip entirely. Each skipped
            class skips one upstream `vacantBerth` lookup (the request
            costs the same, see `x-railcore-cost`). Skipped classes
            never appear in `hopping.options` or `direct.options`.
            Example: `1A` to skip AC First Class. Classes actually
            dropped are echoed in `_meta.preferences_applied.classes_skipped`.
        - name: include_fare
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"] }
          description: |
            When `true`, each hop is decorated with `fare_inr`, each hopping
            option with `total_fare_inr`, and each direct option with
            `fare_inr`. Adds one upstream `fare-estimate` lookup per unique
            `(train, from, to)` tuple across the response; the request
            costs the same (`x-railcore-cost`). Failures degrade
            gracefully to `null` fares — the response is never blocked on
            fare data. Defaults to `false`.
        - name: include_alternate_boarding
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"] }
          description: |
            When `true`, populate `alternate_boarding.candidates[]` with
            same-train boarding stations near the caller's `from` (window of
            2 stops before + 2 after, clipped to the requested segment).
            Each candidate summarises direct-vacancy counts by class and
            which classes are reachable via hopping. Uses only in-memory
            data — no additional upstream chart lookups. If `include_fare`
            is also `true`, each candidate's `fare_inr_by_class` is
            populated for `(candidate → to)`.
        - name: strategies
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated list of optimisation strategies to compute per
            class. Each option in `hopping.options[i]` gains a
            `strategies[]` array with one entry per requested strategy.
            Values:
            - `min_hops` (default) — fewest logical hops, current behaviour
            - `min_seat_changes` — same-berth continuity, best for elderly/family
            - `cheapest` — Dijkstra shortest-path over fare weights;
              auto-enables `include_fare=true`
            Alias: `all` expands to all supported strategies. Unknown
            tokens are silently dropped. Example: `strategies=min_hops,cheapest`.
        - name: alternate_boarding_mode
          in: query
          required: false
          schema: { type: string, enum: ["lightweight", "extended"], default: "lightweight" }
          description: |
            How much upstream work to do when
            `include_alternate_boarding=true`:
            - `lightweight` (default) — re-project existing berth data.
              No extra upstream chart lookups. Fast but approximate.
            - `extended` — fresh `chart-composition` + per-class
              `vacantBerth` fan-out for each top-N candidate. Authoritative
              data (real chart-2 timing, quota-specific berths); adds
              ~800ms-2s latency and 1-5 upstream chart lookups. Either
              mode costs the same (`x-railcore-cost`).
        - name: alternate_boarding_max_candidates
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 5, default: 3 }
          description: |
            Cap on the number of `alternate_boarding_mode=extended`
            candidates to query upstream. Clamped to [1, 5]. Default 3.
            Ignored in lightweight mode. Each candidate adds one
            chart-composition + one vacantBerth lookup per class upstream;
            the request costs the same.
        - name: include_cross_class
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"] }
          description: |
            When `true`, populate `cross_class` with a single plan that
            allows switching classes between hops. Useful when no
            single-class chain covers the requested segment. No
            additional upstream lookups — reuses the same per-class
            vacantBerth data already fetched. Default `false`.
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeVacantBerthsHopping' }}}}
        '400': { $ref: '#/components/responses/ValidationError' }
        '409': { $ref: '#/components/responses/ChartNotReady' }

  /charts/vacant-berths/route-scan:
    get:
      x-railcore-cost: "Counts as 8 requests."
      summary: Route-first multi-train vacant-berth scanner (with per-train hopping)
      description: |
        Route-first, multi-train version of `/charts/vacant-berths/hopping`.
        Where the hopping endpoint answers "for THIS train, what's vacant
        and how do I stitch berths?", this one answers "for THIS `from → to`
        journey on THIS date, which train should I board?" — the intent of
        an urgent traveller who cares about reaching the destination, not
        a specific train.

        **Flow.**
        1. Resolve `from` / `to` — accepts a station **code** (`PUNE`) or a
           station **name** (`Pune Jn`, `nagpur`, `poona`). Fuzzy hits above
           the 0.55 confidence threshold are accepted; alternates are echoed
           in `from.alternates[]` / `to.alternates[]` so callers can render a
           disambiguation prompt when the top hit isn't obvious.
        2. Call `routes-trains` for the route + date.
        3. Filter by `only_running` (weekly-schedule match), `train_types`
           / `avoid_train_types`.
        4. Sort by `sort_by` (default `departure`) and cap to `max_trains`
           (default 100, maximum 100). Trains beyond the cap appear in
           `matches[]` with `scan_status="SKIPPED_CAP_EXCEEDED"` — visible
           but without scan detail.
        5. Fan out per-train (bounded concurrency = 2) to the hopping
           handler, passing through every user preference (`prefer_class`,
           `include_fare`, `strategies`, `include_alternate_boarding`,
           `include_cross_class`, etc.). Individual train failures never
           fail the whole request — they land in `_meta.partial_failures`.
        6. Score each scanned train `[0, 100]` on chart readiness, direct
           vacancy, hopping coverage, seat-change penalty, and preference
           matches (see `score_breakdown`).
        7. Build a plain-English one-liner per train
           (`recommendation` + optional `urgency_hint`) suitable for direct
           display in a consumer UI.
        8. Optionally filter by `min_score`, `only_chart_prepared`.
        9. Re-sort matches by the caller's `sort_by` and return.

        **Upstream work.** One `routes-trains` lookup, then per scanned
        train: one `chart-status`, one `train-schedule`, one
        `coach-position`, and one `vacantBerth` lookup per class
        discovered. With `include_fare=true` add ~1 `fare-estimate` per
        unique `(train, from, to)` tuple; with
        `alternate_boarding_mode=extended` add up to 5 candidate
        chart-status + vacantBerth fan-outs per train. None of this
        changes what the request costs: it is the flat
        `x-railcore-cost` whatever these options are.

        **Errors.**
        - `400 INVALID_INPUT` — missing / malformed query params.
        - `404 STATION_NOT_FOUND` — `from` or `to` could not be resolved
          to a canonical station code above the confidence threshold. The
          error body includes `field` (`"from"` or `"to"`) and `input`.
        - `503 UPSTREAM_UNAVAILABLE` — the `routes-trains` upstream failed
          entirely. Per-train failures degrade gracefully — see
          `_meta.partial_failures[]`.
      tags: [Charts]
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 60 }
          description: |
            Boarding station — either a canonical station code (`PUNE`) or
            a station name (`Pune Jn`, case-insensitive). Fuzzy hits above
            confidence 0.55 are accepted; alternates surface in
            `from.alternates`.
        - name: to
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 60 }
          description: Destination station. Same resolution rules as `from`.
        - name: date
          in: query
          required: true
          schema: { type: string, format: date }
          description: Journey date, `YYYY-MM-DD`. Determines the running-day filter.
        - name: quota
          in: query
          required: false
          schema: { type: string, default: "GN" }
        - name: max_trains
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
          description: |
            Cap on how many trains are actually fanned out for a full
            per-train scan. Default is `100`, effectively "scan every
            train on the route" — even the busiest Indian Railways
            routes rarely see more than ~30 trains per day. Lower this
            explicitly if you want a faster answer (the request costs the same); trains
            beyond the cap still appear in `matches[]` with
            `scan_status="SKIPPED_CAP_EXCEEDED"`. Hard cap 100 guards
            against runaway upstream work.
        - name: min_score
          in: query
          required: false
          schema: { type: number, minimum: 0, maximum: 100, default: 0 }
          description: |
            Drop matches with `score < min_score` from `matches[]`. The
            train count is still reflected in
            `_meta.trains_dropped_by_score_filter`.
        - name: only_running
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"], default: "true" }
          description: |
            When `true` (default), drop trains whose `running_days` don't
            include `date`'s weekday. When `false`, include them with
            `runs_on_date=false` and a running-day penalty applied to the
            score.
        - name: only_chart_prepared
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"], default: "false" }
          description: |
            When `true`, drop trains where neither chart 1 nor chart 2 is
            prepared. Useful for "book right now" UIs.
        - name: sort_by
          in: query
          required: false
          schema: { type: string, enum: [departure, departure_desc, duration, score, chart_ready], default: departure }
          description: |
            Sort key applied to `matches[]`:
            - `departure` (default) — earliest departure first.
            - `departure_desc` — latest first.
            - `duration` — shortest journey first.
            - `score` — highest composite score first.
            - `chart_ready` — chart-2 prepared > chart-1 only > neither;
              then score, then departure.
            `SKIPPED_CAP_EXCEEDED` entries are always sorted last
            regardless of key.
        - name: include_detail
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"], default: "true" }
          description: |
            When `true` (default), each match's `hopping_detail` field
            contains the full `/charts/vacant-berths/hopping` response
            body. When `false`, only the compact `classes_summary` is
            emitted per match — ~10× smaller responses.
        - name: train_types
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated allow list of train types (e.g.
            `SF,RAJDHANI,VANDE_BHARAT`). Empty = accept all.
        - name: avoid_train_types
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated deny list (e.g. `PASSENGER,MEMU,DEMU`).
            Deny always wins over allow.
        - name: max_hops
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 12, default: 6 }
          description: Forwarded to the per-train hopping handler.
        - name: prefer_berth
          in: query
          required: false
          schema: { type: string }
          description: Forwarded to the per-train hopping handler.
        - name: avoid_berth
          in: query
          required: false
          schema: { type: string }
          description: Forwarded to the per-train hopping handler.
        - name: prefer_class
          in: query
          required: false
          schema: { type: string }
          description: |
            Forwarded to the per-train hopping handler AND used in the
            scoring formula — direct/hopping coverage in a preferred
            class earns a bigger bonus than an arbitrary class.
        - name: avoid_class
          in: query
          required: false
          schema: { type: string }
          description: |
            Forwarded to the per-train hopping handler. Skipped classes
            skip one `vacantBerth` lookup per train; the request costs
            the same.
        - name: include_fare
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"] }
          description: |
            When `true`, populate hop-level and direct-option fares in
            every match's `hopping_detail`. Adds one `fare-estimate`
            lookup per unique `(train, from, to)` tuple across the
            response; the request costs the same.
        - name: include_alternate_boarding
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"] }
          description: |
            Forwarded to the per-train hopping handler. See
            `/charts/vacant-berths/hopping` for details on lightweight
            vs. extended modes.
        - name: strategies
          in: query
          required: false
          schema: { type: string }
          description: |
            Forwarded to the per-train hopping handler. `cheapest`
            auto-enables `include_fare`.
        - name: alternate_boarding_mode
          in: query
          required: false
          schema: { type: string, enum: ["lightweight", "extended"], default: "lightweight" }
        - name: alternate_boarding_max_candidates
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 5, default: 3 }
        - name: include_cross_class
          in: query
          required: false
          schema: { type: string, enum: ["true", "false", "1", "0", "yes", "no"] }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EnvelopeVacantBerthsRouteScan' }}}}
        '400': { $ref: '#/components/responses/ValidationError' }
        '404':
          description: A station input could not be resolved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, enum: [STATION_NOT_FOUND] }
                  message: { type: string }
                  field: { type: string, enum: [from, to] }
                  input: { type: string }

  # ------------------------------ Runtime additions ------------------------------
  /fares/unreserved:
    get:
      x-railcore-cost: "Counts as 1 request."
      summary: Unreserved and season-ticket fare enquiry
      tags: [Fares]
      parameters:
        - { name: from, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: to, in: query, required: true, schema: { $ref: '#/components/schemas/StationCode' }}
        - { name: date, in: query, required: false, schema: { type: string, format: date }, description: "Today, tomorrow or the day after, in India (IST): fares are quoted for those three days only. Defaults to tomorrow when omitted. With train_type T (MMTS), today only." }
        - { name: class, in: query, required: false, schema: { type: string, enum: [II, FC], default: II }, description: "II = Second Class; FC = First Class." }
        - { name: train_type, in: query, required: false, schema: { type: string, enum: [O, E, S, T], default: O }, description: "Ordinary, Mail/Express, Superfast, or MMTS. MMTS fares are quoted for today only." }
        - { name: ticket_type, in: query, required: false, schema: { type: string, enum: [J, M, Q, H, Y], default: J }, description: "Journey, Monthly, Quarterly, Half Yearly, or Yearly." }
        - { name: adult, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 9, default: 1 }}
        - { name: child, in: query, required: false, schema: { type: integer, minimum: 0, maximum: 9, default: 0 }}
        - { name: concession, in: query, required: false, schema: { type: string }, description: "Applicable UTS concession code." }
      responses:
        '200':
          description: Unreserved fare routes
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      from: { $ref: '#/components/schemas/StationCode' }
                      to: { $ref: '#/components/schemas/StationCode' }
                      date: { type: string, format: date }
                      class: { type: string, enum: [II, FC] }
                      train_type: { type: string, enum: [O, E, S, T] }
                      ticket_type: { type: string, enum: [J, M, Q, H, Y] }
                      adult: { type: integer }
                      child: { type: integer }
                      routes:
                        type: array
                        items:
                          type: object
                          properties:
                            route: { type: string }
                            fare: { type: string, description: "Decimal INR value preserved as a string." }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/UpstreamError' }

  /jobs/route-scan:
    post:
      x-railcore-cost: "Counts as 8 requests."
      summary: Submit an asynchronous route vacancy scan
      tags: [Charts, Jobs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/RouteScanJobInput'
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /jobs/hopping:
    post:
      x-railcore-cost: "Counts as 4 requests."
      summary: Submit an asynchronous vacant-berth hopping analysis
      tags: [Charts, Jobs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/HoppingJobInput'
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /jobs/{job_id}:
    parameters:
      - $ref: '#/components/parameters/pathJobId'
    get:
      x-railcore-cost: "Free (0 requests)."
      summary: Poll an async job and retrieve its completed result
      tags: [Jobs]
      responses:
        '200':
          description: Current job state or terminal result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EnvelopeJob' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      x-railcore-cost: "Free (0 requests)."
      summary: Cancel a pending or processing async job
      tags: [Jobs]
      responses:
        '200':
          description: Job cancelled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EnvelopeJob' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Job is already in a terminal state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /jobs/{job_id}/stream:
    get:
      x-railcore-cost: "Free (0 requests)."
      summary: Stream async job progress with server-sent events
      tags: [Jobs]
      parameters:
        - $ref: '#/components/parameters/pathJobId'
      responses:
        '200':
          description: Progress events followed by one complete or failed event
          content:
            text/event-stream:
              schema:
                type: string
                description: "Named SSE events: progress, complete, and failed."
        '400': { $ref: '#/components/responses/ValidationError' }
        '404': { $ref: '#/components/responses/NotFound' }

  /jobs:
    get:
      x-railcore-cost: "Free (0 requests)."
      summary: List recent async jobs
      tags: [Jobs]
      parameters:
        - { name: status, in: query, required: false, schema: { $ref: '#/components/schemas/JobStatus' }}
        - { name: type, in: query, required: false, schema: { type: string, enum: [route-scan, hopping] }}
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 20 }}
      responses:
        '200':
          description: Compact job lifecycle rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      jobs:
                        type: array
                        items: { $ref: '#/components/schemas/Job' }
                      count: { type: integer }
                  meta: { $ref: '#/components/schemas/Meta' }
        '401': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    RailCoreKey:
      type: apiKey
      in: header
      name: X-RailCore-Key
      description: |
        Every plan. 24+ character opaque key, created under API keys in the developer dashboard: https://railcore.tech/dashboard?view=developer&section=keys. Free and Starter keys send it alone; Growth, Scale and Enterprise keys must also sign (RailCoreKeyWithHmac). The same key can be sent as RailCoreBearer or RailCoreApiKey instead; two headers carrying different keys answer 401 AMBIGUOUS_API_KEY.
    RailCoreBearer:
      type: http
      scheme: bearer
      description: |
        The API key as `Authorization: Bearer <key>`, an alternative to `X-RailCore-Key`. Growth, Scale and Enterprise keys must still sign.
    RailCoreApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        The API key in an `X-Api-Key` header, an alternative to `X-RailCore-Key`. Growth, Scale and Enterprise keys must still sign.
    RailCoreKeyWithHmac:
      type: apiKey
      in: header
      name: X-RailCore-Key
      description: |
        Growth, Scale and Enterprise plans require HMAC-SHA256 signing in addition to the API key.
        Include headers `X-RailCore-Timestamp`, `X-RailCore-Nonce`, and `X-RailCore-Signature`.
        Keys made before 2026-11-03 have no signing secret: rotate the key in the dashboard to get one.
        Signature payload: `METHOD\nPATH\nCANONICAL_QUERY\nSHA256(BODY)\nTIMESTAMP\nNONCE`, where BODY is the exact bytes of the request body as sent (any JSON formatting) and an empty body hashes zero bytes.

  parameters:
    queryString:
      name: q
      in: query
      required: true
      schema: { type: string, minLength: 1, maxLength: 80 }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
    pathStationCode:
      name: station_code
      in: path
      required: true
      schema: { $ref: '#/components/schemas/StationCode' }
    pathTrainNumber:
      name: train_number
      in: path
      required: true
      schema: { $ref: '#/components/schemas/TrainNumber' }
    pathJobId:
      name: job_id
      in: path
      required: true
      schema: { type: string, pattern: '^job_[A-Za-z0-9]+$' }
      description: Job identifier returned by an async submission endpoint.

  headers:
    AllStandard:
      X-RailCore-Request-ID:
        schema: { type: string }
      X-RailCore-Trace-ID:
        schema: { type: string }
      X-RailCore-Plan:
        schema: { type: string }
      X-RailCore-RateLimit-Limit:
        description: The account's per-minute budget, shared by every key on the account.
        schema: { type: integer }
      X-RailCore-RateLimit-Remaining:
        description: Requests left in the account's current minute.
        schema: { type: integer }
      X-RailCore-RateLimit-Reset:
        schema: { type: integer }
      X-RailCore-Credits-Cost:
        description: What this request counted as. One credit is one request; failed requests use none.
        schema: { type: integer }
      X-RailCore-Credits-Remaining:
        description: Requests left on the account.
        schema: { type: integer }
      X-RailCore-Confidence:
        schema: { type: string, enum: [high, medium, low] }
      Server-Timing:
        description: Where the request spent its time, as `name;dur=milliseconds` entries in the order they ran (for example `key`, `limits`, `credits`, `upstream`), then `total`.
        schema: { type: string }

  responses:
    ValidationError:
      description: Request failed schema validation
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    AuthError:
      description: Authentication failed. `MISSING_API_KEY` (no key in `X-RailCore-Key`, `Authorization` or `X-Api-Key`), `AMBIGUOUS_API_KEY` (two headers with different keys), `INVALID_API_KEY`, `API_KEY_DISABLED`, `API_KEY_EXPIRED`, `SIGNATURE_INVALID`, `TIMESTAMP_EXPIRED` or `NONCE_REUSED`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: One of the account's rate limits is used up. Limits are shared by every key on the account. Wait for `Retry-After`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    UpstreamError:
      description: A live-data source failed (UPSTREAM_UNAVAILABLE) or timed out (UPSTREAM_TIMEOUT). Wait for Retry-After, then retry with backoff. Not counted.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer, minimum: 0 }
        X-RailCore-Request-ID:
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    ChartNotReady:
      description: Chart has not been prepared yet
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    JobAccepted:
      description: Async job accepted for processing
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean }
              data:
                type: object
                properties:
                  job_id: { type: string }
                  status: { type: string, enum: [processing] }
                  poll_url: { type: string }
                  stream_url: { type: string }
                  message: { type: string }
              meta: { $ref: '#/components/schemas/Meta' }

  schemas:
    StationCode:
      type: string
      pattern: '^[A-Z0-9]{1,6}$'
      example: BSL
    NullableStationCode:
      anyOf:
        - { $ref: '#/components/schemas/StationCode' }
        - { type: "null" }
    TrainNumber:
      type: string
      pattern: '^[0-9A-Z]{4,6}$'
      example: '12656'
    ClassCode:
      type: string
      enum: [1A, 2A, 3A, 3E, CC, EC, SL, 2S, GS, GEN, SLR, PC, HP, GEN_RSVD, FC, UNKNOWN]
    Quota:
      type: string
      enum: [GN, TQ, PT, LD, SS, HP, DP, HO, YU, FT]
    RunningDay:
      type: string
      enum: [MON, TUE, WED, THU, FRI, SAT, SUN]

    JobStatus:
      type: string
      enum: [pending, processing, complete, failed, cancelled]

    RouteScanJobInput:
      type: object
      required: [from, to, date]
      properties:
        from: { type: string, description: "Station code or name." }
        to: { type: string, description: "Station code or name." }
        date: { type: string, format: date }
        quota: { $ref: '#/components/schemas/Quota' }
        max_trains: { type: integer, minimum: 1, maximum: 100, default: 100 }
        min_score: { type: number, minimum: 0, maximum: 100, default: 0 }
        only_running: { type: boolean, default: true }
        only_chart_prepared: { type: boolean, default: false }
        sort_by: { type: string, enum: [departure, departure_desc, duration, score, chart_ready], default: departure }
        include_detail: { type: boolean, default: true }
        train_types: { type: string, description: "Comma-separated allow list." }
        avoid_train_types: { type: string, description: "Comma-separated deny list." }
        max_hops: { type: integer, minimum: 1, maximum: 12, default: 6 }
        prefer_berth: { type: string }
        avoid_berth: { type: string }
        prefer_class: { type: string }
        avoid_class: { type: string }
        include_fare: { type: boolean, default: false }
        include_alternate_boarding: { type: boolean, default: false }
        strategies: { type: string }
        alternate_boarding_mode: { type: string, enum: [lightweight, extended], default: lightweight }
        alternate_boarding_max_candidates: { type: integer, minimum: 1, maximum: 5, default: 3 }
        include_cross_class: { type: boolean, default: false }
        callback_url: { type: string, format: uri }

    HoppingJobInput:
      type: object
      required: [train_number, from, to, date]
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        from: { $ref: '#/components/schemas/StationCode' }
        to: { $ref: '#/components/schemas/StationCode' }
        date: { type: string, format: date }
        quota: { $ref: '#/components/schemas/Quota' }
        via: { type: string, description: "Comma-separated station codes." }
        max_hops: { type: integer, minimum: 1, maximum: 12, default: 6 }
        prefer_berth: { type: string }
        avoid_berth: { type: string }
        prefer_class: { type: string }
        avoid_class: { type: string }
        include_fare: { type: boolean, default: false }
        include_alternate_boarding: { type: boolean, default: false }
        include_cross_class: { type: boolean, default: false }
        strategies: { type: string }
        callback_url: { type: string, format: uri }

    Job:
      type: object
      properties:
        job_id: { type: string }
        status: { $ref: '#/components/schemas/JobStatus' }
        type: { type: string, enum: [route-scan, hopping] }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        progress: { type: string }
        callback_status: { type: string, enum: [sent, failed, not_configured] }
        data: { type: object, additionalProperties: true }
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }

    EnvelopeJob:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/Job' }
        meta: { $ref: '#/components/schemas/Meta' }

    Meta:
      type: object
      required: [api_version, request_id, trace_id, timestamp]
      properties:
        api_version: { type: string, example: v1 }
        request_id:  { type: string, example: req_01HXYZABC }
        trace_id:    { type: string, example: trc_01HXYZABC }
        timestamp:   { type: string, format: date-time }
        freshness:
          type: object
          properties:
            mode: { type: string, enum: [live] }
            retrieved_at: { type: string, format: date-time }
            sources:
              type: array
              items:
                type: object
                properties:
                  confidence: { type: string, enum: [high, medium, low] }
                  last_reported_at: { type: string, format: date-time }

    ErrorEnvelope:
      type: object
      required: [success, error, meta]
      properties:
        success: { type: boolean, example: false }
        error:
          type: object
          required: [code, message, category, retryable]
          properties:
            code:     { type: string, example: STATION_NOT_FOUND }
            message:  { type: string }
            category: { type: string, enum: [VALIDATION, AUTH, QUOTA, RATE_LIMIT, UPSTREAM, INTERNAL, NOT_FOUND, FORBIDDEN] }
            retryable: { type: boolean }
            details: { type: array, items: { type: object } }
        meta: { $ref: '#/components/schemas/Meta' }

    Station:
      type: object
      properties:
        station_code: { $ref: '#/components/schemas/StationCode' }
        station_name: { type: string }
        display_name: { type: string }
        city: { type: [string, "null"] }
        state: { type: [string, "null"] }
        country: { type: string, default: "IN" }
        latitude: { type: [number, "null"] }
        longitude: { type: [number, "null"] }
        aliases: { type: array, items: { type: string }}
        is_major: { type: boolean }
        confidence: { type: number, minimum: 0, maximum: 1 }

    Train:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name: { type: string }
        display_name: { type: string }
        source_station_code: { $ref: '#/components/schemas/StationCode' }
        destination_station_code: { $ref: '#/components/schemas/StationCode' }
        train_type: { type: string }
        running_days: { type: array, items: { $ref: '#/components/schemas/RunningDay' }}

    ScheduleStop:
      type: object
      properties:
        sequence: { type: integer }
        station_code: { $ref: '#/components/schemas/StationCode' }
        station_name: { type: string }
        arrival_time: { type: [string, "null"] }
        departure_time: { type: [string, "null"] }
        halt_minutes: { type: [integer, "null"] }
        day: { type: integer }
        distance_km: { type: number }
        platform_number: { type: [string, "null"] }
        latitude: { type: [number, "null"] }
        longitude: { type: [number, "null"] }
        typical_arrival_delay_minutes:
          type: [integer, "null"]
          description: Typical arrival delay at this stop in minutes, from a fixed delay profile. The same on every date and every request, never a live reading. Null when there is no profile for the stop. For live delay, use /trains/{train_number}/live.
        typical_departure_delay_minutes:
          type: [integer, "null"]
          description: Typical departure delay at this stop in minutes, from the same fixed delay profile. Not live.
        arrival_delay_minutes:
          type: [integer, "null"]
          deprecated: true
          description: Deprecated alias of typical_arrival_delay_minutes, with the same value. Not live delay. Removed no earlier than 2026-12-07 (30 days after the 2026-11-07 notice).
        departure_delay_minutes:
          type: [integer, "null"]
          deprecated: true
          description: Deprecated alias of typical_departure_delay_minutes, with the same value. Not live delay. Removed no earlier than 2026-12-07 (30 days after the 2026-11-07 notice).
        is_stop: { type: boolean }

    TrainSchedule:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name: { type: string }
        source_station_code: { $ref: '#/components/schemas/StationCode' }
        destination_station_code: { $ref: '#/components/schemas/StationCode' }
        running_days: { type: array, items: { $ref: '#/components/schemas/RunningDay' }}
        classes: { type: array, items: { $ref: '#/components/schemas/ClassCode' }}
        total_duration_minutes: { type: [integer, "null"] }
        distance_km: { type: [number, "null"] }
        stops: { type: array, items: { $ref: '#/components/schemas/ScheduleStop' }}

    CoachSlot:
      type: object
      properties:
        sequence: { type: integer }
        coach_name: { type: string }
        class_code: { $ref: '#/components/schemas/ClassCode' }
        position_from_engine: { type: integer }

    CoachPosition:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        coach_position: { type: array, items: { $ref: '#/components/schemas/CoachSlot' }}

    StationBoardTrain:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name: { type: string }
        source_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        destination_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        source_station_name: { type: [string, "null"] }
        destination_station_name: { type: [string, "null"] }
        train_type: { type: string }
        scheduled_arrival: { type: [string, "null"], format: date-time }
        scheduled_departure: { type: [string, "null"], format: date-time }
        estimated_arrival: { type: [string, "null"], format: date-time }
        estimated_departure: { type: [string, "null"], format: date-time }
        platform_number: { type: [string, "null"] }
        arrival_delay_minutes: { type: [integer, "null"] }
        departure_delay_minutes: { type: [integer, "null"] }
        scheduled_halt_minutes: { type: [integer, "null"] }
        actual_halt_minutes: { type: [integer, "null"] }
        start_date: { type: [string, "null"], format: date }
        status: { type: string, enum: [NOT_STARTED, ON_ROUTE, AT_STATION, ARRIVED, DEPARTED, SEVERELY_DELAYED, CANCELLED, DIVERTED, UNKNOWN] }
        current_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        departed_current_station: { type: [boolean, "null"] }
        stop_type: { type: string, enum: [ORIGIN, INTERMEDIATE, DESTINATION] }
        cancellation:
          anyOf:
            - { $ref: '#/components/schemas/StationBoardCancellation' }
            - { type: "null" }
        confidence: { type: string, enum: [high, medium, low] }
        last_reported_at: { type: [string, "null"], format: date-time }

    StationBoard:
      type: object
      properties:
        station_code: { $ref: '#/components/schemas/StationCode' }
        window_hours: { type: integer, enum: [4, 8], description: "The window the board covers, in hours from now." }
        trains: { type: array, items: { $ref: '#/components/schemas/StationBoardTrain' }}

    StationBoardCancellation:
      type: object
      required: [fully_cancelled, from_station_code, to_station_code]
      properties:
        fully_cancelled:
          type: boolean
          description: True only when the complete train run is cancelled.
        from_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        to_station_code: { $ref: '#/components/schemas/NullableStationCode' }

    LiveTrainStop:
      type: object
      properties:
        sequence: { type: integer, minimum: 0, description: "Position on the route, from 1." }
        station_code: { $ref: '#/components/schemas/StationCode' }
        station_name: { type: [string, "null"] }
        scheduled_arrival: { type: [string, "null"], format: date-time, description: "Timetabled arrival." }
        scheduled_departure: { type: [string, "null"], format: date-time, description: "Timetabled departure." }
        eta: { type: [string, "null"], format: date-time, description: "Expected arrival at a station still ahead." }
        etd: { type: [string, "null"], format: date-time, description: "Expected departure from a station still ahead." }
        actual_arrival: { type: [string, "null"], format: date-time, description: "Reported arrival at a station already reached." }
        actual_departure: { type: [string, "null"], format: date-time, description: "Reported departure from a station already reached." }
        delay_arrival_minutes: { type: [integer, "null"], description: "Minutes late arriving. Negative when early." }
        delay_departure_minutes: { type: [integer, "null"], description: "Minutes late leaving. Negative when early." }
        platform_number: { type: [string, "null"], description: "Expected platform. Platforms can change at short notice." }
        halt_minutes: { type: [integer, "null"], minimum: 0, description: "Booked halt in minutes." }
        day: { type: [integer, "null"], minimum: 1, description: "Day of the run; 1 is the day it starts." }
        distance_km: { type: [number, "null"], minimum: 0, description: "Distance from the first station." }
        is_stopping: { type: boolean, description: "False for a station the train passes without stopping." }
        is_diverted: { type: boolean, description: "True for a station on a diversion rather than the booked route." }
        has_arrived: { type: boolean, description: "Whether the train has reached this station." }
        has_departed: { type: boolean, description: "Whether the train has left this station." }
        travelled: { type: boolean, description: "Whether the train has covered this station." }
        latitude: { type: [number, "null"], minimum: -90, maximum: 90 }
        longitude: { type: [number, "null"], minimum: -180, maximum: 180 }
        last_reported_at: { type: [string, "null"], format: date-time, description: "When the train was last reported at this station." }

    LiveTrainFreshness:
      type: object
      required: [railcore_cache, retrieved_at, last_reported_at, age_seconds, state, upstream_served_from_cache]
      properties:
        railcore_cache: { type: string, enum: [bypassed, reused], description: "bypassed: read from the live feed for this request. reused: RailCore's read of the same run made within max_age seconds; retrieved_at says when." }
        retrieved_at: { type: string, format: date-time, description: "When RailCore read the live feed." }
        last_reported_at: { type: [string, "null"], format: date-time, description: "The report time the live feed gave back." }
        age_seconds: { type: [integer, "null"], minimum: 0, description: "Age of that report." }
        state: { type: string, enum: [fresh, aging, stale, unknown], description: "What the report's age makes it." }
        upstream_served_from_cache: { type: [boolean, "null"], description: "True when the live feed said it answered from its own cache; null when it did not say." }

    LiveTrainOfficialNotice:
      type: object
      description: What the railway's own running status says differs from the timetable.
      properties:
        checked_at: { type: string, format: date-time, description: "When RailCore last read the railway's running status for this run." }
        reason: { type: [string, "null"], description: "The railway's reason, in its own words." }
        message: { type: [string, "null"], description: "The railway's notice, in its own words." }
        new_source_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        new_destination_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        new_departure: { type: [string, "null"], format: date-time, description: "When the run now leaves its first station. Tentative." }
        new_arrival: { type: [string, "null"], format: date-time, description: "When a run cut short now reaches its new last station." }
        diverted: { type: boolean, description: "Whether the train runs on a diversion." }

    LiveTrainStatus:
      type: object
      required: [train_number, journey_date, status, confidence, stations, freshness]
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name: { type: [string, "null"], description: "The train's published name." }
        journey_date: { type: string, format: date, description: "The date asked for: the day the run left its first station." }
        source_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        destination_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        train_type: { type: [string, "null"], description: "Category code, when the live feed gives it. Often null." }
        train_type_label: { type: [string, "null"], description: "Readable category, when the live feed gives it. Often null." }
        travel_classes: { type: [array, "null"], items: { type: string, pattern: '^[A-Z0-9]{1,4}$' }, description: "Classes the train carries, when the live feed gives them. Often empty." }
        scheduled_duration_minutes: { type: [integer, "null"], minimum: 0, description: "Timetabled time from first to last station." }
        started_at: { type: [string, "null"], format: date-time, description: "When the train actually left its first station. Null before it leaves." }
        start_date: { type: [string, "null"], format: date, description: "The run's start date as the live feed gives it." }
        status: { type: string, enum: [NOT_STARTED, RUNNING, AT_STATION, COMPLETED, CANCELLED, DIVERTED, UNKNOWN], description: "Where the run stands. CANCELLED means the whole run; a part cancellation stays RUNNING or AT_STATION and is in cancellation." }
        status_text: { type: [string, "null"], description: "A ready-to-show line, for example Running 22 minutes late." }
        current_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        current_station_name: { type: [string, "null"], description: "The station the train is at, or the last one it passed." }
        previous_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        next_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        approaching_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        approaching_station_name: { type: [string, "null"], description: "The next station on the line ahead, even one the train will pass without stopping." }
        distance_from_current_station_km: { type: [number, "null"], minimum: 0, description: "How far the train is past the current station. Null when the position is not precise enough." }
        distance_to_approaching_station_km: { type: [number, "null"], minimum: 0, description: "How far the train is short of the approaching station. Null when the position is not precise enough." }
        latitude: { type: [number, "null"], minimum: -90, maximum: 90, description: "The train's last reported position." }
        longitude: { type: [number, "null"], minimum: -180, maximum: 180, description: "The train's last reported position." }
        position_anchor_station_code: { $ref: '#/components/schemas/NullableStationCode' }
        progress_percent: { type: [number, "null"], minimum: 0, maximum: 100, description: "Share of the route covered." }
        distance_covered_km: { type: [number, "null"], minimum: 0, description: "Distance covered so far." }
        total_distance_km: { type: [number, "null"], minimum: 0, description: "Length of the route." }
        speed_kmph: { type: [number, "null"], minimum: 0, description: "Not reported at present; always null." }
        delay_minutes: { type: [integer, "null"], description: "How late the train is at the last station it reached. Negative when early." }
        eta_destination_at: { type: [string, "null"], format: date-time, description: "Expected arrival at the last station; the actual time once complete. Null for a cancelled run." }
        rescheduled_minutes: { type: [integer, "null"], minimum: 1, description: "How far the railway moved the run's start, when it rescheduled it." }
        last_reported_at: { type: [string, "null"], format: date-time, description: "When the live feed last reported this train." }
        confidence: { type: string, enum: [high, medium, low], description: "How far to trust the answer, from its age and source." }
        cancellation:
          description: Null, or the cancelled part of the run.
          anyOf:
            - { $ref: '#/components/schemas/StationBoardCancellation' }
            - { type: "null" }
        official_notice:
          description: Null, or what the railway reports differs from the timetable.
          anyOf:
            - { $ref: '#/components/schemas/LiveTrainOfficialNotice' }
            - { type: "null" }
        next_stop:
          description: The next station the train halts at.
          anyOf:
            - { $ref: '#/components/schemas/LiveTrainStop' }
            - { type: "null" }
        stations: { type: array, items: { $ref: '#/components/schemas/LiveTrainStop' }, description: "Every station on the route in order, including the ones the train passes without stopping." }
        freshness: { $ref: '#/components/schemas/LiveTrainFreshness' }

    AvailabilityMini:
      type: object
      properties:
        class_code: { $ref: '#/components/schemas/ClassCode' }
        status: { type: string, enum: [AVAILABLE, RAC, WAITLIST, REGRET, NOT_AVAILABLE, TRAIN_CANCELLED, TRAIN_DEPARTED, UNKNOWN] }
        text: { type: string }
        fare: { type: [integer, "null"] }
        confidence: { type: string, enum: [high, medium, low] }
        last_updated_at: { type: [string, "null"], format: date-time }

    RouteTrain:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name: { type: string }
        extended_train_name: { type: [string, "null"] }
        departure_time: { type: string }
        arrival_time: { type: string }
        duration_minutes: { type: integer }
        distance_km: { type: [number, "null"] }
        train_type: { type: string }
        classes: { type: array, items: { $ref: '#/components/schemas/ClassCode' }}
        running_days: { type: array, items: { $ref: '#/components/schemas/RunningDay' }}
        has_pantry: { type: [boolean, "null"] }
        on_time_rating: { type: [number, "null"] }
        availability_summary: { type: array, items: { $ref: '#/components/schemas/AvailabilityMini' }}

    RouteTrains:
      type: object
      properties:
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code: { $ref: '#/components/schemas/StationCode' }
        journey_date: { type: string, format: date }
        quota: { $ref: '#/components/schemas/Quota' }
        trains: { type: array, items: { $ref: '#/components/schemas/RouteTrain' }}

    ConfirmationProbability:
      type: [object, "null"]
      properties:
        percentage: { type: integer, minimum: 0, maximum: 100 }
        label: { type: string, enum: [LOW, MEDIUM, HIGH] }
        text: { type: string }

    AvailabilityDetail:
      type: object
      properties:
        class_code: { $ref: '#/components/schemas/ClassCode' }
        status: { type: string, enum: [AVAILABLE, RAC, WAITLIST, REGRET, NOT_AVAILABLE, TRAIN_CANCELLED, TRAIN_DEPARTED, UNKNOWN] }
        availability_text: { type: string }
        available_count:
          type: [integer, "null"]
          minimum: 0
          description: Available berth count. Null when the source reports availability without a quantity.
        waitlist_count: { type: [integer, "null"], description: "Current waitlist position; null if the source reports a waitlist without a position." }
        rac_count: { type: [integer, "null"], description: "Current RAC position; null if the source reports RAC without a position." }
        wl_pool:
          type: [string, "null"]
          enum: [GNWL, PQWL, RLWL, RSWL, TQWL, NLWL, CKWL, RQWL, DPWL, RLGN, LDWL, SSWL, FTWL, HPWL, null]
          description: Original booking pool parsed from the source status; it can remain present after the current status becomes RAC or AVAILABLE.
        wl_pool_position:
          type: [integer, "null"]
          description: Original position in the identified booking pool. Null when no original position is supplied.
        fare: { type: [integer, "null"] }
        catering_charge: { type: integer }
        total_fare: { type: [integer, "null"] }
        confirmation_probability: { $ref: '#/components/schemas/ConfirmationProbability' }
        last_updated_at: { type: [string, "null"], format: date-time }

    AvailabilityResponse:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code: { $ref: '#/components/schemas/StationCode' }
        journey_date: { type: string, format: date }
        quota: { $ref: '#/components/schemas/Quota' }
        classes: { type: array, items: { $ref: '#/components/schemas/AvailabilityDetail' }}
        enquiry_checked_at: { type: string, format: date-time, description: When RailCore asked. }
        enquiry_completed_at: { type: string, format: date-time }
        inventory_updated_at: { type: [string, "null"], format: date-time, description: When the figure was read from IRCTC; can be hours before enquiry_checked_at when the upstream answers from its own cache. }

    AvailabilityBatchResponse:
      type: object
      properties:
        results: { type: array, items: { $ref: '#/components/schemas/AvailabilityResponse' }}

    AvailabilityCalendarDay:
      type: object
      properties:
        date: { type: string, format: date }
        day_of_week: { $ref: '#/components/schemas/RunningDay' }
        status: { type: string }
        availability_text: { type: string }
        available_count:
          type: [integer, "null"]
          minimum: 0
          description: Available berth count. Null when the source reports availability without a quantity.
        waitlist_count: { type: [integer, "null"] }
        fare: { type: [integer, "null"] }
        confirmation_probability: { $ref: '#/components/schemas/ConfirmationProbability' }
        alternate:
          type: [object, "null"]
          properties:
            status: { type: string }
            availability_text: { type: string }
            fare: { type: [integer, "null"] }
            confirmation_probability: { $ref: '#/components/schemas/ConfirmationProbability' }
        last_updated_at: { type: [string, "null"], format: date-time }

    AvailabilityCalendar:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code: { $ref: '#/components/schemas/StationCode' }
        class_code: { $ref: '#/components/schemas/ClassCode' }
        quota: { $ref: '#/components/schemas/Quota' }
        days: { type: array, items: { $ref: '#/components/schemas/AvailabilityCalendarDay' }}

    FareItem:
      type: object
      properties:
        class_code: { $ref: '#/components/schemas/ClassCode' }
        fare: { type: integer }
        currency: { type: string, enum: [INR] }

    FareEstimate:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code: { $ref: '#/components/schemas/StationCode' }
        quota: { $ref: '#/components/schemas/Quota' }
        fares: { type: array, items: { $ref: '#/components/schemas/FareItem' }}

    ChartCoachSummary:
      type: object
      properties:
        coach_name: { type: string }
        class_code: { $ref: '#/components/schemas/ClassCode' }
        position_from_engine: { type: integer }
        vacant_berths: { type: integer }

    ChartStatus:
      type: object
      properties:
        chart_one_prepared: { type: boolean }
        chart_two_prepared: { type: boolean }
        chart_one_date: { type: [string, "null"], format: date-time }
        chart_two_date: { type: [string, "null"], format: date-time }

    ChartStatusResponse:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name: { type: string }
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code: { $ref: '#/components/schemas/StationCode' }
        train_start_date: { type: string, format: date }
        remote_station_code: { $ref: '#/components/schemas/StationCode' }
        next_remote_station_code: { $ref: '#/components/schemas/StationCode' }
        destination_station_code: { $ref: '#/components/schemas/StationCode' }
        chart_status: { $ref: '#/components/schemas/ChartStatus' }
        coach_summary: { type: array, items: { $ref: '#/components/schemas/ChartCoachSummary' }}

    VacantBerth:
      type: object
      properties:
        coach_name: { type: string }
        class_code: { $ref: '#/components/schemas/ClassCode' }
        berth_number: { type: integer }
        berth_code: { type: string }
        cabin_coupe: { type: [string, "null"] }
        cabin_coupe_no: { type: [integer, "null"] }
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code: { $ref: '#/components/schemas/StationCode' }
        split_no: { type: integer }
        covers_requested_segment:
          type: boolean
          description: |
            `true` when this vacant split covers the caller's requested from → to
            segment end-to-end (i.e. the split's from-station is at or before the
            request `from` in the train's stop order AND the split's to-station is
            at or after the request `to`). `false` for splits that only partially
            overlap. Defaults to `true` when the schedule is unavailable or the
            caller did not supply from/to.

    VacantBerthsResponse:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name: { type: string }
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code: { $ref: '#/components/schemas/StationCode' }
        train_start_date: { type: string, format: date }
        remote_station_code: { $ref: '#/components/schemas/StationCode' }
        chart_status: { $ref: '#/components/schemas/ChartStatus' }
        coach_summary: { type: array, items: { $ref: '#/components/schemas/ChartCoachSummary' }}
        vacant_berths: { type: array, items: { $ref: '#/components/schemas/VacantBerth' }}

    HoppingHop:
      type: object
      description: |
        One leg of a hop chain. `from_*` / `to_*` reflect the passenger's
        *effective* travel range — clipped to the requested segment and to the
        previous hop's alighting station. `berth_valid_from_station_code` and
        `berth_valid_to_station_code` preserve the underlying berth's full
        vacancy range so callers can see any extra headroom (e.g. a berth
        vacant AN→AK used by a passenger only travelling AN→BSL).
      properties:
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code:   { $ref: '#/components/schemas/StationCode' }
        from_stop_sequence: { type: integer, description: "Stop sequence along the train's schedule (1 = source)." }
        to_stop_sequence:   { type: integer }
        from_station_name:   { type: [string, "null"] }
        to_station_name:     { type: [string, "null"] }
        from_departure_time: { type: [string, "null"], description: "HH:mm departure from `from_station_code` (null when schedule enrichment failed)." }
        to_arrival_time:     { type: [string, "null"] }
        coach_name:   { type: string }
        class_code:   { $ref: '#/components/schemas/ClassCode' }
        berth_number: { type: integer }
        berth_code:   { type: string }
        berth_code_new:
          type: [string, "null"]
          description: |
            Normalized berth code using the updated IRCTC nomenclature (e.g. "LB"
            instead of "L"). Null when the mapping is unavailable or the berth
            code is already in the new format.
        cabin_coupe:    { type: [string, "null"] }
        cabin_coupe_no: { type: [integer, "null"] }
        split_no: { type: integer }
        berth_valid_from_station_code:
          $ref: '#/components/schemas/StationCode'
        berth_valid_to_station_code:
          $ref: '#/components/schemas/StationCode'
        stops_covered:
          type: integer
          minimum: 0
          description: |
            `to_stop_sequence - from_stop_sequence` — how many stop-order slots
            this hop covers on the passenger's effective travel.
        fare_inr:
          type: [integer, "null"]
          description: |
            Fare in INR for this hop's `(from_station_code → to_station_code)` in
            this class. Populated only when the caller passes `include_fare=true`
            AND the upstream `fare-estimate` lookup succeeded. `null` otherwise.
        berth_ergonomics:
          $ref: '#/components/schemas/BerthErgonomics'

    BerthErgonomics:
      type: object
      description: |
        Human-readable guidance about a berth's accessibility, comfort, and
        typical use cases. Emitted on every hop — always populated regardless
        of caller flags. Non-technical travellers can filter by
        `requires_climbing`, `daytime_seating`, etc. without knowing IRCTC
        berth-code conventions.
      properties:
        code: { type: string, description: "Raw IRCTC berth code (e.g. L, SL, U, P, W)." }
        label: { type: string, description: "Human-friendly label such as 'Lower berth'." }
        position:
          type: string
          enum: [cabin, side, chair, unknown]
        requires_climbing: { type: boolean }
        daytime_seating: { type: boolean }
        privacy_tier: { type: string, enum: [high, medium, low] }
        typical_length_meters: { type: [number, "null"] }
        recommended_for: { type: array, items: { type: string } }
        avoid_for: { type: array, items: { type: string } }
        description: { type: string }

    HoppingOption:
      type: object
      description: |
        Result of running the min-hop cover algorithm for one class. When
        `is_covered=false`, `reason` explains why and `unreachable_from_stop_sequence`
        marks the earliest stop the algorithm could not reach.
      properties:
        class_code: { $ref: '#/components/schemas/ClassCode' }
        is_covered: { type: boolean }
        total_hops: { type: integer }
        physical_seat_changes:
          type: integer
          description: |
            Number of times the passenger must physically move within the chain
            (consecutive hops that keep the same coach+berth do not count).
        hops:
          type: array
          items: { $ref: '#/components/schemas/HoppingHop' }
        reason:
          type: [string, "null"]
          enum: [null, "NO_VACANT_BERTHS", "GAP_UNREACHABLE", "MAX_HOPS_EXCEEDED", "VIA_GAP_UNREACHABLE"]
        unreachable_from_stop_sequence: { type: [integer, "null"] }
        unreachable_from_station_code:  { type: [string, "null"] }
        total_fare_inr:
          type: [integer, "null"]
          description: |
            Sum of `hops[i].fare_inr` across the chain when
            `include_fare=true`. `null` when the flag was off, when the
            chain is empty, or when any hop's fare lookup failed — we
            don't ship a misleading total.
        strategies:
          type: array
          items: { $ref: '#/components/schemas/StrategyPlan' }
          description: |
            Alternative plans for this class, one per requested strategy
            (`min_hops`, `min_seat_changes`, `cheapest`). Defaults to a
            single `min_hops` entry. The top-level fields (`is_covered`,
            `hops`, `total_fare_inr`, etc.) mirror `strategies[0]` for
            backward compatibility.

    StrategyPlan:
      type: object
      description: |
        A single strategy's plan for one class. Same shape as the top-level
        `HoppingOption` fields — consumers can render any strategy with the
        same code.
      properties:
        name:
          type: string
          enum: [min_hops, min_seat_changes, cheapest]
        is_covered: { type: boolean }
        total_hops: { type: integer }
        physical_seat_changes: { type: integer }
        total_fare_inr: { type: [integer, "null"] }
        hops:
          type: array
          items: { $ref: '#/components/schemas/HoppingHop' }
        reason:
          type: [string, "null"]
          enum: [null, "NO_VACANT_BERTHS", "GAP_UNREACHABLE", "MAX_HOPS_EXCEEDED", "VIA_GAP_UNREACHABLE", "REQUIRES_FARE_DATA"]
        unreachable_from_stop_sequence: { type: [integer, "null"] }
        unreachable_from_station_code:  { type: [string, "null"] }
        ranking_reason:
          type: string
          description: One-line human-readable summary of what makes this plan special.

    HoppingDirectOption:
      type: object
      properties:
        class_code: { $ref: '#/components/schemas/ClassCode' }
        berths:
          type: array
          items: { $ref: '#/components/schemas/VacantBerth' }
        fare_inr:
          type: [integer, "null"]
          description: |
            Fare in INR for `(from → to)` in this class. Populated only
            when `include_fare=true` and the fare lookup succeeded. All
            berths in this option cover the same segment so one fare
            applies to all of them.

    AlternateBoardingCandidate:
      type: object
      description: |
        Same-train boarding station near the caller's `from`. Emitted only
        when the caller passes `include_alternate_boarding=true`. No extra
        upstream chart lookups — vacancy is a re-projection of already-fetched
        berth data over `(candidate → to)`.
      properties:
        station_code: { $ref: '#/components/schemas/StationCode' }
        station_name: { type: [string, "null"] }
        stop_sequence: { type: integer }
        is_earlier_boarding:
          type: boolean
          description: True when the candidate is *before* the caller's `from` on the route.
        sequence_delta:
          type: integer
          description: |
            Signed stop-sequence delta from `from` to this candidate.
            Negative → earlier boarding, positive → later boarding.
        direct_vacancy_by_class:
          type: object
          additionalProperties: { type: integer }
          description: Per-class direct-vacancy counts for `(candidate → to)`.
        hopping_covered_classes:
          type: array
          items: { type: string }
          description: |
            Classes for which a hopping plan exists from `(candidate → to)`.
        fare_inr_by_class:
          type: object
          additionalProperties: { type: integer }
          description: |
            Per-class fare for `(candidate → to)`. Populated only when
            `include_fare=true` and the fare lookup succeeded for this tuple.
        chart_status:
          oneOf:
            - $ref: '#/components/schemas/ChartStatus'
            - type: "null"
          description: |
            Fresh chart-status snapshot for this candidate boarding station.
            Only populated in `alternate_boarding_mode=extended`. Chart-2
            preparation timing varies per boarding station, so this is what
            makes "extended" mode authoritative.
        vacancy_delta_vs_user_boarding:
          type: object
          additionalProperties: { type: integer }
          description: |
            Per-class delta vs the caller's actual boarding station. Positive
            = candidate has MORE vacancy. Only populated in `extended` mode.
        notes:
          type: array
          items: { type: string }
          description: |
            Human-readable insights (e.g. "3 more SL berths compared to
            boarding from ADI", "Chart 2 already prepared").
        recommendation_score:
          type: integer
          minimum: 0
          maximum: 100
          description: |
            Composite ranking score. Higher = better candidate.

    AlternateBoardingBlock:
      type: object
      properties:
        enabled:
          type: boolean
          description: True iff the caller passed `include_alternate_boarding=true`.
        mode:
          type: string
          enum: [lightweight, extended]
          description: Which fetch strategy produced the candidates.
        chart_calls_made:
          type: integer
          minimum: 0
          description: |
            Number of extra chart-composition calls made (0 in lightweight
            mode; up to `alternate_boarding_max_candidates` in extended).
        disabled_reason:
          type: [string, "null"]
          enum: [null, "SCHEDULE_UNAVAILABLE", "REQUEST_INVALID", "NO_CANDIDATES_IN_WINDOW"]
        candidates:
          type: array
          items: { $ref: '#/components/schemas/AlternateBoardingCandidate' }

    CrossClassPlan:
      type: object
      description: |
        A single cross-class plan — a chain of hops where consecutive hops
        MAY be in different `class_code` values. The solver still prefers
        same-class and same-berth continuity; classes only switch when
        forced by a gap in the current class.
      properties:
        is_covered: { type: boolean }
        total_hops: { type: integer }
        physical_seat_changes: { type: integer }
        class_changes:
          type: integer
          description: Number of transitions where `class_code` differs from the previous hop.
        classes_used:
          type: array
          items: { type: string }
          description: Unique classes used, in order of first appearance.
        hops:
          type: array
          items: { $ref: '#/components/schemas/HoppingHop' }
        reason:
          type: [string, "null"]
          enum: [null, "NO_VACANT_BERTHS", "GAP_UNREACHABLE", "MAX_HOPS_EXCEEDED", "VIA_GAP_UNREACHABLE", "REQUIRES_FARE_DATA"]
        unreachable_from_stop_sequence: { type: [integer, "null"] }
        unreachable_from_station_code:  { type: [string, "null"] }
        total_fare_inr: { type: [integer, "null"] }
        ranking_reason: { type: string }

    CrossClassBlock:
      type: object
      description: |
        Response block for cross-class hopping. `enabled=false` when the
        caller did not opt in via `include_cross_class=true`. When enabled
        but no plan exists, `disabled_reason` is set and `plan=null`.
      properties:
        enabled: { type: boolean }
        disabled_reason:
          type: [string, "null"]
          enum: [null, "SCHEDULE_UNAVAILABLE", "REQUEST_INVALID", "NO_VACANT_BERTHS"]
        plan:
          oneOf:
            - $ref: '#/components/schemas/CrossClassPlan'
            - type: "null"

    VacantBerthsHoppingResponse:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name:   { type: string }
        from_station_code: { $ref: '#/components/schemas/StationCode' }
        to_station_code:   { $ref: '#/components/schemas/StationCode' }
        train_start_date:  { type: string, format: date }
        remote_station_code: { $ref: '#/components/schemas/StationCode' }
        next_remote_station_code: { type: [string, "null"] }
        chart_status: { $ref: '#/components/schemas/ChartStatus' }
        coach_summary: { type: array, items: { $ref: '#/components/schemas/ChartCoachSummary' }}
        requested_segment:
          type: object
          properties:
            from_stop_sequence: { type: [integer, "null"] }
            to_stop_sequence:   { type: [integer, "null"] }
            stops:
              type: array
              items:
                type: object
                properties:
                  sequence:      { type: integer }
                  station_code:  { $ref: '#/components/schemas/StationCode' }
                  station_name:  { type: string }
            via:
              type: array
              items: { $ref: '#/components/schemas/StationCode' }
        direct:
          type: object
          properties:
            available: { type: boolean, description: "True when at least one class has a berth covering from→to end-to-end." }
            options:
              type: array
              items: { $ref: '#/components/schemas/HoppingDirectOption' }
            all_berths:
              type: array
              items: { $ref: '#/components/schemas/VacantBerth' }
              description: "Every vacant berth returned by the upstream, regardless of segment coverage."
        hopping:
          type: object
          properties:
            disabled: { type: boolean, description: "True when the hopping algorithm could not run (see `disabled_reason`)." }
            disabled_reason:
              type: [string, "null"]
              enum: [null, "SCHEDULE_UNAVAILABLE", "REQUEST_INVALID"]
            options:
              type: array
              items: { $ref: '#/components/schemas/HoppingOption' }
        alternate_boarding:
          $ref: '#/components/schemas/AlternateBoardingBlock'
        cross_class:
          $ref: '#/components/schemas/CrossClassBlock'
        _meta:
          type: object
          properties:
            chart_type_used: { type: integer, enum: [1, 2] }
            classes_requested:
              type: array
              items: { type: string }
            partial_failures:
              type: array
              items:
                type: object
                properties:
                  class_code: { type: string }
                  reason:     { type: string }
            schedule_enrichment:       { type: string, enum: [success, failed, skipped] }
            coach_position_enrichment: { type: string, enum: [success, failed, skipped] }
            max_hops: { type: integer }
            algorithm_notes:
              type: array
              items: { type: string }
            preferences_applied:
              type: object
              description: |
                Echo of preference filters actually applied (post-normalisation
                + avoid-wins resolution).
              properties:
                prefer_berth: { type: array, items: { type: string } }
                avoid_berth:  { type: array, items: { type: string } }
                prefer_class: { type: array, items: { type: string } }
                avoid_class:  { type: array, items: { type: string } }
                classes_skipped:
                  type: array
                  items: { type: string }
                  description: |
                    Intersection of `avoid_class` with classes actually
                    present on the train — what was really dropped.

    # Envelope helpers — one per endpoint payload
    EnvelopeStation:
      allOf:
        - type: object
          properties:
            success: { type: boolean }
            data: { $ref: '#/components/schemas/Station' }
            meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeStationSearch:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            query: { type: string }
            limit: { type: integer }
            results: { type: array, items: { $ref: '#/components/schemas/Station' }}
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeTrain:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/Train' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeTrainSearch:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            query: { type: string }
            limit: { type: integer }
            results: { type: array, items: { $ref: '#/components/schemas/Train' }}
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeTrainSchedule:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/TrainSchedule' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeCoachPosition:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/CoachPosition' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeStationBoard:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/StationBoard' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeLiveTrainStatus:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/LiveTrainStatus' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeRouteTrains:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/RouteTrains' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeAvailability:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/AvailabilityResponse' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeAvailabilityBatch:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/AvailabilityBatchResponse' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeAvailabilityCalendar:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/AvailabilityCalendar' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeFareEstimate:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/FareEstimate' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeChartStatus:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/ChartStatusResponse' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeVacantBerths:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/VacantBerthsResponse' }
        meta: { $ref: '#/components/schemas/Meta' }
    EnvelopeVacantBerthsHopping:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/VacantBerthsHoppingResponse' }
        meta: { $ref: '#/components/schemas/Meta' }

    EnvelopeVacantBerthsRouteScan:
      type: object
      properties:
        success: { type: boolean }
        data: { $ref: '#/components/schemas/VacantBerthsRouteScanResponse' }
        meta: { $ref: '#/components/schemas/Meta' }

    ResolvedStation:
      type: object
      properties:
        station_code: { $ref: '#/components/schemas/StationCode' }
        station_name: { type: [string, "null"] }
        resolved_from:
          type: string
          enum: [code, name, fuzzy]
          description: |
            How the raw input matched:
            - `code` — exact upper-case station code.
            - `name` — exact case-insensitive name match, or a prefix hit
              above 0.95 confidence.
            - `fuzzy` — best hit above the 0.55 confidence threshold.
        input: { type: string, description: "Original caller input." }
        confidence: { type: number, minimum: 0, maximum: 1 }
        alternates:
          type: array
          description: |
            Alternative candidates the resolver considered above threshold.
            Populated for fuzzy matches to enable UI disambiguation.
          items:
            type: object
            properties:
              station_code: { $ref: '#/components/schemas/StationCode' }
              station_name: { type: string }
              confidence:   { type: number, minimum: 0, maximum: 1 }

    ClassSummary:
      type: object
      description: |
        Compact per-class projection of a train's scan result. Callers
        that only need to render "which classes look bookable?" can rely
        on this without walking the full `hopping_detail`.
      properties:
        class_code:              { $ref: '#/components/schemas/ClassCode' }
        direct_available:        { type: boolean }
        direct_berth_count:      { type: integer, minimum: 0 }
        hopping_covered:         { type: boolean }
        hopping_hops:            { type: [integer, "null"] }
        physical_seat_changes:   { type: [integer, "null"] }
        cheapest_fare_inr:       { type: [integer, "null"] }
        availability_summary_text:
          type: [string, "null"]
          description: "Availability text from routes-trains for this class (e.g. `AVAILABLE 12`, `WL 34`)."
        availability_summary_status:
          type: [string, "null"]
          description: "Availability status enum (`AVAILABLE`, `RAC`, `WAITLIST`, `REGRET`, `NOT_AVAILABLE`, ...)."

    ScoreBreakdown:
      type: object
      description: |
        Exposed so consumers can debug why a train ranks where it does, or
        apply their own weightings on top. Total is clipped to `[0, 100]`.
      properties:
        chart_ready_points:       { type: integer, description: "+30 for chart 2 prepared, +20 for chart 1 only, 0 otherwise." }
        direct_vacancy_points:    { type: integer, description: "+40 for direct vacancy in a preferred class, +30 for any class, 0 otherwise." }
        hopping_coverage_points:  { type: integer, description: "+25 preferred / +18 any / +10 cross-class — only when no direct vacancy." }
        seat_changes_penalty:     { type: integer, description: "-3 per physical seat change, capped at -12." }
        running_day_penalty:      { type: integer, description: "-10 when the train doesn't run on the requested day and `only_running=false` kept it." }
        alternate_boarding_bonus: { type: integer, description: "+5 when `include_alternate_boarding=true` returned at least one candidate." }
        no_coverage_penalty:      { type: integer, description: "-15 when no strategy covers the segment at all." }
        multi_class_direct_bonus: { type: integer, description: "+8 when direct vacancy exists in multiple travel classes." }
        berth_richness_bonus:     { type: integer, description: "+5 when the train has high berth diversity (many distinct berth types available)." }
        train_departed_penalty:   { type: integer, description: "-20 when the train has already departed from the boarding station." }
        total:                    { type: integer, minimum: 0, maximum: 100 }

    RouteScanMatch:
      type: object
      properties:
        train_number: { $ref: '#/components/schemas/TrainNumber' }
        train_name:   { type: string }
        train_type:
          type: string
          description: |
            Train type — one of the canonical enum values (see
            `#/components/schemas/RouteTrain.train_type`). Kept as plain
            string here for schema-tooling compatibility.
        departure_time: { type: string, description: "HH:mm at `from` station." }
        arrival_time:   { type: string, description: "HH:mm at `to` station." }
        duration_minutes: { type: integer }
        distance_km:      { type: [number, "null"] }
        running_days:     { type: array, items: { $ref: '#/components/schemas/RunningDay' } }
        runs_on_date:     { type: boolean }
        chart_status:
          oneOf:
            - $ref: '#/components/schemas/ChartStatus'
            - type: "null"
          description: "Null when this train wasn't scanned (SKIPPED_*)."
        chart_prepared: { type: boolean }
        recommendation:
          type: string
          description: |
            Human-readable one-liner suitable for direct UI display.
            Examples:
            - `Direct berth available in SL and 3A`
            - `Hopping plan available in 3A — 1 seat change needed`
            - `Chart not prepared yet. Vacancy data unavailable until charting.`
        urgency_hint:
          type: [string, "null"]
          description: |
            Time-sensitive nudge, or `null`. Examples:
            - `Chart 2 prepared. Vacancy is authoritative — book immediately.`
            - `Chart 1 prepared. Chart 2 will refresh vacancy 30 min before departure.`
        score: { type: integer, minimum: 0, maximum: 100 }
        score_breakdown: { $ref: '#/components/schemas/ScoreBreakdown' }
        classes_summary:
          type: array
          items: { $ref: '#/components/schemas/ClassSummary' }
        hopping_detail:
          oneOf:
            - $ref: '#/components/schemas/VacantBerthsHoppingResponse'
            - type: "null"
          description: "Full hopping response. `null` when `include_detail=false` OR when the train was skipped."
        scan_status:
          type: string
          enum:
            - OK
            - CHART_NOT_PREPARED
            - UPSTREAM_FAILED
            - SKIPPED_NOT_RUNNING
            - SKIPPED_TRAIN_TYPE
            - SKIPPED_CAP_EXCEEDED
            - SKIPPED_SEGMENT_INVALID
        scan_status_reason:
          type: [string, "null"]
          description: "Human-readable explanation for a non-OK scan_status."

    VacantBerthsRouteScanResponse:
      type: object
      properties:
        from: { $ref: '#/components/schemas/ResolvedStation' }
        to:   { $ref: '#/components/schemas/ResolvedStation' }
        journey_date: { type: string, format: date }
        quota: { $ref: '#/components/schemas/Quota' }
        route_summary:
          type: object
          description: |
            Top-line summary for consumers who don't want to loop through
            `matches[]`. Every count is derived from `matches[]` after all
            filters have been applied.
          properties:
            total_trains_on_route:        { type: integer, minimum: 0 }
            trains_running_on_date:       { type: integer, minimum: 0 }
            trains_scanned:               { type: integer, minimum: 0 }
            trains_with_direct_vacancy:   { type: integer, minimum: 0 }
            trains_with_hopping_coverage: { type: integer, minimum: 0 }
            trains_with_no_coverage:      { type: integer, minimum: 0 }
            trains_with_chart_prepared:   { type: integer, minimum: 0 }
            best_train_number:            { type: [string, "null"] }
            best_recommendation:          { type: [string, "null"] }
            best_score:                   { type: integer, minimum: 0, maximum: 100 }
        matches:
          type: array
          items: { $ref: '#/components/schemas/RouteScanMatch' }
        _meta:
          type: object
          properties:
            routes_trains_confidence: { type: string, enum: [high, medium, low] }
            partial_failures:
              type: array
              description: "Per-train failures. The overall request is never failed because one train's chart fetch died."
              items:
                type: object
                properties:
                  train_number: { $ref: '#/components/schemas/TrainNumber' }
                  reason:       { type: string }
            trains_dispatched: { type: integer, minimum: 0, description: "How many trains were actually fanned out (excludes SKIPPED_*)." }
            cap_applied:
              oneOf:
                - type: "null"
                - type: object
                  properties:
                    trains_available: { type: integer }
                    trains_scanned:   { type: integer }
                    reason:           { type: string, enum: [MAX_TRAINS_CAP] }
            trains_dropped_by_type_filter:    { type: integer, minimum: 0 }
            trains_dropped_by_running_filter: { type: integer, minimum: 0 }
            trains_dropped_by_chart_filter:   { type: integer, minimum: 0 }
            trains_dropped_by_score_filter:   { type: integer, minimum: 0 }
            algorithm_notes:
              type: array
              items: { type: string }
              description: "Free-form audit trail of algorithmic decisions (station resolution, drops, cap, sort)."
            upstream_failure_summary:
              type: object
              nullable: true
              description: "Summary of upstream failures during the route-scan. Null when all calls succeeded."
              properties:
                total_failures: { type: integer, description: "Total number of failed upstream calls." }
                timeout_count:  { type: integer, description: "Number of calls that timed out." }
                error_count:    { type: integer, description: "Number of calls that returned non-timeout errors." }
            preferences_applied:
              type: object
              properties:
                prefer_berth:      { type: array, items: { type: string } }
                avoid_berth:       { type: array, items: { type: string } }
                prefer_class:      { type: array, items: { $ref: '#/components/schemas/ClassCode' } }
                avoid_class:       { type: array, items: { $ref: '#/components/schemas/ClassCode' } }
                train_types:       { type: array, items: { type: string } }
                avoid_train_types: { type: array, items: { type: string } }
            sort_by:                    { type: string, enum: [departure, departure_desc, duration, score, chart_ready] }
            only_running:               { type: boolean }
            only_chart_prepared:        { type: boolean }
            include_detail:             { type: boolean }
            include_fare:               { type: boolean }
            include_alternate_boarding: { type: boolean }
            include_cross_class:        { type: boolean }
            strategies:                 { type: array, items: { type: string, enum: [min_hops, min_seat_changes, cheapest] } }

tags:
  - name: Stations
    description: Reference + live station data
  - name: Trains
    description: Train reference, schedule, coach position, live tracking
  - name: Live
    description: Live / real-time endpoints (no cache)
  - name: Journeys
    description: Trains between two stations
  - name: Availability
    description: Seat availability
  - name: Fares
    description: Fare calculation
  - name: Charts
    description: Post-chart preparation status and vacant berths
  - name: Jobs
    description: Asynchronous premium analysis lifecycle
