Skill: brianleach/cta (version 1.0.0)

--- name: cta description: Chicago CTA transit — real-time L train arrivals, bus predictions, service alerts, and route info. Use when the user asks about Chicago public transit, L train schedules, CTA bus times, service alerts, or nearby stops. homepage: "https://github.com/brianleach/cta-skill" license: MIT metadata: clawhub: emoji: "🚇" tags: [transit, chicago, cta, transportation, bus, train, l-train, schedule] requires: bins: ["node", "unzip"] env: ["CTA_TRAIN_API_KEY", "CTA_BUS_API_KEY"] files: ["scripts/cta.mjs"] install: - id: npm-deps kind: shell command: "npm install --prefix $SKILL_DIR" label: "Install Node.js dependencies" --- # CTA Chicago Transit Real-time Chicago CTA transit data — L train arrivals, bus predictions, service alerts, and route information. Requires free API keys for train and bus data; alerts work without any keys. ## When to Use - User asks about CTA, L train, Blue Line, Red Line, Brown Line, or any Chicago rail line - User asks about Chicago bus routes, bus tracker, or train tracker - User asks "when is the next train" or "is the Red Line running" in a Chicago context - User mentions specific CTA stations (O'Hare, Midway, Clark/Lake, Belmont, etc.) - User asks about CTA fares, Ventra, or how to ride Chicago transit - User asks about CTA service alerts, delays, or detours - User asks about nearby stops or route information in Chicago ## Data Sources CTA uses 3 proprietary REST APIs. Train Tracker and Bus Tracker require free API keys. Customer Alerts is open access. ### Train Tracker API (requires `CTA_TRAIN_API_KEY`) Get a free key at: https://www.transitchicago.com/developers/traintrackerapply/ | Endpoint | Description | |----------|-------------| | Arrivals by station (`mapid`) | Train arrivals at a parent station | | Arrivals by stop (`stpid`) | Train arrivals at a directional stop | | Train positions (`rt`) | Live positions of all trains on a line | | Follow a run (`runnumber`) | Track a specific train run | Data refreshes approximately once per minute. Daily limit: 50,000 requests per key. ### Bus Tracker API v2 (requires `CTA_BUS_API_KEY`) Get a free key at: https://www.transitchicago.com/developers/bustracker/ | Endpoint | Description | |----------|-------------| | Predictions by stop | Bus arrival predictions at a stop | | Predictions by vehicle | Predictions for a specific bus | | Vehicle locations | Live bus positions on a route | | Routes list | All active bus routes | | Route directions | Available directions for a route | | Stops for route/direction | All stops along a route | Bus data updates approximately every 30 seconds. ### Customer Alerts API (NO key required) | Endpoint | Description | |----------|-------------| | Route statuses | Current status of all routes | | All alerts | All active service alerts | | Alerts by route | Alerts filtered by route | | Alerts by station | Alerts filtered by station | ### GTFS Static Feed | Feed | Format | URL | |------|--------|-----| | GTFS Static (zip) | ZIP | `https://www.transitchicago.com/downloads/sch_data/google_transit.zip` | Used for stop names, route info, and schedule lookups. Same stop IDs used across all APIs. ## Implementation ### Quick Start: Use the helper scripts The scripts in this skill's `scripts/` directory handle fetching, parsing, and presenting CTA data. ### Script: `scripts/cta.mjs` Main entry point. Supports these commands: ```bash # L train arrivals node scripts/cta.mjs arrivals --station "Clark/Lake" node scripts/cta.mjs arrivals --mapid 40380 node scripts/cta.mjs arrivals --stop 30070 node scripts/cta.mjs arrivals --stop-search "ohare" node scripts/cta.mjs arrivals --stop-search "belmont" --route Red # Bus predictions node scripts/cta.mjs bus-arrivals --stop 456 node scripts/cta.mjs bus-arrivals --stop 456 --route 22 node scripts/cta.mjs bus-arrivals --stop-search "michigan" # Vehicle tracking node scripts/cta.mjs vehicles --route Red node scripts/cta.mjs bus-vehicles --route 22 # Service alerts node scripts/cta.mjs alerts node scripts/cta.mjs alerts --route Red # Routes and stops node scripts/cta.mjs routes node scripts/cta.mjs bus-routes node scripts/cta.mjs stops --search "belmont" node scripts/cta.mjs stops --near 41.8781,-87.6298 --radius 0.3 node scripts/cta.mjs route-info --route Red node scripts/cta.mjs route-info --route 22 # Maintenance node scripts/cta.mjs refresh-gtfs ``` ### Setup: API Keys Train and bus commands require free API keys: 1. **Train Tracker key:** Apply at https://www.transitchicago.com/developers/traintrackerapply/ 2. **Bus Tracker key:** Apply at https://www.transitchicago.com/developers/bustracker/ 3. Set environment variables: `CTA_TRAIN_API_KEY` and `CTA_BUS_API_KEY` Alert commands work without any keys. ### Setup: GTFS Static Data On first use, run `node scripts/cta.mjs refresh-gtfs` to download and extract the static GTFS data (routes, stops, schedules) to `~/.cta/gtfs/`. This only needs to be refreshed when CTA updates their schedule. ### L Train Lines Reference | Route Code | Line | Terminals | |-----------|------|-----------| | Red | Red Line | Howard ↔ 95th/Dan Ryan | | Blue | Blue Line | O'Hare ↔ Forest Park | | Brn | Brown Line | Kimball ↔ Loop | | G | Green Line | Harlem/Lake ↔ Ashland/63rd or Cottage Grove | | Org | Orange Line | Midway ↔ Loop | | P | Purple Line | Linden ↔ Howard (Express to Loop weekdays) | | Pink | Pink Line | 54th/Cermak ↔ Loop | | Y | Yellow Line | Dempster-Skokie ↔ Howard | ### Key Bus Routes Reference | Route | Name | Notes | |-------|------|-------| | 22 | Clark | Major north-south corridor | | 36 | Broadway | North Side lakefront | | 77 | Belmont | Major east-west crosstown | | 151 | Sheridan | Lakefront express | | 146 | Inner Drive/Michigan Express | Loop to north lakefront | | 8 | Halsted | Long north-south route | | 9 | Ashland | Major north-south corridor | | 49 | Western | Longest route in system | | 66 | Chicago | Major east-west route | | 79 | 79th | Major south side east-west | ### CTA Fares Reference (2026) | Fare Type | Price | |-----------|-------| | Regular (Ventra/contactless) | $2.50 | | Bus transfer | $0.25 | | Rail-to-rail transfer | Free within 2 hours | | Reduced fare | $1.25 | | 1-Day Pass | $5.00 | | 3-Day Pass | $15.00 | | 7-Day Pass | $20.00 | | 30-Day Pass | $75.00 | Payment via Ventra card, Ventra app, or contactless bank card. Transfers valid for 2 hours. ### Tips for Users - **Station IDs** are in the 4xxxx range (parent stations); stop IDs in the 3xxxx range (directional) - Use `--station` or `--stop-search` for name-based lookups; use `--mapid` for exact station IDs - **Alerts** always work — no API key needed — so check alerts first if something seems wrong - Train data refreshes ~1 minute; bus data refreshes ~30 seconds - For the Loop, trains may show as arriving from different directions depending on the line ### Error Handling - If `CTA_TRAIN_API_KEY` is not set, train commands print a helpful message with the signup URL - If `CTA_BUS_API_KEY` is not set, bus commands print a helpful message with the signup URL - Alert commands always work (no key required) - Invalid station/stop searches show "No matching station found" with suggestions - Network errors and API error responses produce friendly messages ### Response Formatting When presenting transit info to the user: - Lead with the most actionable info (next arrival time, active alerts) - Show times in 12-hour format with AM/PM - Include line color AND destination (e.g., "Red Line toward 95th/Dan Ryan") - For trains: show "Due" for imminent arrivals, minutes for upcoming - For bus predictions, show estimated minutes until arrival - If there are active service alerts for the route being queried, always mention them ## External Endpoints | Endpoint | Data Sent | Data Received | |----------|-----------|---------------| | `https://lapi.transitchicago.com/api/1.0/ttarrivals.aspx` | API key (query param, HTTPS) | Train arrivals (JSON) | | `https://lapi.transitchicago.com/api/1.0/ttpositions.aspx` | API key (query param, HTTPS) | Train positions (JSON) | | `https://lapi.transitchicago.com/api/1.0/ttfollow.aspx` | API key (query param, HTTPS) | Train run details (JSON) | | `https://www.ctabustracker.com/bustime/api/v2/*` | API key (query param, HTTPS) | Bus predictions/positions (JSON) | | `https://www.transitchicago.com/api/1.0/routes.aspx` | None (GET only) | Route statuses (JSON) | | `https://www.transitchicago.com/api/1.0/alerts.aspx` | None (GET only) | Service alerts (JSON) | | `https://www.transitchicago.com/downloads/sch_data/google_transit.zip` | None (GET only) | GTFS static data (ZIP) | All API calls use HTTPS. API keys are passed as query parameters to CTA's official APIs. No other user data is transmitted. ## Security & Privacy - **API keys required** — Train and Bus Tracker APIs require free developer keys passed as URL query parameters - **No user data transmitted** — requests contain only API keys and route/stop identifiers, no personal information - **Local storage only** — GTFS static data is cached locally at `~/.cta/gtfs/`; no data is written elsewhere - **No telemetry** — this skill does not phone home or collect usage data - **Input handling** — stop names and route IDs from user input are used only for local filtering, never interpolated into shell commands ## Trust Statement This skill reads publicly available transit data from CTA's official APIs. API keys are used only for CTA API authentication. The skill does not access, store, or transmit any personal information beyond the API keys configured by the user.

path: CLAUDE.md
content: # CTA Skill — Development Guide

## Architecture

Single-file Node.js CLI (`scripts/cta.mjs`) with zero npm dependencies. Uses only Node.js built-in modules (`node:util`, `node:fs`, `node:path`, `node:os`, `node:child_process`) and the built-in `fetch` API (Node 18+).

## Project Structure

```
scripts/cta.mjs    Main entry point — CLI, API calls, data parsing, output formatting
SKILL.md           OpenClaw skill manifest (read by the agent runtime)
.env.example       Template for API keys
```

## Key Design Decisions

- **Zero dependencies**: Keeps the skill lightweight and avoids supply chain risk. CSV parsing, .env loading, and timestamp parsing are all hand-rolled.
- **Central Time handling**: CTA APIs return timestamps in Central Time with no timezone suffix. We store these in a Date's UTC slots so `getUTCHours()`/`getUTCMinutes()` returns the Central Time values directly. See `parseCTATimestamp()`.
- **Embedded station data**: The `STATIONS` array provides fuzzy matching for ~50 key stations without requiring GTFS data. GTFS extends this when available.
- **GTFS cached locally**: Static schedule data downloads to `~/.cta/gtfs/` and is read on demand. Not bundled with the skill.

## Running

```bash
node scripts/cta.mjs --help              # Show all commands
node scripts/cta.mjs arrivals --station "Clark/Lake"
node scripts/cta.mjs alerts
```

Requires `CTA_TRAIN_API_KEY` and `CTA_BUS_API_KEY` environment variables (or `.env` file) for train/bus commands. Alerts work without keys.

## Testing

```bash
npm test
```

Tests are in `tests/` using Node.js built-in test runner (`node --test`).

## APIs

| API | Base URL | Auth |
|-----|----------|------|
| Train Tracker | `https://lapi.transitchicago.com/api/1.0` | API key (query param) |
| Bus Tracker v2 | `https://www.ctabustracker.com/bustime/api/v2` | API key (query param) |
| Customer Alerts | `https://www.transitchicago.com/api/1.0` | None |
| GTFS Static | `https://www.transitchicago.com/downloads/sch_data/google_transit.zip` | None |

## Station IDs

- Parent station IDs (mapid): 4xxxx range — used for Train Tracker arrivals
- Directional stop IDs: 3xxxx range — platform-specific
- Source of truth: [CTA L Stops dataset](https://data.cityofchicago.org/Transportation/CTA-System-Information-List-of-L-Stops/8pix-ypme)


path: README.md
content: # :metro: CTA Chicago Transit — OpenClaw Skill

> **Real-time Chicago transit data for your OpenClaw agent.** Get L train arrivals, bus predictions, service alerts, and route info for all CTA services.

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Node.js 18+](https://img.shields.io/badge/node-18%2B-green.svg)](https://nodejs.org)
[![ClawHub](https://img.shields.io/badge/ClawHub-Skill-green.svg)](https://clawhub.ai)

---

## Why This Skill?

**For anyone who rides CTA and wants:**
- Real-time L train arrivals — know exactly when the next train comes
- Bus predictions — see when your bus will arrive at your stop
- Live vehicle tracking — see where trains and buses are right now
- Service alerts — know about delays, detours, and disruptions before you leave
- Stop and route lookup — find the nearest stop or explore a route's path

**How it works:**
Data comes from CTA's three official APIs — [Train Tracker](https://www.transitchicago.com/developers/traintracker/), [Bus Tracker](https://www.transitchicago.com/developers/bustracker/), and [Customer Alerts](https://www.transitchicago.com/developers/alerts/). Train and bus data require free API keys. Alerts always work with no key.

---

## Quick Start

### Step 1: Get Your API Keys (free)

1. **Train Tracker key:** Apply at https://www.transitchicago.com/developers/traintrackerapply/
2. **Bus Tracker key:** Apply at https://www.transitchicago.com/developers/bustracker/

### Step 2: Set Environment Variables

```bash
cp .env.example .env
# Then edit .env and add your keys:
# CTA_TRAIN_API_KEY=your-train-key-here
# CTA_BUS_API_KEY=your-bus-key-here
```

### Step 3: Install the Skill

```bash
# Copy to your skills directory
cp -r cta-skill ~/.openclaw/skills/cta

# Or for a workspace-specific install:
cp -r cta-skill <workspace>/skills/cta
```

### Step 4: Download Transit Data

```bash
# One-time download of routes, stops, and schedules
node scripts/cta.mjs refresh-gtfs
```

Note: Re-run `refresh-gtfs` periodically when CTA updates their schedule. Alert commands work even without API keys or GTFS data.

---

## What You Can Do

### :clock3: Real-Time Train Arrivals
- See when the next L train arrives at any station
- Search stations by name — no need to memorize IDs
- Filter by L line (Red, Blue, Brown, etc.)
- Shows "Due" for approaching trains, minutes for upcoming

### :bus: Bus Predictions
- Real-time bus arrival predictions at any stop
- Filter by route number
- Search for stops by name

### :round_pushpin: Vehicle Tracking
- View live positions of all active trains on a line
- View live positions of all active buses on a route
- Positions update every 30-60 seconds

### :loudspeaker: Service Alerts
- Active delays, detours, and service disruptions
- Filter by route — works for both L lines and bus routes
- No API key required — alerts always work

### :world_map: Route & Stop Discovery
- Search for stops by name or proximity to a location
- List all CTA routes (L lines + 129 bus routes)
- View every stop along a route in order

---

## Usage (via OpenClaw Chat)

Just ask your agent naturally:

- "When's the next Red Line at Belmont?"
- "Is the Blue Line running to O'Hare?"
- "Any CTA service alerts right now?"
- "Where are the Brown Line trains?"
- "When does the 22 bus come to Clark & Diversey?"
- "Find CTA stops near Wrigley Field"
- "What bus routes does CTA run?"

---

## Example Workflows

### Check When Your Train Arrives

```bash
# Search by station name
node scripts/cta.mjs arrivals --stop-search "belmont" --route Red

# Or use the exact station ID
node scripts/cta.mjs arrivals --mapid 41320

# Or search by station name
node scripts/cta.mjs arrivals --station "Clark/Lake"
```

### Track a Bus

```bash
# When does the 22 bus arrive at my stop?
node scripts/cta.mjs bus-arrivals --stop 1836 --route 22

# Where are all the 77 Belmont buses?
node scripts/cta.mjs bus-vehicles --route 77
```

### Find Nearby Stops

```bash
# What stops are within 0.3 miles of the Loop?
node scripts/cta.mjs stops --near 41.8781,-87.6298 --radius 0.3
```

### Check Alerts Before You Go

```bash
# All current alerts
node scripts/cta.mjs alerts

# Just Red Line alerts
node scripts/cta.mjs alerts --route Red
```

---

## Command Cheat Sheet

| What You Want | Command |
|---------------|---------|
| **Train Arrivals** | |
| Search station by name | `cta.mjs arrivals --stop-search "ohare"` |
| Arrivals at station | `cta.mjs arrivals --station "Clark/Lake"` |
| Arrivals by station ID | `cta.mjs arrivals --mapid 40380` |
| Arrivals by stop ID | `cta.mjs arrivals --stop 30070` |
| Filter by L line | `cta.mjs arrivals --stop-search "belmont" --route Red` |
| **Bus Predictions** | |
| Bus arrivals at stop | `cta.mjs bus-arrivals --stop 456` |
| Filter by route | `cta.mjs bus-arrivals --stop 456 --route 22` |
| Search bus stop | `cta.mjs bus-arrivals --stop-search "michigan"` |
| **Vehicle Tracking** | |
| Train positions on a line | `cta.mjs vehicles --route Red` |
| Bus positions on a route | `cta.mjs bus-vehicles --route 22` |
| **Alerts** | |
| All service alerts | `cta.mjs alerts` |
| Alerts for a route | `cta.mjs alerts --route Red` |
| **Stops** | |
| Search by name | `cta.mjs stops --search "belmont"` |
| Find nearby stops | `cta.mjs stops --near LAT,LON` |
| Set search radius | `cta.mjs stops --near LAT,LON --radius 0.5` |
| **Routes** | |
| All routes (L + bus) | `cta.mjs routes` |
| All bus routes (live API) | `cta.mjs bus-routes` |
| Route details + stops | `cta.mjs route-info --route Red` |
| Bus route stops | `cta.mjs route-info --route 22` |
| **Maintenance** | |
| Refresh GTFS data | `cta.mjs refresh-gtfs` |

---

## L Train Lines Reference

| Code | Line | Terminals |
|------|------|-----------|
| Red | Red Line | Howard ↔ 95th/Dan Ryan |
| Blue | Blue Line | O'Hare ↔ Forest Park |
| Brn | Brown Line | Kimball ↔ Loop |
| G | Green Line | Harlem/Lake ↔ Ashland/63rd or Cottage Grove |
| Org | Orange Line | Midway ↔ Loop |
| P | Purple Line | Linden ↔ Howard (Express to Loop weekdays) |
| Pink | Pink Line | 54th/Cermak ↔ Loop |
| Y | Yellow Line | Dempster-Skokie ↔ Howard |

---

## Key Bus Routes Reference

| Route | Name | Notes |
|-------|------|-------|
| 22 | Clark | Major north-south corridor |
| 36 | Broadway | North Side lakefront |
| 77 | Belmont | Major east-west crosstown |
| 151 | Sheridan | Lakefront express |
| 146 | Inner Drive/Michigan Express | Loop to north lakefront |
| 8 | Halsted | Long north-south route |
| 9 | Ashland | Major north-south corridor |
| 49 | Western | Longest route in system |
| 66 | Chicago | Major east-west route |
| 79 | 79th | Major south side east-west |

---

## Installation for ClawHub

Once published on ClawHub, users can install this skill with:

```bash
clawhub install cta
```

**Manual installation:**
```bash
cp -r cta-skill ~/.openclaw/skills/cta
cd ~/.openclaw/skills/cta && npm install
node scripts/cta.mjs refresh-gtfs
```

---

## Requirements

| Requirement | Details | How to Get |
|-------------|---------|------------|
| **Node.js 18+** | Runtime for the skill | [nodejs.org](https://nodejs.org) |
| **unzip** | Required for GTFS data extraction | Pre-installed on most systems |
| **CTA Train API Key** | Required for train commands | [Apply here](https://www.transitchicago.com/developers/traintrackerapply/) |
| **CTA Bus API Key** | Required for bus commands | [Apply here](https://www.transitchicago.com/developers/bustracker/) |

Note: Alert commands (`alerts`) work without any API keys.

---

## Data Sources

| API | Auth | Update Frequency |
|-----|------|-----------------|
| Train Tracker | API key (free) | ~1 minute |
| Bus Tracker v2 | API key (free) | ~30 seconds |
| Customer Alerts | None (open) | As needed |
| GTFS Static | None (open) | Periodic schedule updates |

---

## Troubleshooting

### "CTA Train Tracker API key required"
**Solution:** Get a free key and add it to your `.env` file:
```bash
# Apply at: https://www.transitchicago.com/developers/traintrackerapply/
CTA_TRAIN_API_KEY=your-key-here
```

### "CTA Bus Tracker API key required"
**Solution:** Get a free key and add it to your `.env` file:
```bash
# Apply at: https://www.transitchicago.com/developers/bustracker/
CTA_BUS_API_KEY=your-key-here
```

### "Invalid API key" error
**Possible causes:**
- Key was copy-pasted incorrectly (check for extra spaces)
- Key hasn't been activated yet (can take a few minutes after signup)
- Daily request limit exceeded (50,000/day for train; similar for bus)

### No arrivals showing up
**Possible causes:**
- No active service at this time (late night, holidays)
- Station/stop ID is incorrect — try `--stop-search` to find the right one
- API may be temporarily unavailable

### "GTFS static data not found"
**Solution:** Run the one-time data download:
```bash
node scripts/cta.mjs refresh-gtfs
```

### Alert commands work but train/bus commands don't
**This is expected** if API keys aren't set. Alerts use the Customer Alerts API which requires no authentication. Set `CTA_TRAIN_API_KEY` and `CTA_BUS_API_KEY` for full functionality.

---

## CTA Fares Reference (2026)

| Fare Type | Price |
|-----------|-------|
| Regular (Ventra/contactless) | $2.50 |
| Bus transfer | $0.25 |
| Rail-to-rail transfer | Free within 2 hours |
| Reduced fare | $1.25 |
| 1-Day Pass | $5.00 |
| 3-Day Pass | $15.00 |
| 7-Day Pass | $20.00 |
| 30-Day Pass | $75.00 |

---

## Learn More

### Resources
- **[CTA Developer Center](https://www.transitchicago.com/developers/)** — Official API docs and key signup
- **[Train Tracker API Docs](https://www.transitchicago.com/developers/traintracker/)** — Detailed train API reference
- **[Bus Tracker API Docs](https://www.transitchicago.com/developers/bustracker/)** — Detailed bus API reference
- **[CTA System Map](https://www.transitchicago.com/maps/)** — Official route maps
- **[GTFS Reference](https://gtfs.org)** — The data format standard used by transit agencies worldwide

---

## About This Skill

**Skill Type:** Data Integration (CTA REST APIs + GTFS)
**Runtime:** Node.js
**Created for:** ClawHub / OpenClaw
**License:** MIT

**Credits:**
- Transit data provided by [Chicago Transit Authority (CTA)](https://www.transitchicago.com)
- Train Tracker, Bus Tracker, and Customer Alerts APIs by CTA
- GTFS static data published by CTA

---

**Ready to ride?** Get your free API keys, set them up, and never miss your train again.


path: package-lock.json
content: {
  "name": "cta-skill",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "cta-skill",
      "version": "1.0.0",
      "license": "MIT"
    }
  }
}


path: package.json
content: {
  "name": "cta-skill",
  "version": "1.0.0",
  "description": "Real-time Chicago CTA transit data for OpenClaw. L train arrivals, bus predictions, service alerts, and route info.",
  "main": "scripts/cta.mjs",
  "type": "module",
  "scripts": {
    "test": "node --test 'tests/**/*.test.mjs'"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/brianleach/cta-skill.git"
  },
  "keywords": [
    "openclaw",
    "clawhub",
    "skill",
    "cta",
    "chicago",
    "transit",
    "l-train",
    "bus",
    "rail"
  ],
  "author": "Brian Leach",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/brianleach/cta-skill/issues"
  },
  "homepage": "https://github.com/brianleach/cta-skill#readme",
  "dependencies": {}
}


path: scripts/cta.mjs
content: #!/usr/bin/env node
/**
 * CTA Chicago Transit — OpenClaw Skill
 * Real-time L train arrivals, bus predictions, service alerts, and route info.
 * Uses CTA's Train Tracker, Bus Tracker, and Customer Alerts APIs.
 *
 * SECURITY MANIFEST
 *   Environment variables: CTA_TRAIN_API_KEY, CTA_BUS_API_KEY
 *   External endpoints:    https://lapi.transitchicago.com (Train Tracker, key required)
 *                          https://www.ctabustracker.com (Bus Tracker, key required)
 *                          https://www.transitchicago.com (Alerts, no key; GTFS static, no key)
 *   Local files written:   ~/.cta/gtfs/ (GTFS static data cache)
 *   Local files read:      ~/.cta/gtfs/*.txt (GTFS CSV files)
 *   User input handling:   Used for local filtering only, never interpolated into
 *                          shell commands
 */

import { parseArgs } from 'node:util';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';

// ---------------------------------------------------------------------------
// Load .env file (if present) — no external dependencies
// ---------------------------------------------------------------------------
function loadEnv() {
  const envPath = path.join(path.dirname(new URL(import.meta.url).pathname), '..', '.env');
  if (!fs.existsSync(envPath)) return;
  const content = fs.readFileSync(envPath, 'utf-8');
  for (const line of content.split(/\r?\n/)) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const eq = trimmed.indexOf('=');
    if (eq === -1) continue;
    const key = trimmed.slice(0, eq).trim();
    const val = trimmed.slice(eq + 1).trim();
    if (!process.env[key]) process.env[key] = val;
  }
}
loadEnv();

// ---------------------------------------------------------------------------
// API Configuration
// ---------------------------------------------------------------------------
const CTA_TRAIN_API_KEY = process.env.CTA_TRAIN_API_KEY || '';
const CTA_BUS_API_KEY = process.env.CTA_BUS_API_KEY || '';

const TRAIN_BASE = 'https://lapi.transitchicago.com/api/1.0';
const BUS_BASE = 'https://www.ctabustracker.com/bustime/api/v2';
const ALERTS_BASE = 'https://www.transitchicago.com/api/1.0';
const GTFS_STATIC_URL = 'https://www.transitchicago.com/downloads/sch_data/google_transit.zip';

const GTFS_DIR = path.join(os.homedir(), '.cta', 'gtfs');

// ---------------------------------------------------------------------------
// L Train Lines
// ---------------------------------------------------------------------------
const L_LINES = {
  Red:  { name: 'Red Line',    color: 'Red',    terminals: ['Howard', '95th/Dan Ryan'] },
  Blue: { name: 'Blue Line',   color: 'Blue',   terminals: ['O\'Hare', 'Forest Park'] },
  Brn:  { name: 'Brown Line',  color: 'Brown',  terminals: ['Kimball', 'Loop'] },
  G:    { name: 'Green Line',  color: 'Green',  terminals: ['Harlem/Lake', 'Ashland/63rd', 'Cottage Grove'] },
  Org:  { name: 'Orange Line', color: 'Orange', terminals: ['Midway', 'Loop'] },
  P:    { name: 'Purple Line', color: 'Purple', terminals: ['Linden', 'Howard'] },
  Pink: { name: 'Pink Line',   color: 'Pink',   terminals: ['54th/Cermak', 'Loop'] },
  Y:    { name: 'Yellow Line', color: 'Yellow', terminals: ['Dempster-Skokie', 'Howard'] },
};

// Route code aliases for user-friendly matching
const ROUTE_ALIASES = {
  red: 'Red', blue: 'Blue', brown: 'Brn', brn: 'Brn',
  green: 'G', g: 'G', orange: 'Org', org: 'Org',
  purple: 'P', p: 'P', pink: 'Pink', yellow: 'Y', y: 'Y',
};

function resolveTrainRoute(input) {
  if (!input) return null;
  if (L_LINES[input]) return input;
  return ROUTE_ALIASES[input.toLowerCase()] || input;
}

// ---------------------------------------------------------------------------
// Key Station Data (for fuzzy matching without GTFS)
// mapid = parent station ID (4xxxx)
// ---------------------------------------------------------------------------
const STATIONS = [
  { mapid: '40890', name: "O'Hare", lines: ['Blue'], aliases: ['ohare', 'airport', "o'hare", 'ohare airport'] },
  { mapid: '40390', name: 'Forest Park', lines: ['Blue'], aliases: ['forest park'] },
  { mapid: '40930', name: 'Midway', lines: ['Org'], aliases: ['midway', 'midway airport'] },
  { mapid: '40900', name: 'Howard', lines: ['Red', 'P', 'Y'], aliases: ['howard'] },
  { mapid: '40450', name: '95th/Dan Ryan', lines: ['Red'], aliases: ['95th', 'dan ryan', '95th dan ryan'] },
  { mapid: '40380', name: 'Clark/Lake', lines: ['Blue', 'Brn', 'G', 'Org', 'P', 'Pink'], aliases: ['clark lake', 'clark and lake'] },
  { mapid: '40260', name: 'State/Lake', lines: ['Brn', 'G', 'Org', 'P', 'Pink'], aliases: ['state lake', 'state and lake'] },
  { mapid: '41700', name: 'Washington/Wabash', lines: ['Brn', 'G', 'Org', 'P', 'Pink'], aliases: ['washington wabash', 'washington and wabash'] },
  { mapid: '40680', name: 'Adams/Wabash', lines: ['Brn', 'G', 'Org', 'P', 'Pink'], aliases: ['adams wabash', 'adams and wabash'] },
  { mapid: '40850', name: 'Harold Washington Library', lines: ['Brn', 'Org', 'Pink', 'P'], aliases: ['library', 'harold washington', 'harold washington library'] },
  { mapid: '40160', name: 'LaSalle/Van Buren', lines: ['Brn', 'Org', 'Pink', 'P'], aliases: ['lasalle', 'van buren', 'lasalle van buren'] },
  { mapid: '40040', name: 'Quincy', lines: ['Brn', 'Org', 'Pink', 'P'], aliases: ['quincy'] },
  { mapid: '41320', name: 'Belmont', lines: ['Red', 'Brn', 'P'], aliases: ['belmont red', 'belmont brown'] },
  { mapid: '41220', name: 'Fullerton', lines: ['Red', 'Brn', 'P'], aliases: ['fullerton'] },
  { mapid: '41400', name: 'Roosevelt', lines: ['Red', 'Org', 'G'], aliases: ['roosevelt'] },
  { mapid: '40560', name: 'Jackson (Red)', lines: ['Red'], aliases: ['jackson red'] },
  { mapid: '40070', name: 'Jackson (Blue)', lines: ['Blue'], aliases: ['jackson blue'] },
  { mapid: '41450', name: 'Chicago (Red)', lines: ['Red'], aliases: ['chicago red', 'chicago ave red'] },
  { mapid: '41410', name: 'Chicago (Blue)', lines: ['Blue'], aliases: ['chicago blue', 'chicago ave blue'] },
  { mapid: '40330', name: 'Grand (Red)', lines: ['Red'], aliases: ['grand red'] },
  { mapid: '40490', name: 'Grand (Blue)', lines: ['Blue'], aliases: ['grand blue'] },
  { mapid: '41090', name: 'Monroe (Red)', lines: ['Red'], aliases: ['monroe red'] },
  { mapid: '41160', name: 'Monroe (Blue)', lines: ['Blue'], aliases: ['monroe blue'] },
  { mapid: '41440', name: 'Addison (Red)', lines: ['Red'], aliases: ['wrigley', 'addison red', 'cubs', 'wrigley field'] },
  { mapid: '41240', name: 'Addison (Brown)', lines: ['Brn'], aliases: ['addison brown'] },
  { mapid: '40190', name: 'Sox-35th', lines: ['Red'], aliases: ['sox', 'white sox', '35th', 'guaranteed rate', 'sox 35th'] },
  { mapid: '40350', name: 'UIC-Halsted', lines: ['Blue'], aliases: ['uic', 'united center', 'bulls', 'blackhawks', 'uic halsted'] },
  { mapid: '40530', name: 'Washington (Blue)', lines: ['Blue'], aliases: ['washington blue'] },
  { mapid: '41020', name: 'Logan Square', lines: ['Blue'], aliases: ['logan', 'logan square'] },
  { mapid: '40590', name: 'Damen (Blue)', lines: ['Blue'], aliases: ['wicker park', 'damen blue'] },
  { mapid: '41010', name: 'Kimball', lines: ['Brn'], aliases: ['kimball'] },
  { mapid: '40460', name: 'Linden', lines: ['P'], aliases: ['linden', 'wilmette'] },
  { mapid: '40140', name: 'Dempster-Skokie', lines: ['Y'], aliases: ['dempster', 'skokie'] },
  { mapid: '40830', name: 'Harlem/Lake (Green)', lines: ['G'], aliases: ['harlem green', 'harlem lake'] },
  { mapid: '40510', name: '54th/Cermak', lines: ['Pink'], aliases: ['54th cermak', '54th'] },
  { mapid: '41120', name: 'Cermak-Chinatown', lines: ['Red'], aliases: ['chinatown', 'cermak chinatown'] },
  { mapid: '40720', name: 'Cottage Grove', lines: ['G'], aliases: ['cottage grove'] },
  { mapid: '40290', name: 'Ashland/63rd', lines: ['G'], aliases: ['ashland 63rd'] },
  { mapid: '41000', name: 'Randolph/Wabash', lines: ['Brn', 'G', 'Org', 'P', 'Pink'], aliases: ['millennium', 'randolph', 'randolph wabash'] },
  { mapid: '40730', name: 'Western (Brown)', lines: ['Brn'], aliases: ['western brown'] },
  { mapid: '40220', name: 'Western (Blue - O\'Hare)', lines: ['Blue'], aliases: ['western blue ohare'] },
  { mapid: '40810', name: 'Western (Blue - Forest Park)', lines: ['Blue'], aliases: ['western blue forest park'] },
  { mapid: '41480', name: 'Western (Orange)', lines: ['Org'], aliases: ['western orange'] },
  { mapid: '40750', name: 'Merchandise Mart', lines: ['Brn', 'P'], aliases: ['merchandise mart', 'merch mart'] },
  { mapid: '40800', name: 'Sedgwick', lines: ['Brn', 'P'], aliases: ['sedgwick'] },
  { mapid: '40660', name: 'Armitage', lines: ['Brn', 'P'], aliases: ['armitage'] },
  { mapid: '40570', name: 'Diversey', lines: ['Brn', 'P'], aliases: ['diversey'] },
  { mapid: '41290', name: 'Lake (Red)', lines: ['Red'], aliases: ['lake red'] },
  { mapid: '40920', name: 'Pulaski (Blue)', lines: ['Blue'], aliases: ['pulaski blue'] },
  { mapid: '40180', name: 'Halsted (Orange)', lines: ['Org'], aliases: ['halsted orange'] },
  { mapid: '40980', name: 'Ashland (Orange)', lines: ['Org'], aliases: ['ashland orange'] },
  { mapid: '41060', name: '35th/Archer', lines: ['Org'], aliases: ['35th archer'] },
];

function searchStation(query, routeFilter) {
  const q = query.toLowerCase().replace(/['']/g, '').trim();

  // First try embedded stations
  const scored = [];
  for (const s of STATIONS) {
    if (routeFilter && !s.lines.includes(routeFilter)) continue;
    const nameNorm = s.name.toLowerCase().replace(/['']/g, '');
    let score = 999;
    if (nameNorm === q) score = 0;
    else if (s.aliases.some(a => a === q)) score = 1;
    else if (nameNorm.includes(q)) score = 2;
    else if (s.aliases.some(a => a.includes(q))) score = 3;
    else if (q.split(/\s+/).every(w => nameNorm.includes(w) || s.aliases.some(a => a.includes(w)))) score = 4;
    else continue;
    scored.push({ ...s, score });
  }
  scored.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));

  // Also search GTFS stops if available
  if (fs.existsSync(path.join(GTFS_DIR, 'stops.txt'))) {
    const stops = loadStops();
    for (const [sid, s] of Object.entries(stops)) {
      // Only consider parent stations (4xxxx) for train searches
      if (!sid.startsWith('4')) continue;
      if (scored.some(st => st.mapid === sid)) continue;
      const nameNorm = (s.stop_name || '').toLowerCase().replace(/['']/g, '');
      let score = 999;
      if (nameNorm === q) score = 5;
      else if (nameNorm.includes(q)) score = 6;
      else continue;
      scored.push({ mapid: sid, name: s.stop_name, lines: [], score });
    }
    scored.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
  }

  return scored;
}

// ---------------------------------------------------------------------------
// Time helpers — CST/CDT (US Central Time)
// ---------------------------------------------------------------------------
function localTzOffsetHours() {
  const now = new Date();
  const year = now.getUTCFullYear();
  const mar1 = new Date(Date.UTC(year, 2, 1));
  const firstSunMar = (7 - mar1.getUTCDay()) % 7 + 1;
  const dstStart = Date.UTC(year, 2, firstSunMar + 7, 8);
  const nov1 = new Date(Date.UTC(year, 10, 1));
  const firstSunNov = (7 - nov1.getUTCDay()) % 7 + 1;
  const dstEnd = Date.UTC(year, 10, firstSunNov, 7);
  const ts = now.getTime();
  return (ts >= dstStart && ts < dstEnd) ? -5 : -6;
}

function toLocalDate(d) {
  const offset = localTzOffsetHours();
  return new Date(d.getTime() + offset * 3600000);
}

function localNow() {
  return toLocalDate(new Date());
}

function fmtTime(d) {
  let h = d.getUTCHours(), m = d.getUTCMinutes(), s = d.getUTCSeconds();
  const ampm = h >= 12 ? 'PM' : 'AM';
  if (h > 12) h -= 12; else if (h === 0) h = 12;
  return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')} ${ampm}`;
}

function fmtTimeHM(d) {
  let h = d.getUTCHours(), m = d.getUTCMinutes();
  const ampm = h >= 12 ? 'PM' : 'AM';
  if (h > 12) h -= 12; else if (h === 0) h = 12;
  return `${h}:${String(m).padStart(2, '0')} ${ampm}`;
}

function fmtDateTimeShort(d) {
  const mo = d.getUTCMonth() + 1, day = d.getUTCDate();
  let h = d.getUTCHours(), m = d.getUTCMinutes();
  const ampm = h >= 12 ? 'PM' : 'AM';
  if (h > 12) h -= 12; else if (h === 0) h = 12;
  return `${String(mo).padStart(2, '0')}/${String(day).padStart(2, '0')} ${h}:${String(m).padStart(2, '0')}${ampm}`;
}

// Parse CTA timestamp — all CTA APIs return Central Time.
// Train Tracker uses ISO: "2026-02-16T20:37:05"
// Bus Tracker uses:       "20260216 20:34"
// We store Central Time values in a Date's UTC slots so fmtTimeHM reads them directly.
function parseCTATimestamp(ts) {
  if (!ts) return null;

  // Bus Tracker format: YYYYMMDD HH:MM or YYYYMMDD HH:MM:SS
  const busMatch = ts.match(/^(\d{4})(\d{2})(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/);
  if (busMatch) {
    const [, y, mo, d, h, mi, s] = busMatch;
    return new Date(Date.UTC(
      parseInt(y), parseInt(mo) - 1, parseInt(d),
      parseInt(h), parseInt(mi), parseInt(s || '0')
    ));
  }

  // Train Tracker ISO format: YYYY-MM-DDTHH:MM:SS (Central Time, no timezone suffix)
  const isoMatch = ts.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/);
  if (isoMatch) {
    const [, y, mo, d, h, mi, s] = isoMatch;
    return new Date(Date.UTC(
      parseInt(y), parseInt(mo) - 1, parseInt(d),
      parseInt(h), parseInt(mi), parseInt(s)
    ));
  }

  // Fallback: parse manually to avoid timezone interpretation
  const parsed = new Date(ts);
  if (!isNaN(parsed.getTime())) return parsed;
  return null;
}

// ---------------------------------------------------------------------------
// GTFS Static data helpers
// ---------------------------------------------------------------------------

function ensureGtfs() {
  if (!fs.existsSync(path.join(GTFS_DIR, 'stops.txt'))) {
    console.log(`GTFS static data not found at ${GTFS_DIR}`);
    console.log('Run: node scripts/cta.mjs refresh-gtfs');
    return false;
  }
  return true;
}

function parseCsvLine(line) {
  const fields = [];
  let current = '';
  let inQuotes = false;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (inQuotes) {
      if (ch === '"') {
        if (i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; }
        else inQuotes = false;
      } else {
        current += ch;
      }
    } else {
      if (ch === '"') inQuotes = true;
      else if (ch === ',') { fields.push(current); current = ''; }
      else current += ch;
    }
  }
  fields.push(current);
  return fields;
}

function loadCsv(filename) {
  const filePath = path.join(GTFS_DIR, filename);
  if (!fs.existsSync(filePath)) return [];
  let content = fs.readFileSync(filePath, 'utf-8');
  if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1);
  const lines = content.split(/\r?\n/).filter(l => l.trim());
  if (lines.length === 0) return [];
  const headers = parseCsvLine(lines[0]);
  const rows = [];
  for (let i = 1; i < lines.length; i++) {
    const vals = parseCsvLine(lines[i]);
    const obj = {};
    for (let j = 0; j < headers.length; j++) {
      obj[headers[j]] = vals[j] || '';
    }
    rows.push(obj);
  }
  return rows;
}

let _stopsCache = null;
function loadStops() {
  if (_stopsCache) return _stopsCache;
  const rows = loadCsv('stops.txt');
  const m = {};
  for (const r of rows) m[r.stop_id] = r;
  _stopsCache = m;
  return m;
}

function loadRoutes() {
  const rows = loadCsv('routes.txt');
  const m = {};
  for (const r of rows) m[r.route_id] = r;
  return m;
}

function loadTrips() {
  const rows = loadCsv('trips.txt');
  const m = {};
  for (const r of rows) m[r.trip_id] = r;
  return m;
}

function loadStopTimesForTrip(tripId) {
  const rows = loadCsv('stop_times.txt');
  return rows.filter(r => r.trip_id === tripId)
    .sort((a, b) => parseInt(a.stop_sequence || '0') - parseInt(b.stop_sequence || '0'));
}

// ---------------------------------------------------------------------------
// Haversine
// ---------------------------------------------------------------------------

function haversine(lat1, lon1, lat2, lon2) {
  const R = 3959;
  const dlat = (lat2 - lat1) * Math.PI / 180;
  const dlon = (lon2 - lon1) * Math.PI / 180;
  const a = Math.sin(dlat / 2) ** 2 +
    Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
    Math.sin(dlon / 2) ** 2;
  return R * 2 * Math.asin(Math.sqrt(a));
}

// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------

function requireTrainKey() {
  if (!CTA_TRAIN_API_KEY) {
    console.log('CTA Train Tracker API key required.');
    console.log('Get a free key at: https://www.transitchicago.com/developers/traintrackerapply/');
    console.log('Then set CTA_TRAIN_API_KEY in your environment.');
    return false;
  }
  return true;
}

function requireBusKey() {
  if (!CTA_BUS_API_KEY) {
    console.log('CTA Bus Tracker API key required.');
    console.log('Get a free key at: https://www.transitchicago.com/developers/bustracker/');
    console.log('Then set CTA_BUS_API_KEY in your environment.');
    return false;
  }
  return true;
}

async function fetchJSON(url) {
  const resp = await fetch(url, { signal: AbortSignal.timeout(30000) });
  if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${new URL(url).hostname}`);
  return resp.json();
}

function handleCTAError(data, apiName) {
  // Train Tracker errors
  if (data?.ctatt?.errCd && data.ctatt.errCd !== '0') {
    const code = data.ctatt.errCd;
    const msg = data.ctatt.errNm || 'Unknown error';
    if (code === '101') {
      console.log(`${apiName}: Invalid API key. Check your CTA_TRAIN_API_KEY.`);
    } else if (code === '501') {
      console.log(`${apiName}: No arrival data found. The station may not have active service right now.`);
    } else {
      console.log(`${apiName} error (${code}): ${msg}`);
    }
    return true;
  }
  // Bus Tracker errors
  if (data?.['bustime-response']?.error) {
    const errors = data['bustime-response'].error;
    const errArr = Array.isArray(errors) ? errors : [errors];
    for (const e of errArr) {
      const msg = e.msg || e.stpnm || 'Unknown error';
      if (msg.includes('Invalid API access key')) {
        console.log(`${apiName}: Invalid API key. Check your CTA_BUS_API_KEY.`);
      } else if (msg.includes('No data found') || msg.includes('No arrival times')) {
        console.log(`${apiName}: No data found. The stop/route may not have active service right now.`);
      } else {
        console.log(`${apiName} error: ${msg}`);
      }
    }
    return true;
  }
  return false;
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

async function cmdRefreshGtfs() {
  console.log(`Downloading GTFS static data to ${GTFS_DIR} ...`);
  fs.mkdirSync(GTFS_DIR, { recursive: true });

  const resp = await fetch(GTFS_STATIC_URL, { signal: AbortSignal.timeout(120000) });
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  const buf = Buffer.from(await resp.arrayBuffer());

  const tmpZip = path.join(GTFS_DIR, '_gtfs_tmp.zip');
  fs.writeFileSync(tmpZip, buf);

  try {
    execFileSync('unzip', ['-o', tmpZip, '-d', GTFS_DIR], { stdio: 'pipe' });
  } catch (err) {
    if (err.code === 'ENOENT') {
      console.error('Error: "unzip" command not found. Install it first:');
      console.error('  Ubuntu/Debian: sudo apt install unzip');
      console.error('  macOS: brew install unzip (or use built-in)');
      console.error('  Arch: sudo pacman -S unzip');
    } else {
      console.error(`Error extracting GTFS zip: ${err.message}`);
    }
    fs.unlinkSync(tmpZip);
    return;
  }
  fs.unlinkSync(tmpZip);

  const files = fs.readdirSync(GTFS_DIR).filter(f => f.endsWith('.txt')).sort();
  console.log(`Extracted ${files.length} files:`);
  for (const f of files) console.log(`  ${f}`);
  console.log('GTFS data refreshed successfully.');
}

// ---- Train Arrivals ----

async function cmdArrivals(opts) {
  if (!requireTrainKey()) return;

  let mapid = opts.mapid;
  let stopId = opts.stop;
  const stationName = opts.station;
  const stopSearch = opts['stop-search'];
  const routeFilter = opts.route ? resolveTrainRoute(opts.route) : null;

  // Resolve station by name search
  if (stationName || stopSearch) {
    const query = stationName || stopSearch;
    const matches = searchStation(query, routeFilter);
    if (!matches.length) {
      console.log(`No stations found matching '${query}'.`);
      console.log("Try 'stops --search <name>' to search all stops.");
      return;
    }
    if (matches.length > 1) {
      console.log(`Found ${matches.length} stations matching '${query}':`);
      for (const s of matches.slice(0, 8)) {
        const lineStr = s.lines?.length ? ` (${s.lines.join(', ')})` : '';
        console.log(`  ${s.mapid} — ${s.name}${lineStr}`);
      }
      console.log(`\nUsing best match: ${matches[0].name}\n`);
    }
    mapid = matches[0].mapid;
  }

  if (!mapid && !stopId) {
    console.log('Provide --station, --stop-search, --mapid, or --stop');
    return;
  }

  // Build URL
  let url;
  if (stopId) {
    url = `${TRAIN_BASE}/ttarrivals.aspx?key=${encodeURIComponent(CTA_TRAIN_API_KEY)}&stpid=${encodeURIComponent(stopId)}&max=15&outputType=JSON`;
  } else {
    url = `${TRAIN_BASE}/ttarrivals.aspx?key=${encodeURIComponent(CTA_TRAIN_API_KEY)}&mapid=${encodeURIComponent(mapid)}&max=15&outputType=JSON`;
  }

  const data = await fetchJSON(url);
  if (handleCTAError(data, 'Train Tracker')) return;

  const arrivals = data?.ctatt?.eta;
  if (!arrivals || arrivals.length === 0) {
    console.log('No upcoming train arrivals at this station.');
    return;
  }

  const arrList = Array.isArray(arrivals) ? arrivals : [arrivals];
  const stationLabel = arrList[0]?.staNm || mapid || stopId;
  console.log(`\n=== Train Arrivals at: ${stationLabel} ===\n`);

  // Filter by route if specified
  const filtered = routeFilter
    ? arrList.filter(a => a.rt === routeFilter)
    : arrList;

  if (!filtered.length) {
    console.log(`No arrivals found for ${L_LINES[routeFilter]?.name || routeFilter} at this station.`);
    return;
  }

  for (const a of filtered) {
    const line = L_LINES[a.rt];
    const lineName = line ? line.name : a.rt;
    const dest = a.destNm || 'Unknown';
    const isApproaching = a.isApp === '1';
    const isDelayed = a.isDly === '1';

    const arrTime = parseCTATimestamp(a.arrT);

    let etaStr = '';
    if (isApproaching) {
      etaStr = 'Due';
    } else if (arrTime) {
      // arrT is in Central Time — compare to Central now
      // Since parseCTATimestamp returns a Date in UTC with CT values,
      // compare against localNow which is also shifted to CT
      const now = localNow();
      const minsAway = Math.round((arrTime.getTime() - now.getTime()) / 60000);
      if (minsAway <= 1) etaStr = 'Due';
      else etaStr = `${minsAway} min`;
    }

    const timeStr = arrTime ? fmtTimeHM(arrTime) : '??';
    const delayStr = isDelayed ? ' (delayed)' : '';

    console.log(`  🚇 ${lineName} toward ${dest}`);
    console.log(`     ${timeStr} (${etaStr})${delayStr}`);
    if (a.rn) console.log(`     Run #${a.rn}`);
    console.log();
  }
}

// ---- Bus Arrivals ----

async function cmdBusArrivals(opts) {
  if (!requireBusKey()) return;

  let stopId = opts.stop;
  const stopSearch = opts['stop-search'];
  const routeFilter = opts.route;

  if (stopSearch) {
    if (!ensureGtfs()) return;
    const stops = loadStops();
    const query = stopSearch.toLowerCase();
    const matches = Object.values(stops).filter(s =>
      (s.stop_name || '').toLowerCase().includes(query)
    );
    if (!matches.length) {
      console.log(`No stops found matching '${stopSearch}'.`);
      return;
    }
    // Rank: prefer exact, then shorter names
    matches.sort((a, b) => {
      const an = (a.stop_name || '').toLowerCase();
      const bn = (b.stop_name || '').toLowerCase();
      if (an === query && bn !== query) return -1;
      if (bn === query && an !== query) return 1;
      return an.length - bn.length || an.localeCompare(bn);
    });
    if (matches.length > 1) {
      console.log(`Found ${matches.length} stops matching '${stopSearch}':`);
      for (const s of matches.slice(0, 10)) {
        console.log(`  ${s.stop_id.padStart(6)} — ${s.stop_name}`);
      }
      console.log(`\nUsing best match: ${matches[0].stop_name}\n`);
    }
    stopId = matches[0].stop_id;
  }

  if (!stopId) {
    console.log('Provide --stop or --stop-search');
    return;
  }

  let url = `${BUS_BASE}/getpredictions?key=${encodeURIComponent(CTA_BUS_API_KEY)}&stpid=${encodeURIComponent(stopId)}&format=json`;
  if (routeFilter) {
    url += `&rt=${encodeURIComponent(routeFilter)}`;
  }

  const data = await fetchJSON(url);
  if (handleCTAError(data, 'Bus Tracker')) return;

  const preds = data?.['bustime-response']?.prd;
  if (!preds || preds.length === 0) {
    console.log('No upcoming bus predictions at this stop.');
    return;
  }

  const predList = Array.isArray(preds) ? preds : [preds];
  const stopLabel = predList[0]?.stpnm || stopId;
  console.log(`\n=== Bus Predictions at: ${stopLabel} (Stop ${stopId}) ===\n`);

  for (const p of predList) {
    const route = p.rt || '?';
    const dir = p.rtdir || '';
    const dest = p.des || '';
    const predType = p.typ === 'A' ? 'Arriving' : 'Departing';

    const predTime = parseCTATimestamp(p.prdtm);
    const genTime = parseCTATimestamp(p.tmstmp);
    let minsAway = parseInt(p.prdctdn);
    if (isNaN(minsAway) && predTime && genTime) {
      minsAway = Math.round((predTime.getTime() - genTime.getTime()) / 60000);
    }

    const etaStr = (p.prdctdn === 'DUE' || minsAway <= 0) ? 'Due' :
      minsAway === 1 ? '1 min' : `${minsAway} min`;
    const timeStr = predTime ? fmtTimeHM(predTime) : '??';
    const delayed = p.dly === 'true' ? ' (delayed)' : '';

    console.log(`  🚌 Route ${route} ${dir} → ${dest}`);
    console.log(`     ${timeStr} (${etaStr})${delayed}`);
    if (p.vid) console.log(`     Vehicle #${p.vid}`);
    console.log();
  }
}

// ---- Train Vehicles (Positions) ----

async function cmdVehicles(opts) {
  if (!requireTrainKey()) return;

  const routeCode = opts.route ? resolveTrainRoute(opts.route) : null;
  if (!routeCode) {
    console.log('Provide --route with an L line code (e.g., Red, Blue, Brn, G, Org, P, Pink, Y)');
    return;
  }

  const line = L_LINES[routeCode];
  if (!line) {
    console.log(`Unknown L line: ${opts.route}`);
    console.log('Valid lines: Red, Blue, Brn (Brown), G (Green), Org (Orange), P (Purple), Pink, Y (Yellow)');
    return;
  }

  const url = `${TRAIN_BASE}/ttpositions.aspx?key=${encodeURIComponent(CTA_TRAIN_API_KEY)}&rt=${encodeURIComponent(routeCode)}&outputType=JSON`;
  const data = await fetchJSON(url);
  if (handleCTAError(data, 'Train Tracker')) return;

  const routes = data?.ctatt?.route;
  if (!routes) {
    console.log(`No position data available for ${line.name}.`);
    return;
  }

  const routeArr = Array.isArray(routes) ? routes : [routes];
  let allTrains = [];
  for (const r of routeArr) {
    const trains = r.train;
    if (!trains) continue;
    const trainArr = Array.isArray(trains) ? trains : [trains];
    allTrains = allTrains.concat(trainArr);
  }

  if (!allTrains.length) {
    console.log(`No active trains on ${line.name}.`);
    return;
  }

  console.log(`\n=== ${line.name} Train Positions (${allTrains.length} active) ===\n`);

  for (const t of allTrains) {
    const dest = t.destNm || 'Unknown';
    const nextSta = t.nextStaNm || 'Unknown';
    const isApp = t.isApp === '1';
    const isDly = t.isDly === '1';
    const lat = t.lat;
    const lon = t.lon;
    const heading = t.heading;

    let statusStr = '';
    if (isApp) statusStr = `Approaching ${nextSta}`;
    else statusStr = `En route to ${nextSta}`;
    if (isDly) statusStr += ' (delayed)';

    console.log(`  🚇 Run #${t.rn} → ${dest}`);
    console.log(`     ${statusStr}`);
    if (lat && lon) console.log(`     Position: (${lat}, ${lon}) heading ${heading || '?'}°`);
    console.log();
  }
}

// ---- Bus Vehicles ----

async function cmdBusVehicles(opts) {
  if (!requireBusKey()) return;

  const route = opts.route;
  if (!route) {
    console.log('Provide --route with a bus route number (e.g., 22, 36, 77)');
    return;
  }

  const url = `${BUS_BASE}/getvehicles?key=${encodeURIComponent(CTA_BUS_API_KEY)}&rt=${encodeURIComponent(route)}&format=json`;
  const data = await fetchJSON(url);
  if (handleCTAError(data, 'Bus Tracker')) return;

  const vehicles = data?.['bustime-response']?.vehicle;
  if (!vehicles || vehicles.length === 0) {
    console.log(`No active buses on route ${route}.`);
    return;
  }

  const vList = Array.isArray(vehicles) ? vehicles : [vehicles];
  console.log(`\n=== Route ${route} Bus Positions (${vList.length} active) ===\n`);

  for (const v of vList) {
    const vid = v.vid || '?';
    const dir = v.rtdir || '';
    const dest = v.des || '';
    const lat = v.lat;
    const lon = v.lon;
    const heading = v.hdg;
    const speed = v.spd;
    const delayed = v.dly === 'true' ? ' (delayed)' : '';

    const tsStr = v.tmstmp ? (() => {
      const t = parseCTATimestamp(v.tmstmp);
      return t ? fmtTimeHM(t) : v.tmstmp;
    })() : '';

    console.log(`  🚌 Vehicle #${vid} — ${dir}${delayed}`);
    if (dest) console.log(`     Destination: ${dest}`);
    if (lat && lon) console.log(`     Position: (${lat}, ${lon}) heading ${heading || '?'}° @ ${speed || '?'} mph`);
    if (tsStr) console.log(`     Last update: ${tsStr}`);
    console.log();
  }
}

// ---- Alerts ----

async function cmdAlerts(opts) {
  const routeFilter = opts.route;

  let url = `${ALERTS_BASE}/alerts.aspx?outputType=JSON`;
  if (routeFilter) {
    // Resolve L line codes to route IDs used by alerts API
    const resolved = resolveTrainRoute(routeFilter);
    url += `&routeid=${encodeURIComponent(resolved || routeFilter)}`;
  }

  const data = await fetchJSON(url);

  const alerts = data?.CTAAlerts?.Alert;
  if (!alerts || alerts.length === 0) {
    const filterMsg = routeFilter ? ` for ${routeFilter}` : '';
    console.log(`No active service alerts${filterMsg}.`);
    return;
  }

  const alertList = Array.isArray(alerts) ? alerts : [alerts];
  console.log(`\n=== CTA Service Alerts (${alertList.length} active) ===\n`);

  for (const a of alertList) {
    const headline = a.Headline || 'No headline';
    const shortDesc = a.ShortDescription || '';
    const impact = a.Impact || '';
    const severity = a.SeverityScore || '';

    // Affected services
    const services = a.ImpactedService?.Service;
    const serviceList = services ? (Array.isArray(services) ? services : [services]) : [];
    const affected = serviceList.map(s => s.ServiceName || s.ServiceId || '').filter(Boolean);

    // Event start/end
    const eventStart = a.EventStart || '';
    const eventEnd = a.EventEnd || '';

    let severityIcon = '';
    const severityNum = parseInt(severity) || 0;
    if (severityNum >= 70) severityIcon = '🔴';
    else if (severityNum >= 40) severityIcon = '🟡';
    else severityIcon = '🟢';

    console.log(`${severityIcon} ${headline}`);
    if (affected.length) console.log(`   Routes: ${affected.join(', ')}`);
    if (impact) console.log(`   Impact: ${impact}`);
    if (eventStart || eventEnd) console.log(`   Period: ${eventStart} — ${eventEnd || 'ongoing'}`);
    if (shortDesc) {
      let desc = shortDesc;
      if (desc.length > 300) desc = desc.slice(0, 300) + '...';
      console.log(`   ${desc}`);
    }
    console.log();
  }
}

// ---- Routes ----

async function cmdRoutes() {
  // Show L lines (always available)
  console.log('\n=== CTA L Train Lines ===\n');
  for (const [code, line] of Object.entries(L_LINES)) {
    console.log(`  ${code.padEnd(5)} | ${line.name.padEnd(13)} | ${line.terminals.join(' ↔ ')}`);
  }

  // Show bus routes from GTFS if available
  if (ensureGtfs()) {
    const routes = loadRoutes();
    const typeNames = { '0': 'Tram', '1': 'Rail', '2': 'Rail', '3': 'Bus', '4': 'Ferry' };
    const busRoutes = Object.values(routes).filter(r => r.route_type === '3');

    console.log(`\n=== CTA Bus Routes (${busRoutes.length}) ===\n`);
    busRoutes.sort((a, b) => {
      const an = parseInt(a.route_short_name) || 9999;
      const bn = parseInt(b.route_short_name) || 9999;
      return an - bn || a.route_short_name.localeCompare(b.route_short_name);
    });
    for (const r of busRoutes) {
      const short = (r.route_short_name || r.route_id).padStart(5);
      const longName = r.route_long_name || '';
      console.log(`  ${short} | ${longName}`);
    }
  }
}

async function cmdBusRoutes() {
  if (!requireBusKey()) return;

  const url = `${BUS_BASE}/getroutes?key=${encodeURIComponent(CTA_BUS_API_KEY)}&format=json`;
  const data = await fetchJSON(url);
  if (handleCTAError(data, 'Bus Tracker')) return;

  const routes = data?.['bustime-response']?.routes;
  if (!routes || routes.length === 0) {
    console.log('No bus routes found.');
    return;
  }

  const routeList = Array.isArray(routes) ? routes : [routes];
  console.log(`\n=== CTA Bus Routes (${routeList.length}) ===\n`);

  for (const r of routeList) {
    const num = (r.rt || '?').padStart(5);
    const name = r.rtnm || '';
    const color = r.rtclr ? ` [${r.rtclr}]` : '';
    console.log(`  ${num} | ${name}${color}`);
  }
}

// ---- Stops ----

function cmdStops(opts) {
  if (!ensureGtfs()) return;
  const stops = loadStops();

  if (opts.search) {
    const query = opts.search.toLowerCase();
    const matches = Object.values(stops).filter(s =>
      (s.stop_name || '').toLowerCase().includes(query) ||
      (s.stop_desc || '').toLowerCase().includes(query)
    );
    if (!matches.length) { console.log(`No stops found matching '${opts.search}'.`); return; }
    console.log(`\n=== Stops matching '${opts.search}' (${matches.length} found) ===\n`);
    for (const s of matches.sort((a, b) => a.stop_name.localeCompare(b.stop_name)).slice(0, 25)) {
      console.log(`  📍 ${s.stop_name}`);
      console.log(`     ID: ${s.stop_id}  |  (${s.stop_lat}, ${s.stop_lon})`);
      if (s.stop_desc) console.log(`     ${s.stop_desc}`);
      console.log();
    }
  } else if (opts.near) {
    const parts = opts.near.split(',');
    if (parts.length !== 2) { console.log('Invalid format. Use: --near LAT,LON'); return; }
    const [lat, lon] = parts.map(Number);
    if (isNaN(lat) || isNaN(lon)) { console.log('Invalid format. Use: --near LAT,LON'); return; }
    const radius = opts.radius ? parseFloat(opts.radius) : 0.5;
    if (isNaN(radius) || radius <= 0) { console.log('Invalid --radius value. Must be a positive number (miles).'); return; }

    const nearby = [];
    for (const s of Object.values(stops)) {
      const slat = parseFloat(s.stop_lat), slon = parseFloat(s.stop_lon);
      if (isNaN(slat) || isNaN(slon)) continue;
      const dist = haversine(lat, lon, slat, slon);
      if (dist <= radius) nearby.push([dist, s]);
    }
    nearby.sort((a, b) => a[0] - b[0]);

    if (!nearby.length) { console.log(`No stops found within ${radius} miles of (${lat}, ${lon}).`); return; }
    console.log(`\n=== Nearby Stops (${nearby.length} within ${radius} mi) ===\n`);
    for (const [dist, s] of nearby.slice(0, 20)) {
      console.log(`  📍 ${s.stop_name} — ${dist.toFixed(2)} mi`);
      console.log(`     ID: ${s.stop_id}`);
      console.log();
    }
  } else {
    console.log('Provide --search <name> or --near LAT,LON');
  }
}

// ---- Route Info ----

async function cmdRouteInfo(opts) {
  const routeInput = opts.route;
  if (!routeInput) {
    console.log('Provide --route with a line code (Red, Blue, etc.) or bus route number.');
    return;
  }

  // Check if it's an L line
  const resolved = resolveTrainRoute(routeInput);
  if (L_LINES[resolved]) {
    // Use GTFS for stop list
    if (!ensureGtfs()) return;
    const routes = loadRoutes();
    const trips = loadTrips();
    const stops = loadStops();
    const line = L_LINES[resolved];

    console.log(`\n=== ${line.name} ===`);
    console.log(`    Code: ${resolved}  |  Terminals: ${line.terminals.join(' ↔ ')}`);
    console.log();

    // Find a trip for this route to get stop sequence
    const routeTrips = Object.values(trips).filter(t => {
      const rShort = routes[t.route_id]?.route_short_name || '';
      return rShort === resolved || t.route_id === resolved || rShort === line.name;
    });

    if (routeTrips.length) {
      const dir0 = routeTrips.filter(t => t.direction_id === '0');
      const sampleTrip = (dir0.length ? dir0 : routeTrips)[0];
      const stopTimes = loadStopTimesForTrip(sampleTrip.trip_id);

      if (stopTimes.length) {
        console.log(`Stops (${sampleTrip.trip_headsign || 'direction 0'}):`);
        for (const st of stopTimes) {
          const sname = stops[st.stop_id]?.stop_name || st.stop_id;
          console.log(`  ${(st.stop_sequence || '').padStart(3)}. ${sname} (ID: ${st.stop_id})`);
        }
      }
    } else {
      console.log('No trip data found in GTFS. Try running refresh-gtfs.');
    }
    return;
  }

  // Must be a bus route — try Bus Tracker API for directions and stops
  if (!requireBusKey()) {
    // Fall back to GTFS
    if (!ensureGtfs()) return;
    const routes = loadRoutes();
    const trips = loadTrips();
    const stops = loadStops();

    const routeId = Object.keys(routes).find(k =>
      routes[k].route_short_name === routeInput || k === routeInput
    );
    if (!routeId) { console.log(`Route '${routeInput}' not found.`); return; }
    const r = routes[routeId];
    console.log(`\n=== Route ${r.route_short_name || routeId} — ${r.route_long_name || ''} ===\n`);

    const routeTrips = Object.values(trips).filter(t => t.route_id === routeId);
    if (routeTrips.length) {
      const dir0 = routeTrips.filter(t => t.direction_id === '0');
      const sampleTrip = (dir0.length ? dir0 : routeTrips)[0];
      const stopTimes = loadStopTimesForTrip(sampleTrip.trip_id);
      if (stopTimes.length) {
        console.log(`Stops (${sampleTrip.trip_headsign || ''}):`);
        for (const st of stopTimes) {
          const sname = stops[st.stop_id]?.stop_name || st.stop_id;
          console.log(`  ${(st.stop_sequence || '').padStart(3)}. ${sname} (ID: ${st.stop_id})`);
        }
      }
    }
    return;
  }

  // Use Bus Tracker API
  const dirUrl = `${BUS_BASE}/getdirections?key=${encodeURIComponent(CTA_BUS_API_KEY)}&rt=${encodeURIComponent(routeInput)}&format=json`;
  const dirData = await fetchJSON(dirUrl);
  if (handleCTAError(dirData, 'Bus Tracker')) return;

  const directions = dirData?.['bustime-response']?.directions;
  if (!directions) {
    console.log(`Route '${routeInput}' not found or no direction data available.`);
    return;
  }

  const dirList = Array.isArray(directions) ? directions : [directions];
  console.log(`\n=== Route ${routeInput} ===\n`);

  for (const dir of dirList) {
    const dirName = dir.dir || dir;
    console.log(`Direction: ${dirName}`);

    const stopsUrl = `${BUS_BASE}/getstops?key=${encodeURIComponent(CTA_BUS_API_KEY)}&rt=${encodeURIComponent(routeInput)}&dir=${encodeURIComponent(dirName)}&format=json`;
    const stopsData = await fetchJSON(stopsUrl);
    if (handleCTAError(stopsData, 'Bus Tracker')) continue;

    const stopsList = stopsData?.['bustime-response']?.stops;
    if (!stopsList) continue;

    const stopsArr = Array.isArray(stopsList) ? stopsList : [stopsList];
    for (const s of stopsArr) {
      const sid = s.stpid || '?';
      const sname = s.stpnm || '?';
      console.log(`  ${sid.padStart(6)} — ${sname}`);
    }
    console.log();
  }
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

function main() {
  const args = process.argv.slice(2);
  const command = args[0];

  if (!command || command === '--help' || command === '-h') {
    console.log(`CTA Chicago Transit — OpenClaw Skill

Commands:
  arrivals        Train arrivals (--station NAME | --stop-search NAME | --mapid ID | --stop ID) [--route LINE]
  bus-arrivals    Bus predictions (--stop ID | --stop-search NAME) [--route NUM]
  vehicles        Live train positions (--route LINE)
  bus-vehicles    Live bus positions (--route NUM)
  alerts          Service alerts [--route ID]
  routes          List all CTA routes (L lines + bus)
  bus-routes      List all bus routes (from Bus Tracker API)
  stops           Search stops (--search NAME | --near LAT,LON [--radius MI])
  route-info      Route details and stops (--route LINE_OR_NUM)
  refresh-gtfs    Download/refresh GTFS static data

L Lines: Red, Blue, Brn (Brown), G (Green), Org (Orange), P (Purple), Pink, Y (Yellow)

Environment: CTA_TRAIN_API_KEY, CTA_BUS_API_KEY (free, from transitchicago.com)`);
    return;
  }

  const rest = args.slice(1);

  const optDefs = {
    route: { type: 'string' },
    stop: { type: 'string' },
    'stop-search': { type: 'string' },
    station: { type: 'string' },
    mapid: { type: 'string' },
    search: { type: 'string' },
    near: { type: 'string' },
    radius: { type: 'string' },
  };

  let opts = {};
  try {
    const parsed = parseArgs({ args: rest, options: optDefs, allowPositionals: true, strict: false });
    opts = parsed.values;
  } catch (err) {
    console.error(`Error parsing arguments: ${err.message}`);
    process.exit(1);
  }

  const handlers = {
    'refresh-gtfs': () => cmdRefreshGtfs(),
    arrivals: () => cmdArrivals(opts),
    'bus-arrivals': () => cmdBusArrivals(opts),
    vehicles: () => cmdVehicles(opts),
    'bus-vehicles': () => cmdBusVehicles(opts),
    alerts: () => cmdAlerts(opts),
    routes: () => cmdRoutes(),
    'bus-routes': () => cmdBusRoutes(),
    stops: () => cmdStops(opts),
    'route-info': () => cmdRouteInfo(opts),
  };

  if (handlers[command]) {
    Promise.resolve(handlers[command]()).catch(err => {
      if (err.name === 'TimeoutError' || err.message?.includes('timeout')) {
        console.error('Request timed out. CTA API may be slow or unreachable. Try again in a moment.');
      } else if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED') {
        console.error('Network error: Could not reach CTA API. Check your internet connection.');
      } else {
        console.error(`Error: ${err.message}`);
      }
      process.exit(1);
    });
  } else {
    console.error(`Unknown command: ${command}`);
    console.error("Run 'node scripts/cta.mjs --help' for available commands.");
    process.exit(1);
  }
}

// Run CLI when executed directly
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
if (isMain) main();

// Exports for testing
export {
  parseCTATimestamp, parseCsvLine, haversine, localTzOffsetHours,
  fmtTime, fmtTimeHM, resolveTrainRoute, searchStation, STATIONS,
};


path: tests/unit.test.mjs
content: import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  parseCTATimestamp, parseCsvLine, haversine, localTzOffsetHours,
  fmtTime, fmtTimeHM, resolveTrainRoute, searchStation, STATIONS,
} from '../scripts/cta.mjs';

// ---------------------------------------------------------------------------
// parseCTATimestamp
// ---------------------------------------------------------------------------
describe('parseCTATimestamp', () => {
  it('parses Bus Tracker format (YYYYMMDD HH:MM)', () => {
    const d = parseCTATimestamp('20260221 14:30');
    assert.equal(d.getUTCHours(), 14);
    assert.equal(d.getUTCMinutes(), 30);
    assert.equal(d.getUTCFullYear(), 2026);
    assert.equal(d.getUTCMonth(), 1); // Feb = 1
    assert.equal(d.getUTCDate(), 21);
  });

  it('parses Bus Tracker format with seconds', () => {
    const d = parseCTATimestamp('20260221 14:30:45');
    assert.equal(d.getUTCSeconds(), 45);
  });

  it('parses Train Tracker ISO format (YYYY-MM-DDTHH:MM:SS)', () => {
    const d = parseCTATimestamp('2026-02-21T08:15:00');
    assert.equal(d.getUTCHours(), 8);
    assert.equal(d.getUTCMinutes(), 15);
    assert.equal(d.getUTCSeconds(), 0);
  });

  it('returns null for empty/null input', () => {
    assert.equal(parseCTATimestamp(null), null);
    assert.equal(parseCTATimestamp(''), null);
    assert.equal(parseCTATimestamp(undefined), null);
  });
});

// ---------------------------------------------------------------------------
// parseCsvLine
// ---------------------------------------------------------------------------
describe('parseCsvLine', () => {
  it('parses simple comma-separated values', () => {
    assert.deepEqual(parseCsvLine('a,b,c'), ['a', 'b', 'c']);
  });

  it('handles quoted fields with commas', () => {
    assert.deepEqual(parseCsvLine('"hello, world",b,c'), ['hello, world', 'b', 'c']);
  });

  it('handles escaped quotes (doubled)', () => {
    assert.deepEqual(parseCsvLine('"say ""hi""",b'), ['say "hi"', 'b']);
  });

  it('handles empty fields', () => {
    assert.deepEqual(parseCsvLine('a,,c'), ['a', '', 'c']);
  });

  it('handles single field', () => {
    assert.deepEqual(parseCsvLine('hello'), ['hello']);
  });
});

// ---------------------------------------------------------------------------
// haversine
// ---------------------------------------------------------------------------
describe('haversine', () => {
  it('returns 0 for same point', () => {
    assert.equal(haversine(41.8781, -87.6298, 41.8781, -87.6298), 0);
  });

  it('calculates distance between Chicago Loop and O\'Hare (~14-15 mi)', () => {
    const dist = haversine(41.8781, -87.6298, 41.9742, -87.9073);
    assert.ok(dist > 13 && dist < 16, `Expected ~14mi, got ${dist}`);
  });

  it('returns distance in miles', () => {
    // NYC to LA ~2450mi
    const dist = haversine(40.7128, -74.0060, 34.0522, -118.2437);
    assert.ok(dist > 2400 && dist < 2500, `Expected ~2450mi, got ${dist}`);
  });
});

// ---------------------------------------------------------------------------
// localTzOffsetHours
// ---------------------------------------------------------------------------
describe('localTzOffsetHours', () => {
  it('returns -5 or -6 (CDT or CST)', () => {
    const offset = localTzOffsetHours();
    assert.ok(offset === -5 || offset === -6, `Expected -5 or -6, got ${offset}`);
  });
});

// ---------------------------------------------------------------------------
// fmtTime / fmtTimeHM
// ---------------------------------------------------------------------------
describe('fmtTime', () => {
  it('formats morning time in 12-hour with seconds', () => {
    const d = new Date(Date.UTC(2026, 0, 1, 9, 5, 30));
    assert.equal(fmtTime(d), '9:05:30 AM');
  });

  it('formats afternoon time', () => {
    const d = new Date(Date.UTC(2026, 0, 1, 14, 30, 0));
    assert.equal(fmtTime(d), '2:30:00 PM');
  });

  it('formats midnight as 12 AM', () => {
    const d = new Date(Date.UTC(2026, 0, 1, 0, 0, 0));
    assert.equal(fmtTime(d), '12:00:00 AM');
  });

  it('formats noon as 12 PM', () => {
    const d = new Date(Date.UTC(2026, 0, 1, 12, 0, 0));
    assert.equal(fmtTime(d), '12:00:00 PM');
  });
});

describe('fmtTimeHM', () => {
  it('formats without seconds', () => {
    const d = new Date(Date.UTC(2026, 0, 1, 14, 30, 0));
    assert.equal(fmtTimeHM(d), '2:30 PM');
  });

  it('pads minutes to 2 digits', () => {
    const d = new Date(Date.UTC(2026, 0, 1, 9, 5, 0));
    assert.equal(fmtTimeHM(d), '9:05 AM');
  });
});

// ---------------------------------------------------------------------------
// resolveTrainRoute
// ---------------------------------------------------------------------------
describe('resolveTrainRoute', () => {
  it('returns canonical code for exact match', () => {
    assert.equal(resolveTrainRoute('Red'), 'Red');
    assert.equal(resolveTrainRoute('Brn'), 'Brn');
  });

  it('resolves aliases (case-insensitive)', () => {
    assert.equal(resolveTrainRoute('red'), 'Red');
    assert.equal(resolveTrainRoute('blue'), 'Blue');
    assert.equal(resolveTrainRoute('brown'), 'Brn');
    assert.equal(resolveTrainRoute('green'), 'G');
    assert.equal(resolveTrainRoute('orange'), 'Org');
    assert.equal(resolveTrainRoute('purple'), 'P');
  });

  it('returns null for empty input', () => {
    assert.equal(resolveTrainRoute(null), null);
    assert.equal(resolveTrainRoute(''), null);
  });

  it('passes through unknown input', () => {
    assert.equal(resolveTrainRoute('unknown'), 'unknown');
  });
});

// ---------------------------------------------------------------------------
// searchStation
// ---------------------------------------------------------------------------
describe('searchStation', () => {
  it('finds exact match by name', () => {
    const results = searchStation("Clark/Lake");
    assert.ok(results.length > 0);
    assert.equal(results[0].name, 'Clark/Lake');
  });

  it('finds by alias', () => {
    const results = searchStation('wrigley');
    assert.ok(results.length > 0);
    assert.equal(results[0].name, 'Addison (Red)');
  });

  it('finds by partial match', () => {
    const results = searchStation('ohare');
    assert.ok(results.length > 0);
    assert.equal(results[0].name, "O'Hare");
  });

  it('returns empty for gibberish', () => {
    const results = searchStation('zzzzxyzzy');
    assert.equal(results.length, 0);
  });

  it('filters by route', () => {
    const results = searchStation('belmont', 'Red');
    assert.ok(results.length > 0);
    assert.ok(results[0].lines.includes('Red'));
  });
});

// ---------------------------------------------------------------------------
// STATIONS data integrity
// ---------------------------------------------------------------------------
describe('STATIONS', () => {
  it('has no duplicate mapids', () => {
    const seen = new Map();
    for (const s of STATIONS) {
      if (seen.has(s.mapid)) {
        assert.fail(`Duplicate mapid ${s.mapid}: "${seen.get(s.mapid)}" and "${s.name}"`);
      }
      seen.set(s.mapid, s.name);
    }
  });

  it('all mapids are 5-digit strings starting with 4', () => {
    for (const s of STATIONS) {
      assert.match(s.mapid, /^4\d{4}$/, `Invalid mapid "${s.mapid}" for ${s.name}`);
    }
  });
});


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)