# API reference

Base URL: `http://127.0.0.1:8000/api` in dev. All responses are JSON. Authenticated endpoints expect a bearer token in `Authorization: Bearer <token>` obtained from `/api/auth/verify-otp`.

## Public (no auth)

### `GET /api/config`
Returns admin-managed config keys flagged `is_public`. Used by the Flutter app to apply branding (colors, fonts, logo path) before sign-in.

```json
{
  "config": {
    "site_name": "AGS Vocab",
    "primary_color": "#960000",
    "primary_font": "Raleway",
    "logo_path": "brand/logo.png",
    "default_accent_code": "en-US",
    "show_singlish": true,
    "feature_lives_enabled": true,
    ...
  }
}
```

### `GET /api/voices`
Returns the reader voice rotation pool, scoped to the admin's school accent.

```json
{
  "voices": [
    {
      "id": 1,
      "code": "asha_us",
      "name": "Asha",
      "accent_code": "en-US",
      "accent_label": "American",
      "gender": "female",
      "character": "girl",
      "voice_hints": ["jenny", "aria", "samantha", "google us english", "zira", "female"],
      "pitch": 1.25,
      "rate": 0.95,
      "letter": "A",
      "placard_color": "#88C808",
      "mood": "happy",
      "intro": "reading with you"
    },
    ...
  ],
  "active_accent_code": "en-US"
}
```

### `POST /api/auth/request-otp`
Generates and dispatches an OTP for the supplied contact.

Body:
```json
{ "contact": "pamelaliusm@gmail.com" }
```

Response:
```json
{
  "message": "OTP sent.",
  "channel": "email",
  "hint": "p*********m@gmail.com",
  "expires_in_seconds": 600,
  "dev_code": "123456"      // ONLY in non-production environments
}
```

Contact can be an email or a phone with `+` country code (regex `^\+?[0-9]{8,15}$`).

### `POST /api/auth/verify-otp`
Verifies the OTP and returns a Sanctum bearer token.

Body:
```json
{ "contact": "pamelaliusm@gmail.com", "otp_code": "123456" }
```

Response:
```json
{
  "token": "1|xxxxxxxxxxxxxxxxxx",
  "user": { "id": 3, "name": "Pamela Lim", "email": "pamelaliusm@gmail.com", "phone": null, "role": "admin" },
  "created": false,
  "lives": { "lives": 5, "max_lives": 5, "is_unlimited": true, "next_regen_in_seconds": null },
  "kudos": { "awarded_this_attempt": null, "user_total": 0 }
}
```

If the user doesn't exist, they're created with `role=user`. Non-admin accounts can't sign in to the admin panel; verification at `/admin/login` rejects them.

---

## Authenticated (bearer token)

### `GET /api/auth/me`
Current user + their lives/kudos snapshot.

### `POST /api/auth/logout`
Revokes the current bearer token.

### `GET /api/test-types`
The 3 test types plus per-user eligibility and the full scope picker payload.

```json
{
  "test_types": [
    {
      "id": 1, "code": "vocab_diagnostic", "name": "Vocab Diagnostic",
      "tagline": "Find your Vocabile score",
      "description": "...",
      "icon": "target",
      "color_hex": "#960000",
      "requires_premium": false,
      "updates_canonical_score": true,
      "is_eligible": true,
      "lock_reason": null,
      "cooldown_until": null
    },
    { "id": 2, "code": "skill_practice", ... },
    { "id": 3, "code": "vocab_path", "requires_premium": true, "is_eligible": false, "lock_reason": "premium_required", ... }
  ],
  "scopes": {
    "skills": [{ "id": 1, "code": "recognition", "name": "Recognition", "mode": "receptive", "color_hex": "#3BA9F4" }, ...],
    "pos_categories": [{ "id": 1, "code": "noun", "name": "Noun", "color_hex": "#BF9237" }, ...],
    "vocabile_levels": [{ "id": 1, "code": "K", "name": "Kindergarten", "short_name": "K", "color_hex": "#FBD3D3" }, ...],
    "genres": [{ "id": 1, "code": "everyday", "name": "Everyday Conversation", "color_hex": "#88C808" }, ...]
  }
}
```

Lock reasons:
- `cooldown` — user just finished a Diagnostic, has to wait (cooldown_until ISO timestamp included)
- `premium_required` — Vocab Path is premium-only; `is_unlimited_lives` proxies as premium

### `POST /api/tests`
Start a new test session.

Body:
```json
{
  "test_type": "vocab_diagnostic",   // or "skill_practice" or "vocab_path"
  "scope_kind": "genre",             // for skill_practice only: "skill" | "pos" | "level" | "genre"
  "scope_id": 9                      // for skill_practice only
}
```

Response (`201 Created`):
```json
{
  "session": { "id": 12, "status": "in_progress", "theta": 0, "theta_se": 1, "items_administered": 0, "items_correct": 0, "min_items": 15, "max_items": 40, "vocabile_score": 650, "level": { "code": "G6", "name": "Grade 6", ... }, "started_at": "..." },
  "next_question": { ... see below ... },
  "lives": { ... },
  "kudos": { ... }
}
```

Validation errors (422):
- `test_type` ∉ {vocab_diagnostic, skill_practice, vocab_path}
- `scope_kind` missing or invalid for skill_practice
- Eligibility failure (cooldown / premium_required / scope has no questions)

### `GET /api/tests/{session}`
Session state (without picking a new question).

### `GET /api/tests/{session}/next`
The next question for this session (server-side IRT selector chooses it).

### `POST /api/tests/{session}/answer`
Body:
```json
{ "question_id": 47, "option_id": 188, "response_time_ms": 1240 }
```

Response:
```json
{
  "response": {
    "id": 451, "is_correct": true,
    "theta_after": 0.42, "theta_se_after": 0.81,
    "sequence_no": 4,
    "kudos_awarded": 3, "life_deducted": false
  },
  "session": { ... updated counters, theta, vocabile_score, level ... },
  "next_question": { ... or null if the session just finished ... },
  "lives": { ... },
  "kudos": { ... }
}
```

The `next_question` payload looks like:
```json
{
  "id": 47, "stem": "Which word means closest to 'X'?", "format": "synonym",
  "word": {
    "lemma": "ephemeral",
    "pos_categories": [{ "code": "adj", "name": "Adjective", "color_hex": "#3BA9F4", "is_primary": true }],
    "vocabile_levels": [{ "code": "G12", "name": "Grade 12", "short_name": "G12", "color_hex": "#BF9237", "is_primary": true }],
    "difficulty_level": { "code": "sophisticated", "name": "Sophisticated", "color_hex": "#E57373" }
  },
  "bloom_level": { "code": "remember", "name": "Remember", "level_number": 1, "color_hex": "#88C808" },
  "skill": { "code": "recognition", "name": "Recognition", "mode": "receptive", "color_hex": "#3BA9F4" },
  "options": [
    { "id": 188, "position": 0, "label": "fleeting" },
    { "id": 189, "position": 1, "label": "eternal" },
    ...
  ]
}
```

### `GET /api/tests/{session}/results`
Final results for a completed session. 409 if the session isn't finished.

### `POST /api/pronunciations`
Submit a pronunciation attempt (audio + transcribed text).

Body (multipart): `question_id?`, `word_id`, `transcribed_text`, `confidence?`, `duration_ms?`, `audio?` (m4a/mp3/wav/webm/ogg/aac, max 5 MB).

Grades by normalised substring match between `transcribed_text` and the target lemma.

### `GET /api/lives`
Lives snapshot.

### `POST /api/lives/purchase`
Body: `{ "pack": "single" | "refill" | "bundle" }`. In dev, credits immediately. In prod, returns 501 until Stripe/RevenueCat is wired up.

### `GET /api/kudos`
Kudos snapshot.

### `GET /api/kudos/history`
The last 100 kudo_events for the current user.

---

## Error shapes

Laravel-standard. Validation errors:
```json
{ "message": "The given data was invalid.", "errors": { "scope_kind": ["The scope kind field is required..."] } }
```

Auth failures: `401`. Forbidden: `403`. Not found: `404`. Conflict (e.g. answering a question twice in the same session): `409`. Server error: `500`.
