openapi: 3.1.0
info:
  title: GECO — Climbing Results System API
  version: "2.0"
  description: |
    API REST + SSE per gestione punteggi competizioni di arrampicata sportiva.
    Supporta Boulder, Lead, Speed, Paraclimbing.

    ## Autenticazione
    Tutte le richieste richiedono un API key via header `Authorization: Bearer {api_key}`.
    Le chiavi hanno scopes che limitano l'accesso: `read:public`, `read:live`, `write:scores`, `timer`, `sync`, `admin`.

    ## Formato Risposte
    Tutte le risposte sono avvolte in un envelope:
    ```json
    { "data": {...}, "meta": {"timestamp": "ISO8601", "version": "2.0"} }
    ```
    Errori:
    ```json
    { "error": {"code": "NOT_FOUND", "message": "..."}, "meta": {...} }
    ```

    ## SSE Streaming
    5 canali Server-Sent Events per dati real-time:
    - `/stream/rounds/{id}` — ranking + ascent + timer (FasiResults, pubblico)
    - `/stream/rounds/{id}/judge` — multi-giudice coordinato
    - `/stream/competitions/{id}` — panoramica admin
    - `/stream/timer/{id}` — solo cronometro
    - `/stream/sync/{id}` — sync LocalKit

    Usa `Last-Event-ID` per riconnessione senza perdita dati.
  contact:
    name: GECO Dev
    url: https://geco.arkenu.dev
servers:
  - url: https://geco.arkenu.dev/api/v2
    description: Staging

security:
  - BearerAuth: []

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: "API key GECO (formato: geco_xxxxx)"

  schemas:
    Meta:
      type: object
      properties:
        timestamp: { type: string, format: date-time }
        version: { type: string, example: "2.0" }

    Envelope:
      type: object
      properties:
        data: {}
        meta: { $ref: "#/components/schemas/Meta" }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: NOT_FOUND }
            message: { type: string }
        meta: { $ref: "#/components/schemas/Meta" }

    AthleteCompact:
      type: object
      properties:
        id: { type: integer }
        first_name: { type: string }
        last_name: { type: string }
        club: { type: string, description: "Abbreviazione societa'" }
        club_id: { type: integer }
        bib: { type: string, nullable: true }

    RankingEntry:
      type: object
      properties:
        rank: { type: integer, nullable: true }
        athlete: { $ref: "#/components/schemas/AthleteCompact" }
        score: { type: string, nullable: true, description: "Punteggio totale" }
        start_order: { type: integer, nullable: true }
        tops: { type: integer, description: "Solo boulder" }
        zones: { type: integer, description: "Solo boulder" }
        best_time_ms: { type: integer, description: "Solo speed" }
        high_score: { type: string, description: "Solo lead" }
        ascents:
          type: array
          items:
            type: object
            properties:
              problem_id: { type: integer }
              problem_name: { type: string }
              top: { type: boolean }
              top_tries: { type: integer }
              zone: { type: boolean }
              zone_tries: { type: integer }
              points: { type: number }
              score: { type: string }
              time_ms: { type: integer }
              status: { type: string }

paths:
  /:
    get:
      summary: Discovery
      tags: [Discovery]
      responses:
        "200":
          description: Info servizio, endpoints, capabilities

  /seasons:
    get:
      summary: Lista stagioni
      tags: [Stagioni]
      parameters:
        - { name: page, in: query, schema: { type: integer } }
      responses:
        "200":
          description: Lista paginata

  /seasons/current:
    get:
      summary: Stagione corrente
      tags: [Stagioni]
      responses:
        "200":
          description: Stagione con leghe, coppe, competizioni

  /seasons/{id}:
    get:
      summary: Dettaglio stagione
      tags: [Stagioni]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Stagione con leghe e competizioni

  /leagues/{id}:
    get:
      summary: Dettaglio lega
      tags: [Leghe]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Lega con coppe e categorie

  /cups/{id}:
    get:
      summary: Dettaglio coppa
      tags: [Coppe]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Coppa con categorie e discipline

  /cups/{id}/standings/{dcatId}:
    get:
      summary: Classifica coppa per categoria
      tags: [Coppe]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: dcatId, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Classifica aggregata da tutti gli eventi

  /cups/{id}/team-standings/{discipline}:
    get:
      summary: Classifica a squadre coppa
      tags: [Coppe]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: discipline, in: path, required: true, schema: { type: string, enum: [boulder, lead, speed] } }
      responses:
        "200":
          description: Classifica societa' per disciplina

  /competitions:
    get:
      summary: Lista competizioni
      tags: [Competizioni]
      parameters:
        - { name: search, in: query, schema: { type: string } }
        - { name: season_id, in: query, schema: { type: integer } }
        - { name: discipline, in: query, schema: { type: string } }
        - { name: per_page, in: query, schema: { type: integer, default: 25 } }
      responses:
        "200":
          description: Lista paginata con filtri

  /competitions/{id}:
    get:
      summary: Dettaglio competizione
      tags: [Competizioni]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Competizione con categorie, round, discipline

  /competitions/{id}/registrations:
    get:
      summary: Iscrizioni competizione
      tags: [Competizioni]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: dcat_id, in: query, schema: { type: integer } }
      responses:
        "200":
          description: Atleti iscritti per categoria

  /competitions/{id}/results/{dcatId}:
    get:
      summary: Risultati per categoria
      tags: [Competizioni]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: dcatId, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Ranking con dettaglio per round

  /competitions/{id}/team-results:
    get:
      summary: Classifica a squadre
      tags: [Competizioni]
      responses:
        "200":
          description: Ranking per societa' per disciplina

  /rounds/{id}:
    get:
      summary: Dettaglio round
      tags: [Round]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Round con problemi e configurazione scoring

  /rounds/{id}/ranking:
    get:
      summary: Classifica live round
      description: |
        Endpoint critico per FasiResults e pubblico.
        Supporta ETag/304 per polling efficiente.
      tags: [Round]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: If-None-Match, in: header, schema: { type: string } }
      responses:
        "200":
          description: Classifica con ascent per atleta
          headers:
            ETag:
              schema: { type: string }
              description: "Weak ETag per caching"
        "304":
          description: Not Modified (dati invariati)

  /rounds/{id}/startlist:
    get:
      summary: Startlist round
      tags: [Round]
      responses:
        "200":
          description: Ordine di partenza

  /rounds/{id}/problems:
    get:
      summary: Problemi/vie del round
      tags: [Round]
      responses:
        "200":
          description: Lista problemi con starting group

  /rounds/{id}/problems/{problemId}/results:
    get:
      summary: Risultati singolo problema
      tags: [Round]
      responses:
        "200":
          description: Ranking su un singolo problema

  /rounds/{id}/speed-bracket:
    get:
      summary: Bracket eliminatorio speed
      tags: [Round]
      responses:
        "200":
          description: Stages, heats, atleti con tempi e vincitori

  /rounds/{id}/score:
    post:
      summary: Inserisci punteggio
      tags: [Scoring]
      security:
        - BearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [athlete_id, data]
              properties:
                athlete_id: { type: integer }
                problem_id: { type: integer, description: "Per boulder/lead/speed qualifica" }
                heat_id: { type: integer, description: "Per speed bracket (eliminazione)" }
                data:
                  type: object
                  description: "Campi disciplina-specifici"
                  properties:
                    top: { type: boolean }
                    top_tries: { type: integer }
                    zone: { type: boolean }
                    zone_tries: { type: integer }
                    score: { type: string }
                    plus: { type: boolean }
                    time_ms: { type: integer }
                    dnf: { type: boolean }
                    dns: { type: boolean }
            examples:
              boulder:
                value: { athlete_id: 42, problem_id: 501, data: { top: true, top_tries: 2, zone: true, zone_tries: 1 } }
              lead:
                value: { athlete_id: 42, problem_id: 601, data: { score: "35", plus: true } }
              speed:
                value: { athlete_id: 42, problem_id: 701, data: { time_ms: 5813, dnf: false, dns: false } }
      responses:
        "201":
          description: Punteggio registrato
        "422":
          description: Errore validazione

  /athletes:
    get:
      summary: Ricerca atleti
      tags: [Atleti]
      parameters:
        - { name: search, in: query, schema: { type: string } }
        - { name: gender, in: query, schema: { type: string, enum: [male, female] } }
      responses:
        "200":
          description: Lista paginata

  /athletes/{id}:
    get:
      summary: Profilo atleta
      tags: [Atleti]
      responses:
        "200":
          description: Atleta con podiums e dettagli

  /athletes/{id}/results:
    get:
      summary: Storico risultati atleta
      tags: [Atleti]
      parameters:
        - { name: discipline, in: query, schema: { type: string } }
        - { name: season_id, in: query, schema: { type: integer } }
      responses:
        "200":
          description: Risultati paginati

  /clubs:
    get:
      summary: Lista societa'
      tags: [Societa']
      parameters:
        - { name: search, in: query, schema: { type: string } }
      responses:
        "200":
          description: Lista paginata con conteggio atleti

  /clubs/{id}:
    get:
      summary: Dettaglio societa'
      tags: [Societa']
      responses:
        "200":
          description: Societa' con conteggio atleti

  /timer/data:
    post:
      summary: Ricezione dati cronometro dal bridge
      tags: [Timer]
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_id, lane, status, type]
              properties:
                event_id: { type: integer }
                lane: { type: string, enum: [A, B, master] }
                time_ms: { type: integer }
                display: { type: string }
                status: { type: string, enum: [idle, running, stopped, false_start] }
                type: { type: string, enum: [time, false_start, reaction_time, countdown] }
                reaction_time_ms: { type: integer }
                source: { type: string }
                raw: { type: string }
      responses:
        "200":
          description: Dato ricevuto

  /timer/status:
    post:
      summary: Heartbeat bridge cronometro
      tags: [Timer]
      responses:
        "200":
          description: Acknowledged

  /timer/state:
    get:
      summary: Stato corrente timer (polling)
      tags: [Timer]
      parameters:
        - { name: event_id, in: query, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Stato lane, semaforo, bridge

  /sync/handshake:
    post:
      summary: Handshake sync LocalKit
      tags: [Sync]
      responses:
        "200":
          description: Versione, capabilities, clock offset

  /sync/push:
    post:
      summary: Push journal entries dal remoto
      tags: [Sync]
      responses:
        "201":
          description: Entries applicate

  /sync/pull:
    get:
      summary: Pull entries sequence-based
      tags: [Sync]
      parameters:
        - { name: since_sequence, in: query, schema: { type: integer } }
        - { name: event_id, in: query, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, default: 100 } }
      responses:
        "200":
          description: Entries con sequence

  /sync/status:
    get:
      summary: Stato sincronizzazione
      tags: [Sync]
      responses:
        "200":
          description: Contatori pending, local, remote

  /stream/rounds/{id}:
    get:
      summary: "SSE: Ranking + scoring + timer live"
      tags: [SSE Streaming]
      description: |
        Server-Sent Events stream. Invia eventi: ascent, ranking, round_status, speed_heat, timer.
        Usa `Last-Event-ID: seq-N` per riprendere dopo disconnessione.

        ## Payload events
        - **AscentScored**: `{ascent_id, route_id, athlete_id, bib, top, top_tries, zone, zone_tries, points, score, time_ms, status}`
        - **RankingUpdated**: `{ranking: [{athlete_id, bib, rank, total_points, tops, zones}, ...]}`
        - **SpeedHeatUpdated**: `{heat_id, athlete_id, bib, lane, time_ms, winner, dnf, dns}`
        - **RoundStatusChanged**: `{status, finished_at?}`
        - **TimerData/TimerStatus**: vedi `/timer/state`

        Il campo `bib` (string|null) e' parte di tutti gli scoring events.
        Quando un bib viene modificato in admin, l'ETag del ranking cambia (vedi `/rounds/{id}/ranking`).
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Event stream
          content:
            text/event-stream: {}

  /stream/rounds/{id}/judge:
    get:
      summary: "SSE: Stream giudice"
      tags: [SSE Streaming]
      responses:
        "200":
          description: Event stream (ascent, timer, round_status)

  /stream/competitions/{id}:
    get:
      summary: "SSE: Panoramica competizione"
      tags: [SSE Streaming]
      responses:
        "200":
          description: Event stream (round_status, advancement)

  /stream/timer/{id}:
    get:
      summary: "SSE: Solo cronometro"
      tags: [SSE Streaming]
      responses:
        "200":
          description: Event stream (timer, false_start, semaphore)

  /stream/sync/{id}:
    get:
      summary: "SSE: Sync LocalKit"
      tags: [SSE Streaming]
      responses:
        "200":
          description: Event stream (tutti gli eventi journal)
