Skip to main content

chess_tui/
lichess.rs

1use reqwest::blocking::Client;
2use serde::Deserialize;
3use shakmaty::Color;
4use std::error::Error;
5use std::io::{BufRead, BufReader};
6use std::sync::mpsc::Sender;
7use std::thread;
8
9const LICHESS_API_URL: &str = "https://lichess.org/api";
10
11#[derive(Debug, Deserialize)]
12#[serde(tag = "type")]
13enum GameEvent {
14    #[serde(rename = "gameFull")]
15    GameFull {
16        id: String,
17        white: Player,
18        black: Player,
19        state: GameState,
20    },
21    #[serde(rename = "gameState")]
22    GameState(GameState),
23    #[serde(rename = "chatLine")]
24    ChatLine,
25}
26
27// Event stream types for /api/stream/event
28#[derive(Debug, Deserialize)]
29#[serde(tag = "type")]
30enum EventStreamEvent {
31    #[serde(rename = "gameStart")]
32    GameStart { game: EventStreamGame },
33    #[serde(rename = "gameFinish")]
34    GameFinish { game: EventStreamGame },
35    #[serde(rename = "challenge")]
36    Challenge,
37    #[serde(rename = "challengeCanceled")]
38    ChallengeCanceled,
39    #[serde(rename = "challengeDeclined")]
40    ChallengeDeclined,
41}
42
43#[derive(Debug, Deserialize)]
44struct EventStreamGame {
45    #[serde(rename = "gameId")]
46    game_id: String,
47    color: String,
48    // We only need game_id and color, but keep minimal structure for deserialization
49    #[serde(flatten)]
50    _rest: serde_json::Value,
51}
52
53#[derive(Debug, Deserialize)]
54struct Player {
55    id: Option<String>,
56}
57
58#[derive(Debug, Deserialize)]
59struct GameState {
60    moves: String,
61    status: String,
62}
63
64#[derive(Debug, Deserialize, Clone)]
65pub struct OngoingGame {
66    #[serde(rename = "gameId")]
67    pub game_id: String,
68    #[serde(rename = "fullId")]
69    pub full_id: String,
70    pub color: String,
71    pub fen: String,
72    pub opponent: OpponentInfo,
73    #[serde(rename = "isMyTurn")]
74    pub is_my_turn: bool,
75}
76
77#[derive(Debug, Deserialize, Clone)]
78pub struct OpponentInfo {
79    pub id: Option<String>,
80    pub username: String,
81    pub rating: Option<u32>,
82}
83
84#[derive(Debug, Deserialize)]
85struct OngoingGamesResponse {
86    #[serde(rename = "nowPlaying")]
87    now_playing: Vec<OngoingGame>,
88}
89
90#[derive(Debug, Deserialize, Clone)]
91pub struct Puzzle {
92    pub game: PuzzleGame,
93    pub puzzle: PuzzleInfo,
94}
95
96#[derive(Debug, Deserialize, Clone)]
97pub struct PuzzleGame {
98    pub id: String,
99    pub pgn: String,
100    pub clock: String,
101}
102
103#[derive(Debug, Deserialize, Clone)]
104pub struct PuzzleInfo {
105    pub id: String,
106    pub rating: u32,
107    pub plays: u32,
108    #[serde(rename = "initialPly")]
109    pub initial_ply: u32,
110    pub solution: Vec<String>,
111    pub themes: Vec<String>,
112}
113
114#[derive(Debug, Deserialize, Clone)]
115pub struct UserProfile {
116    pub id: String,
117    pub username: String,
118    #[serde(default)]
119    pub perfs: Option<Perfs>,
120    #[serde(default)]
121    pub title: Option<String>,
122    #[serde(default)]
123    pub online: Option<bool>,
124    #[serde(default)]
125    pub profile: Option<ProfileInfo>,
126    #[serde(default)]
127    pub seen_at: Option<u64>,
128    #[serde(default)]
129    pub created_at: Option<u64>,
130    #[serde(default)]
131    pub count: Option<UserCounts>,
132}
133
134#[derive(Debug, Deserialize, Clone)]
135pub struct ProfileInfo {
136    #[serde(default)]
137    pub bio: Option<String>,
138    #[serde(default)]
139    pub country: Option<String>,
140    #[serde(default)]
141    pub location: Option<String>,
142    #[serde(default, rename = "firstName")]
143    pub first_name: Option<String>,
144    #[serde(default, rename = "lastName")]
145    pub last_name: Option<String>,
146}
147
148#[derive(Debug, Deserialize, Clone)]
149pub struct UserCounts {
150    #[serde(default)]
151    pub all: Option<u32>,
152    #[serde(default)]
153    pub rated: Option<u32>,
154    #[serde(default)]
155    pub ai: Option<u32>,
156    #[serde(default)]
157    pub draw: Option<u32>,
158    #[serde(default, rename = "drawH")]
159    pub draw_h: Option<u32>,
160    #[serde(default)]
161    pub loss: Option<u32>,
162    #[serde(default, rename = "lossH")]
163    pub loss_h: Option<u32>,
164    #[serde(default)]
165    pub win: Option<u32>,
166    #[serde(default, rename = "winH")]
167    pub win_h: Option<u32>,
168    #[serde(default)]
169    pub bookmark: Option<u32>,
170    #[serde(default)]
171    pub playing: Option<u32>,
172    #[serde(default)]
173    pub import: Option<u32>,
174    #[serde(default)]
175    pub me: Option<u32>,
176}
177
178#[derive(Debug, Deserialize, Clone)]
179pub struct Perfs {
180    #[serde(default)]
181    pub bullet: Option<Perf>,
182    #[serde(default)]
183    pub blitz: Option<Perf>,
184    #[serde(default)]
185    pub rapid: Option<Perf>,
186    #[serde(default)]
187    pub classical: Option<Perf>,
188    #[serde(default)]
189    pub puzzle: Option<Perf>,
190}
191
192#[derive(Debug, Deserialize, Clone)]
193pub struct Perf {
194    pub rating: u32,
195    #[serde(default)]
196    pub rd: Option<u32>,
197    #[serde(default)]
198    pub prog: Option<i32>,
199}
200
201#[derive(Debug, Deserialize, Clone)]
202pub struct RatingHistoryEntry {
203    pub name: String,
204    pub points: Vec<[i32; 4]>, // [year, month, day, rating]
205}
206
207#[derive(Clone)]
208pub struct LichessClient {
209    token: String,
210    client: Client,
211}
212
213impl LichessClient {
214    pub fn new(token: String) -> Self {
215        Self {
216            token,
217            client: Client::builder()
218                .timeout(None)
219                .http1_only()
220                .build()
221                .unwrap_or_else(|_| Client::new()),
222        }
223    }
224
225    pub fn get_my_profile(&self) -> Result<String, Box<dyn Error>> {
226        let url = format!("{}/account", LICHESS_API_URL);
227        let response = self
228            .client
229            .get(&url)
230            .header(
231                "User-Agent",
232                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
233            )
234            .bearer_auth(&self.token)
235            .send()?;
236
237        if !response.status().is_success() {
238            return Err(format!("Failed to fetch profile: {}", response.status()).into());
239        }
240
241        let player: Player = response.json()?;
242        player.id.ok_or("Profile missing ID".into())
243    }
244
245    pub fn get_user_profile(&self) -> Result<UserProfile, Box<dyn Error>> {
246        let url = format!("{}/account", LICHESS_API_URL);
247        log::info!("Fetching user profile from: {}", url);
248
249        let response = self
250            .client
251            .get(&url)
252            .header(
253                "User-Agent",
254                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
255            )
256            .bearer_auth(&self.token)
257            .send()?;
258
259        if !response.status().is_success() {
260            return Err(format!("Failed to fetch user profile: {}", response.status()).into());
261        }
262
263        let profile: UserProfile = response.json()?;
264        log::info!("Fetched user profile: {}", profile.username);
265        Ok(profile)
266    }
267
268    pub fn get_rating_history(
269        &self,
270        username: &str,
271    ) -> Result<Vec<RatingHistoryEntry>, Box<dyn Error>> {
272        let url = format!("{}/user/{}/rating-history", LICHESS_API_URL, username);
273        log::info!("Fetching rating history from: {}", url);
274
275        let response = self
276            .client
277            .get(&url)
278            .header(
279                "User-Agent",
280                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
281            )
282            .bearer_auth(&self.token)
283            .send()?;
284
285        if !response.status().is_success() {
286            return Err(format!("Failed to fetch rating history: {}", response.status()).into());
287        }
288
289        let history: Vec<RatingHistoryEntry> = response.json()?;
290        log::info!(
291            "Fetched rating history with {} time controls",
292            history.len()
293        );
294        Ok(history)
295    }
296
297    pub fn get_ongoing_games(&self) -> Result<Vec<OngoingGame>, Box<dyn Error>> {
298        let url = format!("{}/account/playing", LICHESS_API_URL);
299        log::info!("Fetching ongoing games from: {}", url);
300
301        let response = self
302            .client
303            .get(&url)
304            .header(
305                "User-Agent",
306                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
307            )
308            .bearer_auth(&self.token)
309            .send()?;
310
311        if !response.status().is_success() {
312            if response.status() == reqwest::StatusCode::UNAUTHORIZED {
313                return Err("Invalid token. Please check your token or generate a new one.".into());
314            }
315            return Err(format!("Failed to fetch ongoing games: {}", response.status()).into());
316        }
317
318        let games_response: OngoingGamesResponse = response.json()?;
319        log::info!("Found {} ongoing games", games_response.now_playing.len());
320        Ok(games_response.now_playing)
321    }
322
323    pub fn get_next_puzzle(&self) -> Result<Puzzle, Box<dyn Error>> {
324        // Use /puzzle/next but add a cache-busting parameter to ensure we get a new puzzle
325        // Adding a timestamp parameter forces the server to return a fresh puzzle
326        use std::time::{SystemTime, UNIX_EPOCH};
327        let _timestamp = SystemTime::now()
328            .duration_since(UNIX_EPOCH)
329            .unwrap_or_default()
330            .as_millis();
331        let url = format!("{}/puzzle/next?t={}", LICHESS_API_URL, _timestamp);
332
333        log::info!("Fetching puzzle from: {}", url);
334
335        let response = self
336            .client
337            .get(&url)
338            .header(
339                "User-Agent",
340                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
341            )
342            .bearer_auth(&self.token)
343            .send()?;
344
345        if !response.status().is_success() {
346            return Err(format!("Failed to fetch puzzle: {}", response.status()).into());
347        }
348
349        let puzzle: Puzzle = response.json()?;
350        log::info!(
351            "Fetched puzzle: {} (rating: {})",
352            puzzle.puzzle.id,
353            puzzle.puzzle.rating
354        );
355        Ok(puzzle)
356    }
357
358    /// Submit puzzle result to Lichess
359    /// According to https://lichess.org/api#tag/puzzles/post/apipuzzlebatchangle
360    /// This endpoint expects a JSON body with puzzle results
361    pub fn submit_puzzle_result(
362        &self,
363        puzzle_id: &str,
364        win: bool,
365        time: Option<u32>,
366    ) -> Result<(), Box<dyn Error>> {
367        use serde_json::json;
368
369        // The API expects a JSON object with a "solutions" field containing an array
370        // Each result has: id, win (boolean), and optionally time (milliseconds)
371        let payload = json!({
372            "solutions": [{
373                "id": puzzle_id,
374                "win": win,
375                "time": time.unwrap_or(0)
376            }]
377        });
378
379        let url = format!("{}/puzzle/batch/angle", LICHESS_API_URL);
380        log::info!("=== SUBMITTING PUZZLE RESULT ===");
381        log::info!("URL: {}", url);
382        log::info!("Puzzle ID: {}, Win: {}, Time: {:?}ms", puzzle_id, win, time);
383        log::info!(
384            "Payload: {}",
385            serde_json::to_string_pretty(&payload).unwrap_or_default()
386        );
387
388        let response = self
389            .client
390            .post(&url)
391            .header(
392                "User-Agent",
393                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
394            )
395            .header("Content-Type", "application/json")
396            .bearer_auth(&self.token)
397            .json(&payload)
398            .send()?;
399
400        let status = response.status();
401        let response_text = response.text().unwrap_or_default();
402
403        log::info!("Response status: {}", status);
404        log::info!("Response body: {}", response_text);
405
406        if !status.is_success() {
407            log::error!(
408                "Failed to submit puzzle result: {} - {}",
409                status,
410                response_text
411            );
412            return Err(format!(
413                "Failed to submit puzzle result: {} - {}",
414                status, response_text
415            )
416            .into());
417        }
418
419        log::info!("✓ Puzzle result submitted successfully to Lichess!");
420        Ok(())
421    }
422
423    fn spawn_event_stream_thread(
424        &self,
425        initial_game_ids: std::collections::HashSet<String>,
426        cancellation_token: std::sync::Arc<std::sync::atomic::AtomicBool>,
427        game_found_tx: Sender<Result<(String, Color), String>>,
428    ) {
429        let token = self.token.clone();
430        let client = self.client.clone();
431
432        thread::spawn(move || {
433            log::info!("Starting event stream thread for game detection");
434            let url = format!("{}/stream/event", LICHESS_API_URL);
435
436            loop {
437                if cancellation_token.load(std::sync::atomic::Ordering::Relaxed) {
438                    break;
439                }
440
441                log::info!("Connecting to event stream...");
442                let response = match client
443                    .get(&url)
444                    .header(
445                        "User-Agent",
446                        "chess-tui (https://github.com/thomas-mauran/chess-tui)",
447                    )
448                    .bearer_auth(&token)
449                    .send()
450                {
451                    Ok(resp) => resp,
452                    Err(e) => {
453                        log::error!("Failed to connect to event stream: {}", e);
454                        std::thread::sleep(std::time::Duration::from_secs(5));
455                        continue;
456                    }
457                };
458
459                if !response.status().is_success() {
460                    log::error!("Event stream returned status: {}", response.status());
461                    std::thread::sleep(std::time::Duration::from_secs(5));
462                    continue;
463                }
464
465                log::info!("Connected to event stream");
466                let reader = BufReader::new(response);
467
468                for line in reader.lines() {
469                    if cancellation_token.load(std::sync::atomic::Ordering::Relaxed) {
470                        break;
471                    }
472
473                    let line = match line {
474                        Ok(l) => l,
475                        Err(e) => {
476                            log::error!("Error reading event stream line: {}", e);
477                            break; // Reconnect
478                        }
479                    };
480
481                    // Empty lines are keep-alive (sent every 7 seconds)
482                    if line.trim().is_empty() {
483                        continue;
484                    }
485
486                    log::debug!("Received event: {}", line);
487
488                    // Parse the event
489                    match serde_json::from_str::<EventStreamEvent>(&line) {
490                        Ok(EventStreamEvent::GameStart { game }) => {
491                            // Check if this is a new game
492                            if !initial_game_ids.contains(&game.game_id) {
493                                let color = if game.color == "white" {
494                                    Color::White
495                                } else {
496                                    Color::Black
497                                };
498                                log::info!(
499                                    "Event stream found new game: {} as {:?}",
500                                    game.game_id,
501                                    color
502                                );
503                                let _ = game_found_tx.send(Ok((game.game_id.clone(), color)));
504                                return; // Exit thread after finding game
505                            }
506                        }
507                        Ok(EventStreamEvent::GameFinish { game }) => {
508                            log::debug!("Game finished: {}", game.game_id);
509                        }
510                        Ok(EventStreamEvent::Challenge) => {
511                            log::debug!("Challenge event received");
512                        }
513                        Ok(EventStreamEvent::ChallengeCanceled) => {
514                            log::debug!("Challenge canceled event received");
515                        }
516                        Ok(EventStreamEvent::ChallengeDeclined) => {
517                            log::debug!("Challenge declined event received");
518                        }
519                        Err(e) => {
520                            log::warn!("Failed to parse event: {} - {}", line, e);
521                        }
522                    }
523                }
524
525                // Stream ended, reconnect after a short delay
526                log::warn!("Event stream ended, reconnecting in 5 seconds...");
527                std::thread::sleep(std::time::Duration::from_secs(5));
528            }
529        });
530    }
531
532    pub fn seek_game(
533        &self,
534        time: u32,
535        increment: u32,
536        cancellation_token: std::sync::Arc<std::sync::atomic::AtomicBool>,
537    ) -> Result<(String, Color), Box<dyn Error>> {
538        let url = format!("{}/board/seek", LICHESS_API_URL);
539
540        // Track games we've seen before seeking to detect new games
541        let initial_games = self.get_ongoing_games().unwrap_or_default();
542        let initial_game_ids: std::collections::HashSet<String> =
543            initial_games.iter().map(|g| g.game_id.clone()).collect();
544        log::info!(
545            "Tracking {} existing games before seek",
546            initial_game_ids.len()
547        );
548
549        // Open Event stream FIRST (as recommended by API docs)
550        let (game_found_tx, game_found_rx) =
551            std::sync::mpsc::channel::<Result<(String, Color), String>>();
552
553        self.spawn_event_stream_thread(
554            initial_game_ids.clone(),
555            cancellation_token.clone(),
556            game_found_tx,
557        );
558
559        // Small delay to ensure event stream is connected before seeking
560        std::thread::sleep(std::time::Duration::from_millis(500));
561
562        log::info!("Creating seek request...");
563        let request_builder = self
564            .client
565            .post(&url)
566            .header(
567                "User-Agent",
568                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
569            )
570            .header("Content-Type", "application/x-www-form-urlencoded")
571            .bearer_auth(&self.token);
572
573        let response = if time == 0 && increment == 0 {
574            // Correspondence game
575            request_builder
576                .form(&[
577                    ("rated", "true"),
578                    ("variant", "standard"),
579                    ("ratingRange", ""),
580                    ("days", "3"),
581                    ("color", "random"),
582                ])
583                .send()?
584        } else {
585            // Real-time game
586            let time_str = time.to_string();
587            let inc_str = increment.to_string();
588            request_builder
589                .form(&[
590                    ("rated", "true"),
591                    ("variant", "standard"),
592                    ("ratingRange", ""),
593                    ("time", &time_str),
594                    ("increment", &inc_str),
595                    ("color", "random"),
596                ])
597                .send()?
598        };
599
600        let status = response.status();
601        if !status.is_success() {
602            let error_text = response.text().unwrap_or_default();
603            log::error!("Seek request failed: {} - {}", status, error_text);
604
605            if status == reqwest::StatusCode::FORBIDDEN {
606                return Err("Token missing permissions. Please generate a new token with 'board:play' scope enabled.".into());
607            }
608            if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
609                return Err(
610                    "Rate limit exceeded. Please wait a minute before trying again.".into(),
611                );
612            }
613            if status == reqwest::StatusCode::UNAUTHORIZED {
614                return Err("Invalid token. Please check your token or generate a new one.".into());
615            }
616            if status == reqwest::StatusCode::BAD_REQUEST {
617                return Err(format!("Invalid seek parameters: {}", error_text).into());
618            }
619            return Err(format!("Failed to seek game: {} - {}", status, error_text).into());
620        }
621
622        log::info!("Seek created. Waiting for event stream to detect game start...");
623
624        // For correspondence: response completes immediately with seek ID
625        if time == 0 && increment == 0 {
626            let seek_id: String = response.text()?.trim().to_string();
627            log::info!("Correspondence seek ID: {}", seek_id);
628            // Seek stays active on server, event stream will notify when game starts
629            // Response is consumed, connection already closed
630        } else {
631            // For real-time: keep connection open to keep seek active
632            // Closing the connection cancels the seek (as per API docs)
633            // We keep response alive in this scope - when function returns, connection closes
634            let reader = BufReader::new(response);
635
636            for line in reader.lines() {
637                // Check cancellation first - return immediately to close connection
638                if cancellation_token.load(std::sync::atomic::Ordering::Relaxed) {
639                    log::info!("Seek cancelled by user - closing connection to cancel seek");
640                    // Returning here will drop 'response', closing the connection and canceling the seek
641                    return Err("Seek cancelled".into());
642                }
643
644                // Check if event stream found a game
645                match game_found_rx.try_recv() {
646                    Ok(result) => {
647                        log::info!("Game found via event stream");
648                        // Returning here closes the connection (seek is no longer needed)
649                        return result.map_err(|e| e.into());
650                    }
651                    Err(std::sync::mpsc::TryRecvError::Empty) => {
652                        // No game yet, keep connection open
653                    }
654                    Err(std::sync::mpsc::TryRecvError::Disconnected) => {
655                        log::warn!("Event stream disconnected");
656                        // Continue reading seek stream
657                    }
658                }
659
660                // Read keep-alive messages (empty lines)
661                if let Err(e) = line {
662                    log::debug!("Seek stream read error (may be normal): {}", e);
663                    break; // Connection closed by server
664                }
665            }
666            // Connection closed by server = seek expired or was accepted
667            log::info!("Seek connection closed by server");
668        }
669
670        // Wait for event stream to detect game
671        // For correspondence: wait indefinitely until game found or cancelled
672        // For real-time: wait a bit longer in case event arrived after connection closed
673        let max_wait_seconds = if time == 0 && increment == 0 {
674            300 // 5 minutes for correspondence (seeks can take time)
675        } else {
676            10 // 10 seconds for real-time (should be faster)
677        };
678
679        log::info!(
680            "Waiting for event stream to detect game (up to {} seconds)...",
681            max_wait_seconds
682        );
683        for _ in 0..max_wait_seconds {
684            if cancellation_token.load(std::sync::atomic::Ordering::Relaxed) {
685                return Err("Seek cancelled".into());
686            }
687
688            // Use blocking receive with timeout to avoid busy-waiting
689            match game_found_rx.recv_timeout(std::time::Duration::from_secs(1)) {
690                Ok(result) => {
691                    log::info!("Game found via event stream");
692                    return result.map_err(|e| e.into());
693                }
694                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
695                    // Continue waiting
696                }
697                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
698                    return Err("Event stream disconnected".into());
699                }
700            }
701        }
702
703        Err("Seek timed out. No game started.".into())
704    }
705
706    pub fn join_game(
707        &self,
708        game_id: &str,
709        my_id: String,
710    ) -> Result<(String, Color), Box<dyn Error>> {
711        log::info!("Attempting to join game: {}", game_id);
712
713        // First, try to get the game from ongoing games (for already-started games)
714        if let Ok(ongoing_games) = self.get_ongoing_games() {
715            if let Some(game) = ongoing_games.iter().find(|g| g.game_id == game_id) {
716                let color = if game.color == "white" {
717                    Color::White
718                } else {
719                    Color::Black
720                };
721                log::info!("Found game in ongoing games: {} as {:?}", game_id, color);
722                return Ok((game_id.to_string(), color));
723            }
724        }
725
726        // If not in ongoing games, try to accept the challenge in case it hasn't been accepted yet
727        log::info!("Attempting to accept challenge: {}", game_id);
728        let accept_url = format!("{}/challenge/{}/accept", LICHESS_API_URL, game_id);
729        let accept_response = self
730            .client
731            .post(&accept_url)
732            .header(
733                "User-Agent",
734                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
735            )
736            .bearer_auth(&self.token)
737            .send();
738
739        let challenge_accepted = match accept_response {
740            Ok(resp) => {
741                if resp.status().is_success() {
742                    log::info!("Successfully accepted challenge");
743                    true
744                } else if resp.status() == reqwest::StatusCode::NOT_FOUND {
745                    // Challenge not found - might be a game ID, not a challenge ID
746                    log::info!("Challenge not found, treating as game ID");
747                    false
748                } else {
749                    log::info!(
750                        "Challenge accept returned {}, game may already be started or you created the challenge",
751                        resp.status()
752                    );
753                    false
754                }
755            }
756            Err(e) => {
757                log::warn!(
758                    "Failed to accept challenge: {}, will try to stream game anyway",
759                    e
760                );
761                false
762            }
763        };
764
765        // If we accepted the challenge, wait a bit for the game to start
766        if challenge_accepted {
767            std::thread::sleep(std::time::Duration::from_millis(1000));
768        }
769
770        // Wait for the game to appear in ongoing games or be streamable
771        // Poll for up to 30 seconds (for challenges that need to be accepted)
772        const MAX_POLL_ATTEMPTS: usize = 30;
773        const POLL_INTERVAL_MS: u64 = 1000;
774
775        for attempt in 0..MAX_POLL_ATTEMPTS {
776            // Check ongoing games first
777            if let Ok(ongoing_games) = self.get_ongoing_games() {
778                if let Some(game) = ongoing_games.iter().find(|g| g.game_id == game_id) {
779                    let color = if game.color == "white" {
780                        Color::White
781                    } else {
782                        Color::Black
783                    };
784                    log::info!(
785                        "Found game in ongoing games after polling: {} as {:?}",
786                        game_id,
787                        color
788                    );
789                    return Ok((game_id.to_string(), color));
790                }
791            }
792
793            // Try to stream the game
794            let url = format!("{}/board/game/{}/stream", LICHESS_API_URL, game_id);
795            let response = match self
796                .client
797                .get(&url)
798                .header(
799                    "User-Agent",
800                    "chess-tui (https://github.com/thomas-mauran/chess-tui)",
801                )
802                .bearer_auth(&self.token)
803                .send()
804            {
805                Ok(resp) => {
806                    if !resp.status().is_success() {
807                        if resp.status() == reqwest::StatusCode::NOT_FOUND {
808                            // Game not started yet, continue polling
809                            if attempt < MAX_POLL_ATTEMPTS - 1 {
810                                log::info!(
811                                    "Game not started yet, waiting... (attempt {}/{})",
812                                    attempt + 1,
813                                    MAX_POLL_ATTEMPTS
814                                );
815                                std::thread::sleep(std::time::Duration::from_millis(
816                                    POLL_INTERVAL_MS,
817                                ));
818                                continue;
819                            } else {
820                                return Err("Game not found or hasn't started yet. Make sure the challenge has been accepted by your opponent.".into());
821                            }
822                        }
823                        if resp.status() == reqwest::StatusCode::FORBIDDEN {
824                            return Err(
825                                "Cannot join this game. You may not be a participant.".into()
826                            );
827                        }
828                        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
829                            return Err(
830                                "Invalid token. Please check your token or generate a new one."
831                                    .into(),
832                            );
833                        }
834                        return Err(format!("Failed to join game: {}", resp.status()).into());
835                    }
836                    resp
837                }
838                Err(e) => {
839                    log::warn!("Failed to connect to stream: {}, will retry", e);
840                    if attempt < MAX_POLL_ATTEMPTS - 1 {
841                        std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
842                        continue;
843                    } else {
844                        return Err(format!("Failed to connect to game stream: {}", e).into());
845                    }
846                }
847            };
848
849            log::info!("Connected to game stream. Status: {}", response.status());
850
851            let reader = BufReader::new(response);
852            let mut line_count = 0;
853            const MAX_LINES: usize = 100; // Limit to prevent infinite loop
854
855            for line in reader.lines() {
856                let line = line?;
857                line_count += 1;
858
859                if line_count > MAX_LINES {
860                    break; // Break inner loop, will retry outer loop
861                }
862
863                if line.trim().is_empty() {
864                    continue;
865                }
866
867                log::info!("Received game event: {}", line);
868                match serde_json::from_str::<GameEvent>(&line) {
869                    Ok(event) => {
870                        if let GameEvent::GameFull {
871                            id, white, black, ..
872                        } = event
873                        {
874                            // Determine our color based on player IDs
875                            let color = if white.id.as_ref() == Some(&my_id) {
876                                Color::White
877                            } else if black.id.as_ref() == Some(&my_id) {
878                                Color::Black
879                            } else {
880                                return Err("You are not a participant in this game.".into());
881                            };
882
883                            log::info!("Successfully joined game {} as {:?}", id, color);
884                            return Ok((id, color));
885                        }
886                        // For already-started games, we might only get GameState events
887                        // In this case, we need to determine color from ongoing games
888                        if let GameEvent::GameState(_) = event {
889                            log::info!("Received GameState event, checking ongoing games");
890                            // Try to get color from ongoing games
891                            if let Ok(ongoing_games) = self.get_ongoing_games() {
892                                if let Some(game) =
893                                    ongoing_games.iter().find(|g| g.game_id == game_id)
894                                {
895                                    let color = if game.color == "white" {
896                                        Color::White
897                                    } else {
898                                        Color::Black
899                                    };
900                                    log::info!(
901                                        "Found game in ongoing games after GameState: {} as {:?}",
902                                        game_id,
903                                        color
904                                    );
905                                    return Ok((game_id.to_string(), color));
906                                }
907                            }
908                            // If we can't determine color, break and retry
909                            break;
910                        }
911                    }
912                    Err(e) => {
913                        log::error!("Failed to parse event: {} - Error: {}", line, e);
914                    }
915                }
916            }
917
918            // If we got here, we didn't get a GameFull event, wait and retry
919            if attempt < MAX_POLL_ATTEMPTS - 1 {
920                log::info!(
921                    "Game not fully started yet, waiting... (attempt {}/{})",
922                    attempt + 1,
923                    MAX_POLL_ATTEMPTS
924                );
925                std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
926            }
927        }
928
929        Err("Game not started yet or failed to join. If you created a challenge, make sure your opponent has accepted it. Otherwise, try using 'My Ongoing Games' to join.".into())
930    }
931
932    /// Get turn count and last move from public API
933    /// Returns (turn_count, last_move) - useful when setting up a game
934    pub fn get_game_turn_count_and_last_move(
935        &self,
936        game_id: &str,
937    ) -> Result<(usize, Option<String>), Box<dyn Error>> {
938        // Use public stream endpoint /api/stream/game/{id} (same as polling)
939        // Read the first line (gameFull event), then close the stream
940        let url = format!("{}/stream/game/{}", LICHESS_API_URL, game_id);
941        let response = self
942            .client
943            .get(&url)
944            .header(
945                "User-Agent",
946                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
947            )
948            .send()?;
949
950        if !response.status().is_success() {
951            return Err(format!("Failed to get game info: {}", response.status()).into());
952        }
953
954        // Read the first line which should be the gameFull event
955        let reader = BufReader::new(response);
956        if let Some(Ok(line)) = reader.lines().next() {
957            if line.trim().is_empty() {
958                return Err("Empty response from stream".into());
959            }
960
961            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
962                log::info!("Game info JSON: {:?}", json);
963                // Extract turns (number of half-moves)
964                let turns = json
965                    .get("turns")
966                    .and_then(|v| v.as_u64())
967                    .map(|v| v as usize)
968                    .unwrap_or(0);
969
970                // Extract last move
971                let last_move = json
972                    .get("lastMove")
973                    .and_then(|v| v.as_str())
974                    .map(|s| s.to_string());
975
976                return Ok((turns, last_move));
977            } else {
978                return Err("Failed to parse JSON from stream".into());
979            }
980        }
981
982        Err("No data received from stream".into())
983    }
984
985    fn spawn_game_stream_thread(
986        &self,
987        game_id: String,
988        move_tx: Sender<String>,
989        _player_color: Option<Color>,
990    ) {
991        let client: Client = self.client.clone();
992        let token = self.token.clone();
993
994        thread::spawn(move || {
995            log::info!("Starting game stream thread for Lichess game {}", game_id);
996            let mut last_turns: Option<usize> = None;
997            let mut last_move_seen: Option<String> = None;
998            let mut last_status: Option<String> = None;
999
1000            loop {
1001                let stream_url = format!("{}/board/game/stream/{}", LICHESS_API_URL, game_id);
1002                log::info!("Connecting to board game stream: {}", stream_url);
1003
1004                let response = match client
1005                    .get(&stream_url)
1006                    .header(
1007                        "User-Agent",
1008                        "chess-tui (https://github.com/thomas-mauran/chess-tui)",
1009                    )
1010                    .bearer_auth(&token)
1011                    .send()
1012                {
1013                    Ok(resp) => resp,
1014                    Err(e) => {
1015                        log::error!("Failed to connect to board game stream: {}", e);
1016                        std::thread::sleep(std::time::Duration::from_secs(5));
1017                        continue;
1018                    }
1019                };
1020
1021                if !response.status().is_success() {
1022                    if response.status() == reqwest::StatusCode::NOT_FOUND {
1023                        log::info!("Game {} not found, stopping stream", game_id);
1024                        break;
1025                    }
1026                    log::error!("Game stream returned status: {}", response.status());
1027                    std::thread::sleep(std::time::Duration::from_secs(5));
1028                    continue;
1029                }
1030
1031                log::info!("Connected to game stream");
1032                let reader = BufReader::new(response);
1033
1034                for line in reader.lines() {
1035                    let line = match line {
1036                        Ok(l) => l,
1037                        Err(e) => {
1038                            log::error!("Error reading game stream line: {}", e);
1039                            break; // Reconnect
1040                        }
1041                    };
1042
1043                    // Empty lines are keep-alive
1044                    if line.trim().is_empty() {
1045                        continue;
1046                    }
1047
1048                    log::debug!("Game stream received: {}", line);
1049
1050                    // Parse as GameEvent (gameFull or gameState)
1051                    match serde_json::from_str::<GameEvent>(&line) {
1052                        Ok(GameEvent::GameFull {
1053                            id: _,
1054                            white: _,
1055                            black: _,
1056                            state,
1057                        }) => {
1058                            // Handle initial game state
1059                            if last_turns.is_none() {
1060                                let turns = state.moves.split_whitespace().count();
1061                                log::info!("Initial game state: {} moves", turns);
1062                                last_turns = Some(turns);
1063
1064                                // Extract last move from moves string
1065                                if turns > 0 {
1066                                    if let Some(last_move) = state.moves.split_whitespace().last() {
1067                                        log::info!("Sending initial move: {}", last_move);
1068                                        let _ = move_tx.send(last_move.to_string());
1069                                        last_move_seen = Some(last_move.to_string());
1070                                    }
1071                                }
1072
1073                                // Send initial move count
1074                                let _ = move_tx.send(format!("INIT_MOVES:{}", turns));
1075                            }
1076                        }
1077                        Ok(GameEvent::GameState(state)) => {
1078                            // Handle game state updates (new moves)
1079                            let current_turns = state.moves.split_whitespace().count();
1080
1081                            // Check for status changes
1082                            if last_status.as_ref() != Some(&state.status) {
1083                                log::info!("Game status changed: {}", state.status);
1084                                match state.status.as_str() {
1085                                    "mate" | "checkmate" => {
1086                                        let _ = move_tx.send("GAME_STATUS:checkmate".to_string());
1087                                    }
1088                                    "draw" | "stalemate" | "repetition" | "insufficient"
1089                                    | "fifty" => {
1090                                        let _ = move_tx.send("GAME_STATUS:draw".to_string());
1091                                    }
1092                                    "resign" => {
1093                                        let _ = move_tx.send("GAME_STATUS:resign".to_string());
1094                                    }
1095                                    "aborted" => {
1096                                        let _ = move_tx.send("GAME_STATUS:aborted".to_string());
1097                                    }
1098                                    _ => {}
1099                                }
1100                                last_status = Some(state.status.clone());
1101                            }
1102
1103                            // Check for new moves
1104                            if let Some(last_turns_val) = last_turns {
1105                                if current_turns > last_turns_val {
1106                                    // New move detected
1107                                    if let Some(new_move) = state.moves.split_whitespace().last() {
1108                                        if last_move_seen.as_ref() != Some(&new_move.to_string()) {
1109                                            log::info!("New move from stream: {}", new_move);
1110                                            let _ = move_tx.send(new_move.to_string());
1111                                            last_move_seen = Some(new_move.to_string());
1112                                            last_turns = Some(current_turns);
1113                                        }
1114                                    }
1115                                }
1116                            } else {
1117                                // First gameState event - initialize
1118                                last_turns = Some(current_turns);
1119                                if current_turns > 0 {
1120                                    if let Some(last_move) = state.moves.split_whitespace().last() {
1121                                        let _ = move_tx.send(last_move.to_string());
1122                                        last_move_seen = Some(last_move.to_string());
1123                                    }
1124                                }
1125                                let _ = move_tx.send(format!("INIT_MOVES:{}", current_turns));
1126                            }
1127                        }
1128                        Ok(GameEvent::ChatLine) => {
1129                            // Ignore chat lines
1130                            continue;
1131                        }
1132                        Err(e) => {
1133                            // Try parsing as raw JSON for status updates
1134                            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
1135                                // Handle gameFull events from public stream (different format)
1136                                if json.get("fen").is_some() && json.get("turns").is_some() {
1137                                    if last_turns.is_none() {
1138                                        if let Some(turns) =
1139                                            json.get("turns").and_then(|v| v.as_u64())
1140                                        {
1141                                            let turns_usize = turns as usize;
1142                                            last_turns = Some(turns_usize);
1143                                            if turns_usize > 0 {
1144                                                if let Some(last_move) =
1145                                                    json.get("lastMove").and_then(|v| v.as_str())
1146                                                {
1147                                                    let _ = move_tx.send(last_move.to_string());
1148                                                    last_move_seen = Some(last_move.to_string());
1149                                                }
1150                                            }
1151                                            let _ =
1152                                                move_tx.send(format!("INIT_MOVES:{}", turns_usize));
1153                                        }
1154                                    } else {
1155                                        // Check for new moves
1156                                        if let Some(last_move) =
1157                                            json.get("lastMove").and_then(|v| v.as_str())
1158                                        {
1159                                            if last_move_seen.as_ref()
1160                                                != Some(&last_move.to_string())
1161                                            {
1162                                                log::info!("New move from stream: {}", last_move);
1163                                                let _ = move_tx.send(last_move.to_string());
1164                                                last_move_seen = Some(last_move.to_string());
1165                                                if let Some(turns) =
1166                                                    json.get("turns").and_then(|v| v.as_u64())
1167                                                {
1168                                                    last_turns = Some(turns as usize);
1169                                                }
1170                                            }
1171                                        }
1172                                    }
1173
1174                                    // Check status
1175                                    if let Some(status) = json
1176                                        .get("status")
1177                                        .and_then(|s| s.get("name"))
1178                                        .and_then(|n| n.as_str())
1179                                    {
1180                                        if last_status.as_ref() != Some(&status.to_string()) {
1181                                            match status {
1182                                                "mate" | "checkmate" => {
1183                                                    let _ = move_tx
1184                                                        .send("GAME_STATUS:checkmate".to_string());
1185                                                }
1186                                                "draw" | "stalemate" | "repetition"
1187                                                | "insufficient" | "fifty" => {
1188                                                    let _ = move_tx
1189                                                        .send("GAME_STATUS:draw".to_string());
1190                                                }
1191                                                "resign" => {
1192                                                    let _ = move_tx
1193                                                        .send("GAME_STATUS:resign".to_string());
1194                                                }
1195                                                "aborted" => {
1196                                                    let _ = move_tx
1197                                                        .send("GAME_STATUS:aborted".to_string());
1198                                                }
1199                                                _ => {}
1200                                            }
1201                                            last_status = Some(status.to_string());
1202                                        }
1203                                    }
1204                                }
1205                            } else {
1206                                log::warn!("Failed to parse game stream event: {} - {}", line, e);
1207                            }
1208                        }
1209                    }
1210                }
1211
1212                // Stream ended, reconnect
1213                log::warn!("Game stream ended, reconnecting in 5 seconds...");
1214                std::thread::sleep(std::time::Duration::from_secs(5));
1215            }
1216
1217            log::info!("Game stream thread ended for game {}", game_id);
1218        });
1219    }
1220
1221    pub fn stream_game(
1222        &self,
1223        game_id: String,
1224        move_tx: Sender<String>,
1225        player_color: Option<Color>,
1226    ) -> Result<(), Box<dyn Error>> {
1227        // Verify we have a valid game_id (safety check - should always be true for Lichess)
1228        if game_id.is_empty() {
1229            log::warn!(
1230                "Cannot start stream: empty game_id (this should not happen for Lichess games)"
1231            );
1232            return Ok(());
1233        }
1234
1235        // Use streaming - keeps connection open and reads moves as they arrive
1236        self.spawn_game_stream_thread(game_id, move_tx, player_color);
1237
1238        Ok(())
1239    }
1240
1241    pub fn make_move(&self, game_id: &str, move_str: &str) -> Result<(), Box<dyn Error>> {
1242        let url = format!(
1243            "{}/board/game/{}/move/{}",
1244            LICHESS_API_URL, game_id, move_str
1245        );
1246        let response = self.client.post(&url).bearer_auth(&self.token).send()?;
1247
1248        if !response.status().is_success() {
1249            return Err(format!("Failed to make move: {}", response.status()).into());
1250        }
1251        Ok(())
1252    }
1253
1254    /// Resign a game
1255    /// Uses the board API endpoint /board/game/{id}/resign
1256    pub fn resign_game(&self, game_id: &str) -> Result<(), Box<dyn Error>> {
1257        let url = format!("{}/board/game/{}/resign", LICHESS_API_URL, game_id);
1258        log::info!("Resigning game: {}", game_id);
1259
1260        let response = self
1261            .client
1262            .post(&url)
1263            .header(
1264                "User-Agent",
1265                "chess-tui (https://github.com/thomas-mauran/chess-tui)",
1266            )
1267            .bearer_auth(&self.token)
1268            .send()?;
1269
1270        if !response.status().is_success() {
1271            let status = response.status();
1272            let error_text = response.text().unwrap_or_default();
1273            log::error!("Failed to resign game: {} - {}", status, error_text);
1274            return Err(format!("Failed to resign game: {} - {}", status, error_text).into());
1275        }
1276
1277        log::info!("Successfully resigned game: {}", game_id);
1278        Ok(())
1279    }
1280}