use std::collections::BTreeSet;
use std::fs::{self, remove_dir_all, remove_file};
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};

use aisling::{
    Color as AislingColor, Effect, EffectConfig, EffectKind, Gradient, GradientDirection, Loader,
    LoaderConfig, LoaderKind, LoaderProgress,
};
use clap::Parser;
use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
    Frame, Terminal,
    backend::CrosstermBackend,
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color as TuiColor, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use reqwest::StatusCode;
use reqwest::blocking::{Client, Response};
use serde::Deserialize;
use thiserror::Error;

const GITHUB_API: &str = "https://api.github.com";
const DEFAULT_PAGE_SIZE: usize = 100;
const BATCH_SIZE_CHOICES: [usize; 5] = [1, 2, 3, 6, 8];

#[derive(Parser, Clone)]
#[command(
    name = "thesa",
    version,
    about = "Clone public GitHub repositories for a user or org"
)]
struct Cli {
    /// Target in one of: owner, owner/repo, github.com/owner, github.com/owner/repo
    target: Option<String>,

    /// Output directory where repositories are cloned
    #[arg(short = 'o', long = "output", default_value = "./archives")]
    output: PathBuf,

    /// GitHub token for API requests (optional)
    #[arg(short = 't', long = "token")]
    token: Option<String>,

    /// Clone depth for each repository
    #[arg(long = "depth")]
    depth: Option<u32>,

    /// Number of repositories to clone at once
    #[arg(short = 'c', long = "concurrency", default_value_t = 4)]
    concurrency: usize,

    /// Skip existing directories instead of failing or replacing
    #[arg(long = "skip-existing")]
    skip_existing: bool,

    /// Filter repositories by substring in repo name
    #[arg(long = "filter")]
    filter: Option<String>,

    /// Print plan only; do not execute clones
    #[arg(long = "dry-run")]
    dry_run: bool,

    /// Force the interactive Ratatui interface
    #[arg(short = 'T', long = "tui")]
    tui: bool,
}

#[derive(Debug, Clone)]
enum Target {
    Owner(String),
    Repo { owner: String, repo: String },
}

#[derive(Debug, Clone)]
struct RepoItem {
    name: String,
    full_name: String,
    clone_url: String,
}

#[derive(Debug, Deserialize)]
struct RepoResponse {
    name: String,
    #[serde(rename = "full_name")]
    full_name: String,
    #[serde(rename = "clone_url")]
    clone_url: String,
    private: bool,
}

#[derive(Debug, Clone)]
enum CloneStatus {
    Cloned,
    Skipped,
}

#[derive(Debug, Clone, Copy)]
enum BatchState {
    Pending,
    Running,
    Done,
    Skipped,
    Failed,
}

#[derive(Debug)]
enum CloneAction {
    Skipped,
    Running(Child),
}

#[derive(Debug, Default)]
struct CloneSummary {
    cloned: usize,
    skipped: usize,
    failed: Vec<String>,
}

#[derive(Error, Debug)]
enum GitArchiverError {
    #[error("invalid target '{0}'")]
    InvalidTarget(String),

    #[error("GitHub API returned {status}: {body}")]
    ApiError { status: u16, body: String },

    #[error("GitHub entity '{0}' was not found")]
    NotFound(String),

    #[error("repo '{0}' is private, only public repos can be cloned")]
    PrivateRepo(String),

    #[error("output path '{0}' is not a directory")]
    OutputNotDirectory(String),

    #[error("'git' executable not found in PATH")]
    MissingGit,

    #[error("clone failed for '{repo}': {message}")]
    CloneFailed { repo: String, message: String },

    #[error("{0}")]
    Message(String),

    #[error("I/O error: {0}")]
    Io(#[from] io::Error),

    #[error("request error: {0}")]
    Request(#[from] reqwest::Error),
}

type Result<T> = std::result::Result<T, GitArchiverError>;

struct TerminalSession {
    terminal: Terminal<CrosstermBackend<io::Stdout>>,
}

impl TerminalSession {
    fn new() -> Result<Self> {
        enable_raw_mode()?;
        let mut stdout = io::stdout();
        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
        let backend = CrosstermBackend::new(stdout);
        let mut terminal = Terminal::new(backend)?;
        terminal.clear()?;
        Ok(Self { terminal })
    }
}

impl Drop for TerminalSession {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let _ = execute!(
            self.terminal.backend_mut(),
            DisableMouseCapture,
            LeaveAlternateScreen
        );
        let _ = self.terminal.show_cursor();
    }
}

#[derive(Debug, Default)]
struct UiState {
    show_details: bool,
    abort_requested: bool,
    panel_lines: usize,
}

fn main() {
    if let Err(err) = run() {
        eprintln!("error: {err}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let mut args = Cli::parse();

    if args.token.is_none() {
        args.token = std::env::var("GITHUB_TOKEN").ok();
    }

    let interactive = args.tui || args.target.is_none();
    if args.tui && !io::stdout().is_terminal() {
        return Err(GitArchiverError::Message(
            "--tui requires an interactive terminal".to_string(),
        ));
    }
    let mut terminal_session = if interactive && io::stdout().is_terminal() {
        Some(TerminalSession::new()?)
    } else {
        None
    };

    if interactive {
        args.target = Some(if let Some(session) = terminal_session.as_mut() {
            prompt_target_tui_inner(&mut session.terminal)?
        } else {
            prompt_target()?
        });
    }

    let target = parse_target(args.target.as_deref().unwrap_or_default())?;

    if args.output == PathBuf::from("./archives") {
        args.output = default_output_path(&target);
    }

    let output = prepare_output_dir(&args.output)?;

    let client = Client::builder()
        .user_agent("thesa")
        .build()
        .map_err(GitArchiverError::Request)?;

    let mut repos = if let Some(session) = terminal_session.as_mut() {
        fetch_repositories_tui(
            &mut session.terminal,
            &client,
            &target,
            args.token.as_deref(),
        )?
    } else {
        fetch_repositories_for_target(&client, &target, args.token.as_deref())?
    };

    if interactive {
        repos = match &target {
            Target::Owner(_) if terminal_session.is_some() => {
                let session = terminal_session.as_mut().expect("checked above");
                pick_repositories_tui_inner(&mut session.terminal, &repos, &mut args.concurrency)?
            }
            Target::Owner(_) => pick_repositories_interactively(&repos)?,
            Target::Repo { .. } => repos,
        };
    } else if let Some(pattern) = args.filter.as_deref() {
        repos.retain(|repo| repo.name.contains(pattern));
    }

    if repos.is_empty() {
        drop(terminal_session);
        println!("No repositories matched your request.");
        return Ok(());
    }

    if args.dry_run {
        drop(terminal_session);
        println!("Dry run: {} repositories would be processed", repos.len());
        for repo in &repos {
            println!(" - {}", repo.full_name);
        }
        return Ok(());
    }

    let mut ui_state = UiState {
        show_details: true,
        ..UiState::default()
    };

    let summary = if let Some(session) = terminal_session.as_mut() {
        clone_repositories_ratatui_inner(
            &mut session.terminal,
            &repos,
            &output,
            &args,
            &mut ui_state,
        )?
    } else if io::stdout().is_terminal() {
        clone_repositories_ratatui(&repos, &output, &args, &mut ui_state)?
    } else {
        let (ui_tx, ui_rx) = mpsc::channel::<char>();
        let _ui_thread = thread::spawn(move || {
            let stdin = io::stdin();
            loop {
                let mut line = String::new();
                if stdin.read_line(&mut line).is_err() || line.is_empty() {
                    break;
                }

                let mut chars = line.chars().filter(|c| !c.is_whitespace());
                if let Some(command) = chars.next() {
                    if ui_tx.send(command).is_err() {
                        break;
                    }
                }
            }
        });

        if args.concurrency > 1 {
            clone_repositories_batched(&repos, &output, &args, &ui_rx, &mut ui_state)?
        } else {
            clone_repositories_serial(&repos, &output, &args, &ui_rx, &mut ui_state)?
        }
    };

    drop(terminal_session);

    if ui_state.abort_requested {
        println!("Aborted by user input");
    }

    println!(
        "\nCompleted: {} cloned, {} skipped, {} failed",
        summary.cloned,
        summary.skipped,
        summary.failed.len()
    );

    if !summary.failed.is_empty() {
        eprintln!("Failures:");
        for item in summary.failed {
            eprintln!(" - {}", item);
        }
        return Err(GitArchiverError::Message(
            "some repositories failed".to_string(),
        ));
    }

    Ok(())
}

fn prompt_target_tui_inner(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<String> {
    let mut input = String::new();
    let mut frame_index = 0usize;
    let mut status = "enter GitHub owner or repo name".to_string();

    loop {
        terminal.draw(|frame| {
            draw_target_tui(frame, &input, &status, frame_index);
        })?;

        frame_index = frame_index.wrapping_add(1);
        if !event::poll(Duration::from_millis(50))? {
            continue;
        }

        let Event::Key(key) = event::read()? else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }

        match key.code {
            KeyCode::Esc => return Err(GitArchiverError::Message("aborted by user".to_string())),
            KeyCode::Enter => {
                let target = input.trim().to_string();
                if target.is_empty() {
                    status = "repo name or owner is required".to_string();
                    continue;
                }
                if parse_target(&target).is_ok() {
                    return Ok(target);
                }
                status = "try an owner like octocat or a repo like octocat/Spoon-Knife".to_string();
            }
            KeyCode::Backspace => {
                input.pop();
            }
            KeyCode::Char(ch) => {
                input.push(ch);
            }
            _ => {}
        }
    }
}

fn pick_repositories_tui_inner(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    repos: &[RepoItem],
    batch_size: &mut usize,
) -> Result<Vec<RepoItem>> {
    if repos.is_empty() {
        return Ok(vec![]);
    }

    let mut query = String::new();
    let mut selected = BTreeSet::new();
    let mut cursor = 0usize;
    let mut frame_index = 0usize;
    let mut batch_overlay = false;
    let mut status =
        "Space checks repos; Enter clones checked repos, or visible if none checked".to_string();

    loop {
        let filtered = filtered_repo_indices(repos, &query);
        if cursor >= filtered.len() {
            cursor = filtered.len().saturating_sub(1);
        }

        terminal.draw(|frame| {
            draw_repo_picker_tui(
                frame,
                repos,
                &filtered,
                &selected,
                cursor,
                &query,
                &status,
                frame_index,
                *batch_size,
                batch_overlay,
            );
        })?;
        frame_index = frame_index.wrapping_add(1);

        if !event::poll(Duration::from_millis(50))? {
            continue;
        }

        let Event::Key(key) = event::read()? else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }

        if batch_overlay {
            match key.code {
                KeyCode::Esc | KeyCode::Char('b') | KeyCode::Enter => {
                    batch_overlay = false;
                    status = format!("batch size set to {}; Enter clones repos", *batch_size);
                }
                KeyCode::Char(ch) => {
                    if let Some(value) = batch_size_from_key(ch) {
                        *batch_size = value;
                        batch_overlay = false;
                        status = format!("batch size set to {value}; Enter clones repos");
                    }
                }
                KeyCode::Left | KeyCode::Up => {
                    *batch_size = previous_batch_size(*batch_size);
                    status = format!("batch size set to {}; Enter accepts", *batch_size);
                }
                KeyCode::Right | KeyCode::Down => {
                    *batch_size = next_batch_size(*batch_size);
                    status = format!("batch size set to {}; Enter accepts", *batch_size);
                }
                _ => {}
            }
            continue;
        }

        match key.code {
            KeyCode::Esc => return Err(GitArchiverError::Message("aborted by user".to_string())),
            KeyCode::Enter => {
                let picked = if selected.is_empty() {
                    filtered
                } else {
                    selected.iter().copied().collect::<Vec<_>>()
                };
                if picked.is_empty() {
                    status = "no repositories selected or visible".to_string();
                    continue;
                }
                return Ok(picked
                    .into_iter()
                    .map(|index| repos[index].clone())
                    .collect());
            }
            KeyCode::Up => cursor = cursor.saturating_sub(1),
            KeyCode::Down => {
                if cursor + 1 < filtered.len() {
                    cursor += 1;
                }
            }
            KeyCode::Char(' ') => {
                if let Some(repo_index) = filtered.get(cursor).copied() {
                    if !selected.insert(repo_index) {
                        selected.remove(&repo_index);
                    }
                    let checked = selected.len();
                    status = if checked == 0 {
                        "nothing checked; Enter clones visible repos".to_string()
                    } else {
                        format!("{checked} checked; Enter clones checked repos only")
                    };
                }
            }
            KeyCode::Char('a') => {
                for repo_index in &filtered {
                    selected.insert(*repo_index);
                }
                status = format!("checked {} visible repos", filtered.len());
            }
            KeyCode::Char('c') => {
                selected.clear();
                query.clear();
                cursor = 0;
                status = "cleared filter and selection".to_string();
            }
            KeyCode::Char('b') => {
                batch_overlay = true;
                status = "choose batch size: 1 2 3 6 8".to_string();
            }
            KeyCode::Backspace => {
                query.pop();
                cursor = 0;
            }
            KeyCode::Char(ch) => {
                query.push(ch);
                cursor = 0;
            }
            _ => {}
        }
    }
}

fn fetch_repositories_for_target(
    client: &Client,
    target: &Target,
    token: Option<&str>,
) -> Result<Vec<RepoItem>> {
    match target {
        Target::Owner(owner) => match list_repos_for_owner(client, owner, token) {
            Ok(items) => Ok(items),
            Err(GitArchiverError::NotFound(_)) => list_repos_for_org(client, owner, token),
            Err(other) => Err(other),
        },
        Target::Repo { owner, repo } => Ok(vec![fetch_single_repo(client, owner, repo, token)?]),
    }
}

fn batch_size_from_key(ch: char) -> Option<usize> {
    match ch {
        '1' => Some(1),
        '2' => Some(2),
        '3' => Some(3),
        '6' => Some(6),
        '8' => Some(8),
        _ => None,
    }
}

fn previous_batch_size(current: usize) -> usize {
    let index = BATCH_SIZE_CHOICES
        .iter()
        .position(|value| *value == current)
        .unwrap_or(0);
    BATCH_SIZE_CHOICES[index.saturating_sub(1)]
}

fn next_batch_size(current: usize) -> usize {
    let index = BATCH_SIZE_CHOICES
        .iter()
        .position(|value| *value == current)
        .unwrap_or(0);
    BATCH_SIZE_CHOICES[(index + 1).min(BATCH_SIZE_CHOICES.len() - 1)]
}

fn fetch_repositories_tui(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    client: &Client,
    target: &Target,
    token: Option<&str>,
) -> Result<Vec<RepoItem>> {
    let client = client.clone();
    let target = target.clone();
    let token = token.map(str::to_string);
    let loading_label = match &target {
        Target::Owner(owner) => format!("loading public repos for {owner}"),
        Target::Repo { owner, repo } => format!("loading repo {owner}/{repo}"),
    };
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let result = fetch_repositories_for_target(&client, &target, token.as_deref());
        let _ = tx.send(result);
    });

    let mut tick = 0usize;
    loop {
        if let Ok(result) = rx.try_recv() {
            return result;
        }

        terminal.draw(|frame| draw_loading_tui(frame, &loading_label, tick))?;
        tick = tick.wrapping_add(1);

        if event::poll(Duration::from_millis(80))? {
            let Event::Key(key) = event::read()? else {
                continue;
            };
            if key.kind == KeyEventKind::Press && matches!(key.code, KeyCode::Esc) {
                return Err(GitArchiverError::Message("aborted by user".to_string()));
            }
        }
    }
}

fn filtered_repo_indices(repos: &[RepoItem], query: &str) -> Vec<usize> {
    if query.trim().is_empty() {
        return (0..repos.len()).collect();
    }

    let query = query.to_lowercase();
    repos
        .iter()
        .enumerate()
        .filter(|(_, repo)| {
            repo.name.to_lowercase().contains(&query)
                || repo.full_name.to_lowercase().contains(&query)
        })
        .map(|(index, _)| index)
        .collect()
}

fn draw_target_tui(frame: &mut Frame<'_>, input: &str, status: &str, tick: usize) {
    let area = frame.area();
    let prompt_label = "repo/owner> ";
    if area.width < 24 || area.height < 8 {
        frame.render_widget(
            Paragraph::new(format!("{prompt_label}{input}\n{status}")),
            area,
        );
        return;
    }

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(if area.height >= 32 { 22 } else { 3 }),
            Constraint::Min(5),
            Constraint::Length(2),
        ])
        .split(area);

    draw_thesa_header(frame, layout[0], tick, status, "interactive setup");

    let body = Paragraph::new(Text::from(vec![
        Line::from(vec![
            Span::styled(prompt_label, prompt_style()),
            Span::styled(input.to_string(), Style::default().fg(theme_white())),
        ]),
        Line::from(""),
        Line::from(vec![
            Span::styled("examples ", Style::default().fg(theme_dim())),
            Span::styled("octocat", Style::default().fg(theme_blue())),
            Span::raw("  "),
            Span::styled("octocat/Spoon-Knife", Style::default().fg(theme_green())),
            Span::raw("  "),
            Span::styled(
                "https://github.com/owner/repo",
                Style::default().fg(theme_red()),
            ),
        ]),
    ]))
    .block(knott_block(" Target ", theme_blue()))
    .wrap(Wrap { trim: false });
    frame.render_widget(body, layout[1]);
    draw_thesa_bottom_bar(frame, layout[2], status, &["Enter accept", "Esc quit"]);

    let cursor_x = layout[1]
        .x
        .saturating_add(1)
        .saturating_add(prompt_label.len() as u16)
        .saturating_add(input.chars().count() as u16)
        .min(layout[1].x + layout[1].width.saturating_sub(2));
    frame.set_cursor_position((cursor_x, layout[1].y + 1));
}

fn draw_loading_tui(frame: &mut Frame<'_>, label: &str, tick: usize) {
    let area = frame.area();
    if area.width < 30 || area.height < 8 {
        frame.render_widget(Paragraph::new(format!("thesa\n{label}")), area);
        return;
    }

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(if area.height >= 32 { 22 } else { 3 }),
            Constraint::Min(5),
            Constraint::Length(2),
        ])
        .split(area);

    draw_thesa_header(frame, layout[0], tick, label, "loading");

    let loader = Loader::with_config(
        LoaderKind::Tqdm,
        LoaderConfig::default()
            .with_size(layout[1].width.saturating_sub(4).max(12) as usize, 1)
            .with_label("github")
            .with_unit("repo")
            .with_fraction(false),
    );
    let loader_line = loader.line(
        tick,
        LoaderProgress::from_counts((tick % 97) as u64 + 1, 100),
    );
    let effect = aisling_effect_text(EffectKind::Wipe, tick, layout[1].width as usize, 5);
    let panel = Paragraph::new(Text::from(vec![
        section_line("Loading"),
        Line::styled(label.to_string(), Style::default().fg(theme_white())),
        Line::raw(loader_line),
        Line::from(""),
        Line::raw(effect),
    ]))
    .block(knott_block(" Aisling Loader ", theme_blue()))
    .wrap(Wrap { trim: false });
    frame.render_widget(panel, layout[1]);
    draw_thesa_bottom_bar(frame, layout[2], label, &["Esc cancel"]);
}

#[allow(clippy::too_many_arguments)]
fn draw_repo_picker_tui(
    frame: &mut Frame<'_>,
    repos: &[RepoItem],
    filtered: &[usize],
    selected: &BTreeSet<usize>,
    cursor: usize,
    query: &str,
    status: &str,
    tick: usize,
    batch_size: usize,
    batch_overlay: bool,
) {
    let area = frame.area();
    if area.width < 28 || area.height < 8 {
        frame.render_widget(
            Paragraph::new(format!(
                "repos {} visible / {} total\nfilter> {}\n{}",
                filtered.len(),
                repos.len(),
                query,
                status,
            )),
            area,
        );
        return;
    }

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(if area.height >= 34 { 22 } else { 3 }),
            Constraint::Min(8),
            Constraint::Length(2),
        ])
        .split(area);
    draw_thesa_header(frame, layout[0], tick, status, "repo picker");

    let body = Layout::default()
        .direction(Direction::Horizontal)
        .constraints(if layout[1].width >= 96 {
            [Constraint::Percentage(72), Constraint::Percentage(28)]
        } else {
            [Constraint::Percentage(100), Constraint::Percentage(0)]
        })
        .split(layout[1]);

    let clone_scope = if selected.is_empty() {
        format!("visible: {}", filtered.len())
    } else {
        format!("checked only: {}", selected.len())
    };
    let items = filtered
        .iter()
        .map(|repo_index| {
            let repo = &repos[*repo_index];
            let marker = if selected.contains(repo_index) {
                "[x]"
            } else {
                "[ ]"
            };
            ListItem::new(Line::from(vec![
                Span::styled(
                    marker,
                    Style::default()
                        .fg(theme_green())
                        .add_modifier(Modifier::BOLD),
                ),
                Span::raw(" "),
                Span::styled(repo.full_name.clone(), Style::default().fg(theme_white())),
            ]))
        })
        .collect::<Vec<_>>();
    let mut state = ListState::default();
    if !filtered.is_empty() {
        state.select(Some(cursor));
    }

    let list_title = format!(
        " Repositories | filter: {} | {} | batch: {} ",
        if query.is_empty() { "all" } else { query },
        clone_scope,
        batch_size,
    );
    let list = List::new(items)
        .block(knott_block(&list_title, theme_blue()))
        .highlight_style(
            Style::default()
                .bg(TuiColor::White)
                .fg(TuiColor::Black)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("> ");
    frame.render_stateful_widget(list, body[0], &mut state);

    if body[1].width > 0 {
        let effect = aisling_effect_text(EffectKind::Matrix, tick, body[1].width as usize, 6);
        let sidebar = Paragraph::new(Text::from(vec![
            section_line("Controls"),
            help_pair_line("type", "filter public repos"),
            help_pair_line("Space", "check/uncheck repo"),
            help_pair_line("a", "check visible"),
            help_pair_line("b", "batch size overlay"),
            help_pair_line("c", "clear"),
            help_pair_line("Enter", "checked only, or visible"),
            Line::from(""),
            section_line("Aisling Matrix"),
            Line::raw(effect),
        ]))
        .block(knott_block(" Knott-style panel ", theme_dim()))
        .wrap(Wrap { trim: false });
        frame.render_widget(sidebar, body[1]);
    }

    draw_thesa_bottom_bar(
        frame,
        layout[2],
        status,
        &[
            "Up/Down move",
            "Space check",
            "b batch",
            "Enter clone",
            "Esc quit",
        ],
    );

    if batch_overlay {
        draw_batch_overlay(frame, area, batch_size);
    }
}

fn draw_batch_overlay(frame: &mut Frame<'_>, area: Rect, batch_size: usize) {
    let width = area.width.min(58).max(30.min(area.width));
    let height = area.height.min(11).max(7.min(area.height));
    let x = area.x + area.width.saturating_sub(width) / 2;
    let y = area.y + area.height.saturating_sub(height) / 2;
    let overlay = Rect::new(x, y, width, height);
    frame.render_widget(Clear, overlay);

    let mut choice_spans = Vec::new();
    for value in BATCH_SIZE_CHOICES {
        if !choice_spans.is_empty() {
            choice_spans.push(Span::raw("  "));
        }
        let style = if value == batch_size {
            Style::default()
                .bg(TuiColor::White)
                .fg(TuiColor::Black)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default()
                .fg(theme_blue())
                .add_modifier(Modifier::BOLD)
        };
        choice_spans.push(Span::styled(format!(" {value} "), style));
    }

    let panel = Paragraph::new(Text::from(vec![
        section_line("Batch Size"),
        Line::styled(
            "How many repos to pull at once",
            Style::default().fg(theme_dim()),
        ),
        Line::from(""),
        Line::from(choice_spans),
        Line::from(""),
        help_pair_line("1/2/3/6/8", "select and close"),
        help_pair_line("Left/Right", "cycle choices"),
        help_pair_line("b / Enter", "close overlay"),
    ]))
    .block(knott_block(" Batch Options ", theme_red()))
    .wrap(Wrap { trim: false });
    frame.render_widget(panel, overlay);
}

fn draw_thesa_header(frame: &mut Frame<'_>, area: Rect, tick: usize, status: &str, mode: &str) {
    if area.height == 0 {
        return;
    }

    if area.height < 22 {
        let mut spans = vec![Span::styled(
            "[ thesa ]     t h e s a",
            Style::default()
                .fg(theme_white())
                .add_modifier(Modifier::BOLD),
        )];
        append_status_chip(&mut spans, "mode", mode, theme_blue());
        append_status_chip(&mut spans, "state", status, theme_green());
        let header = Paragraph::new(Text::from(vec![Line::from(spans)]))
            .block(knott_block(" [ thesa ] ", theme_blue()));
        frame.render_widget(header, area);
        return;
    }

    let mut lines = thesa_banner_lines();
    let mut primary = Vec::new();
    append_status_chip(&mut primary, "mode", mode, theme_blue());
    append_status_chip(&mut primary, "state", status, theme_green());
    append_status_chip(&mut primary, "effect", "aisling", theme_red());
    append_status_chip(&mut primary, "frame", tick.to_string(), theme_dim());
    lines.push(Line::from(primary));

    let header = Paragraph::new(Text::from(lines))
        .block(knott_block(" [ thesa ]     t h e s a ", theme_blue()))
        .alignment(Alignment::Left);
    frame.render_widget(header, area);
}

fn draw_thesa_bottom_bar(frame: &mut Frame<'_>, area: Rect, status: &str, keys: &[&str]) {
    if area.height == 0 {
        return;
    }

    let mut status_spans = vec![
        Span::styled("status: ", Style::default().fg(theme_dim())),
        Span::styled(status.to_string(), Style::default().fg(theme_white())),
    ];
    append_status_chip(&mut status_spans, "ui", "ratatui", theme_blue());
    append_status_chip(&mut status_spans, "fx", "aisling", theme_green());

    let key_line = Line::from(
        keys.iter()
            .flat_map(|key| {
                let (head, detail) = key.split_once(' ').unwrap_or((key, ""));
                vec![
                    Span::styled(head.to_string(), key_style()),
                    Span::raw(if detail.is_empty() {
                        "  ".to_string()
                    } else {
                        format!(" {detail}  ")
                    }),
                ]
            })
            .collect::<Vec<_>>(),
    );

    let lines = if area.height <= 1 {
        vec![Line::from(status_spans)]
    } else {
        vec![Line::from(status_spans), key_line]
    };
    frame.render_widget(
        Paragraph::new(Text::from(lines)).style(Style::default().fg(TuiColor::Gray)),
        area,
    );
}

fn knott_block<'a>(title: &'a str, color: TuiColor) -> Block<'a> {
    Block::default()
        .title(title)
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(color))
}

fn theme_white() -> TuiColor {
    TuiColor::White
}

fn theme_red() -> TuiColor {
    TuiColor::Rgb(210, 35, 45)
}

fn theme_blue() -> TuiColor {
    TuiColor::Rgb(75, 145, 220)
}

fn theme_green() -> TuiColor {
    TuiColor::Rgb(70, 190, 125)
}

fn theme_dim() -> TuiColor {
    TuiColor::Rgb(105, 115, 130)
}

fn prompt_style() -> Style {
    Style::default()
        .fg(theme_blue())
        .add_modifier(Modifier::BOLD)
}

fn key_style() -> Style {
    Style::default()
        .bg(TuiColor::White)
        .fg(TuiColor::Black)
        .add_modifier(Modifier::BOLD)
}

fn section_line(label: &str) -> Line<'static> {
    Line::from(Span::styled(
        label.to_string(),
        Style::default()
            .fg(theme_white())
            .add_modifier(Modifier::BOLD),
    ))
}

fn help_pair_line(head: &str, detail: &str) -> Line<'static> {
    Line::from(vec![
        Span::styled(
            format!("{head:<12}"),
            Style::default()
                .fg(theme_blue())
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(detail.to_string(), Style::default().fg(theme_dim())),
    ])
}

fn append_status_chip(
    spans: &mut Vec<Span<'static>>,
    label: &str,
    value: impl Into<String>,
    color: TuiColor,
) {
    if !spans.is_empty() {
        spans.push(Span::raw(" "));
    }
    spans.push(Span::styled("[", Style::default().fg(theme_dim())));
    spans.push(Span::styled(
        label.to_string(),
        Style::default().fg(theme_dim()),
    ));
    spans.push(Span::raw(" "));
    spans.push(Span::styled(
        value.into(),
        Style::default().fg(color).add_modifier(Modifier::BOLD),
    ));
    spans.push(Span::styled("]", Style::default().fg(theme_dim())));
}

fn thesa_banner_lines() -> Vec<Line<'static>> {
    const BANNER: [&str; 19] = [
        "/                                      ",
        "          #/                                       ",
        "    #     ##                                       ",
        "   ##     ##                                       ",
        "   ##     ##                                       ",
        " ######## ##  /##      /##       /###      /###    ",
        "########  ## / ###    / ###     / #### /  / ###  / ",
        "   ##     ##/   ###  /   ###   ##  ###/  /   ###/  ",
        "   ##     ##     ## ##    ### ####      ##    ##   ",
        "   ##     ##     ## ########    ###     ##    ##   ",
        "   ##     ##     ## #######       ###   ##    ##   ",
        "   ##     ##     ## ##              ### ##    ##   ",
        "   ##     ##     ## ####    /  /###  ## ##    /#   ",
        "   ##     ##     ##  ######/  / #### /   ####/ ##  ",
        "    ##     ##    ##   #####      ###/     ###   ## ",
        "                 /                                 ",
        "                /                                  ",
        "               /                                   ",
        "              /                                    ",
    ];

    BANNER
        .iter()
        .enumerate()
        .map(|(index, line)| {
            let color = match index % 4 {
                0 => theme_white(),
                1 => theme_blue(),
                2 => theme_red(),
                _ => theme_dim(),
            };
            Line::from(Span::styled(
                (*line).to_string(),
                Style::default().fg(color).add_modifier(Modifier::BOLD),
            ))
        })
        .collect()
}

fn prompt_target() -> Result<String> {
    loop {
        let value = prompt_line("GitHub target [owner or owner/repo]: ")?;
        if value.is_empty() {
            println!("Target is required.");
            continue;
        }

        if parse_target(&value).is_ok() {
            return Ok(value);
        }

        println!(
            "Invalid target. Examples: torvalds, torvalds/linux, https://github.com/torvalds/linux"
        );
    }
}

fn default_output_path(target: &Target) -> PathBuf {
    let slug = match target {
        Target::Owner(owner) => owner.clone(),
        Target::Repo { repo, .. } => repo.clone(),
    };

    PathBuf::from("./archives").join(slug)
}

fn prompt_line(prompt: &str) -> Result<String> {
    print!("{prompt}");
    io::stdout().flush()?;

    let mut value = String::new();
    io::stdin()
        .read_line(&mut value)
        .map_err(GitArchiverError::Io)?;
    Ok(value.trim().to_string())
}

fn pick_repositories_interactively(repos: &[RepoItem]) -> Result<Vec<RepoItem>> {
    if repos.is_empty() {
        return Ok(vec![]);
    }

    let snapshot: Vec<RepoItem> = repos.to_vec();

    loop {
        println!("Found {} public repositories", snapshot.len());

        let query = prompt_line("Search public repos (blank = all, q = quit): ")?;
        if query.eq_ignore_ascii_case("q") {
            return Err(GitArchiverError::Message("aborted by user".to_string()));
        }

        let filtered = if query.is_empty() {
            snapshot.clone()
        } else {
            let normalized_query = query.to_lowercase();
            snapshot
                .iter()
                .filter(|repo| repo.name.to_lowercase().contains(&normalized_query))
                .cloned()
                .collect::<Vec<_>>()
        };

        if filtered.is_empty() {
            println!("No matches for '{query}'.");
            continue;
        }

        for (index, repo) in filtered.iter().enumerate() {
            println!("{:>3}. {}", index + 1, repo.full_name);
        }

        let selection = prompt_line("Clone [A]ll, specific indexes (1,3-5) or [q] to quit [a]: ")?;
        if selection.is_empty() || selection.eq_ignore_ascii_case("a") {
            return Ok(filtered);
        }
        if selection.eq_ignore_ascii_case("q") {
            return Err(GitArchiverError::Message("aborted by user".to_string()));
        }

        match parse_indices(&selection, filtered.len()) {
            Ok(indices) => {
                let selected = indices
                    .into_iter()
                    .map(|idx| filtered[idx].clone())
                    .collect::<Vec<_>>();
                return Ok(selected);
            }
            Err(message) => {
                println!("{message}");
            }
        }
    }
}

fn parse_indices(input: &str, max_len: usize) -> std::result::Result<Vec<usize>, String> {
    let mut unique = Vec::new();

    for token in input.split(',') {
        let token = token.trim();
        if token.is_empty() {
            continue;
        }

        if let Some((start, end)) = token.split_once('-') {
            let start = start
                .trim()
                .parse::<usize>()
                .map_err(|_| "Invalid start of range".to_string())?;
            let end = end
                .trim()
                .parse::<usize>()
                .map_err(|_| "Invalid end of range".to_string())?;

            if start == 0 || end == 0 || start > end {
                return Err(
                    "Invalid range: indexes are 1-based and must be start <= end".to_string(),
                );
            }

            let (start, end) = (start - 1, end - 1);
            if end >= max_len {
                return Err(format!(
                    "Range end {input} is above result count {max_len}",
                    input = end + 1,
                ));
            }

            for index in start..=end {
                if unique.contains(&index) {
                    continue;
                }
                unique.push(index);
            }
            continue;
        }

        let index = token
            .parse::<usize>()
            .map_err(|_| format!("Invalid index '{token}'"))?;
        if index == 0 {
            return Err("Indexes are 1-based, use 1 or greater".to_string());
        }
        let index = index - 1;
        if index >= max_len {
            return Err(format!(
                "Index {} is above result count {}",
                index + 1,
                max_len
            ));
        }

        if !unique.contains(&index) {
            unique.push(index);
        }
    }

    if unique.is_empty() {
        return Err("No valid indexes provided".to_string());
    }

    Ok(unique)
}

fn apply_ui_events(ui_rx: &mpsc::Receiver<char>, ui_state: &mut UiState) {
    while let Ok(command) = ui_rx.try_recv() {
        match command {
            't' | 'T' => {
                ui_state.show_details = !ui_state.show_details;
            }
            'q' | 'Q' => {
                ui_state.abort_requested = true;
            }
            _ => {}
        }
    }
}

fn thesa_gradient() -> Gradient {
    Gradient::new(
        vec![
            AislingColor::rgb(0, 255, 210),
            AislingColor::rgb(142, 92, 255),
            AislingColor::rgb(255, 80, 210),
            AislingColor::rgb(255, 196, 92),
        ],
        36,
    )
}

fn paint(text: impl AsRef<str>, color: AislingColor) -> String {
    let color = fg(color);
    format!("{color}{}\x1b[0m", text.as_ref())
}

fn paint_bold(text: impl AsRef<str>, color: AislingColor) -> String {
    let color = fg(color);
    format!("\x1b[1m{color}{}\x1b[0m", text.as_ref())
}

fn fg(color: AislingColor) -> String {
    format!("\x1b[38;2;{};{};{}m", color.r, color.g, color.b)
}

fn clone_repositories_ratatui(
    repos: &[RepoItem],
    output: &Path,
    args: &Cli,
    ui_state: &mut UiState,
) -> Result<CloneSummary> {
    let mut session = TerminalSession::new()?;
    clone_repositories_ratatui_inner(&mut session.terminal, repos, output, args, ui_state)
}

fn clone_repositories_ratatui_inner(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    repos: &[RepoItem],
    output: &Path,
    args: &Cli,
    ui_state: &mut UiState,
) -> Result<CloneSummary> {
    let batch_size = args.concurrency.max(1);
    let total = repos.len();
    let total_batches = total.div_ceil(batch_size);
    let mut summary = CloneSummary::default();
    let mut effect_kind = EffectKind::Matrix;
    let started_at = Instant::now();
    let mut tick = 0usize;
    let mut batch_index = 1;

    while batch_index <= total {
        let batch_end = (batch_index + batch_size - 1).min(total);
        let batch = &repos[batch_index - 1..batch_end];
        let names = batch
            .iter()
            .map(|repo| repo.full_name.clone())
            .collect::<Vec<_>>();
        let mut state = vec![BatchState::Pending; batch.len()];
        let (tx, rx) = mpsc::channel::<(usize, BatchEvent)>();
        let mut handles = Vec::new();

        for (offset, repo) in batch.iter().enumerate() {
            let repo = repo.clone();
            let output = output.to_path_buf();
            let args = args.clone();
            let tx = tx.clone();

            handles.push((
                repo.full_name.clone(),
                thread::spawn(move || {
                    let started = tx.send((offset, BatchEvent::Started));
                    if started.is_err() {
                        return;
                    }

                    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                        clone_repository(&repo, &output, &args)
                    }));

                    let event = match outcome {
                        Ok(Ok(CloneStatus::Cloned)) => BatchEvent::Result(BatchResult::Done),
                        Ok(Ok(CloneStatus::Skipped)) => BatchEvent::Result(BatchResult::Skipped),
                        Ok(Err(err)) => BatchEvent::Result(BatchResult::Error(err.to_string())),
                        Err(_) => BatchEvent::Result(BatchResult::Error(
                            "worker thread panicked while cloning".to_string(),
                        )),
                    };

                    let _ = tx.send((offset, event));
                }),
            ));
        }
        drop(tx);

        let mut completed = 0;
        while completed < batch.len() {
            while event::poll(Duration::from_millis(0))? {
                if let Event::Key(key) = event::read()? {
                    match key.code {
                        KeyCode::Char('q') | KeyCode::Esc => ui_state.abort_requested = true,
                        KeyCode::Char('t') => ui_state.show_details = !ui_state.show_details,
                        KeyCode::Char('m') => effect_kind = EffectKind::Matrix,
                        KeyCode::Char('b') => effect_kind = EffectKind::Burn,
                        KeyCode::Char('w') => effect_kind = EffectKind::Wipe,
                        _ => {}
                    }
                }
            }

            while let Ok((offset, event)) = rx.try_recv() {
                match event {
                    BatchEvent::Started => state[offset] = BatchState::Running,
                    BatchEvent::Result(BatchResult::Done) => {
                        state[offset] = BatchState::Done;
                        summary.cloned += 1;
                        completed += 1;
                    }
                    BatchEvent::Result(BatchResult::Skipped) => {
                        state[offset] = BatchState::Skipped;
                        summary.skipped += 1;
                        completed += 1;
                    }
                    BatchEvent::Result(BatchResult::Error(message)) => {
                        state[offset] = BatchState::Failed;
                        summary
                            .failed
                            .push(format!("{}: {}", names[offset], message));
                        completed += 1;
                    }
                }
            }

            terminal.draw(|frame| {
                render_clone_dashboard(
                    frame,
                    &names,
                    &state,
                    &summary,
                    batch_index,
                    batch_size,
                    total,
                    total_batches,
                    tick,
                    started_at.elapsed(),
                    effect_kind,
                    ui_state,
                )
            })?;

            tick = tick.wrapping_add(1);
            if completed < batch.len() {
                thread::sleep(Duration::from_millis(80));
            }
        }

        for (repo_name, handle) in handles {
            if handle.join().is_err() {
                summary
                    .failed
                    .push(format!("{repo_name}: worker thread panicked"));
            }
        }

        terminal.draw(|frame| {
            render_clone_dashboard(
                frame,
                &names,
                &state,
                &summary,
                batch_index,
                batch_size,
                total,
                total_batches,
                tick,
                started_at.elapsed(),
                effect_kind,
                ui_state,
            )
        })?;

        if ui_state.abort_requested {
            break;
        }

        batch_index = batch_end + 1;
    }

    Ok(summary)
}

#[allow(clippy::too_many_arguments)]
fn render_clone_dashboard(
    frame: &mut Frame,
    names: &[String],
    state: &[BatchState],
    summary: &CloneSummary,
    batch_index: usize,
    batch_size: usize,
    total_repos: usize,
    total_batches: usize,
    tick: usize,
    elapsed: Duration,
    effect_kind: EffectKind,
    ui_state: &UiState,
) {
    let area = frame.area();
    let overall_done = summary.cloned + summary.skipped + summary.failed.len();
    let status = if ui_state.abort_requested {
        "stopping after current batch"
    } else {
        "cloning"
    };

    if area.width < 36 || area.height < 10 {
        let loader = Loader::with_config(
            LoaderKind::Tqdm,
            LoaderConfig::default()
                .with_size(area.width.saturating_sub(4) as usize, 1)
                .with_label("thesa")
                .with_unit("repo")
                .with_fraction(true),
        );
        let line = loader.line(
            tick,
            LoaderProgress::from_counts(overall_done as u64, total_repos as u64),
        );
        frame.render_widget(
            Paragraph::new(Text::from(vec![
                Line::styled(
                    "thesa",
                    Style::default()
                        .fg(theme_white())
                        .add_modifier(Modifier::BOLD),
                ),
                Line::raw(line),
                Line::styled(status.to_string(), Style::default().fg(theme_dim())),
            ]))
            .block(knott_block(" thesa ", theme_blue())),
            area,
        );
        return;
    }

    let root = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(if area.height >= 34 { 22 } else { 3 }),
            Constraint::Min(8),
            Constraint::Length(2),
        ])
        .split(area);

    draw_thesa_header(frame, root[0], tick, status, "clone dashboard");

    let body = Layout::default()
        .direction(Direction::Horizontal)
        .constraints(if root[1].width >= 104 {
            [Constraint::Percentage(68), Constraint::Percentage(32)]
        } else {
            [Constraint::Percentage(100), Constraint::Percentage(0)]
        })
        .split(root[1]);

    let left = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(5), Constraint::Min(5)])
        .split(body[0]);

    let overall_loader = Loader::with_config(
        LoaderKind::Tqdm,
        LoaderConfig::default()
            .with_size(left[0].width.saturating_sub(4).max(12) as usize, 1)
            .with_label("thesa")
            .with_unit("repo")
            .with_fraction(true),
    );
    let overall_line = overall_loader.line(
        tick,
        LoaderProgress::from_counts(overall_done as u64, total_repos as u64),
    );

    let mut chips = Vec::new();
    append_status_chip(
        &mut chips,
        "cloned",
        summary.cloned.to_string(),
        theme_green(),
    );
    append_status_chip(
        &mut chips,
        "skipped",
        summary.skipped.to_string(),
        TuiColor::Yellow,
    );
    append_status_chip(
        &mut chips,
        "failed",
        summary.failed.len().to_string(),
        theme_red(),
    );
    append_status_chip(
        &mut chips,
        "elapsed",
        format!("{}s", elapsed.as_secs()),
        theme_dim(),
    );

    let current_batch = ((batch_index - 1) / batch_size) + 1;
    let stats = Paragraph::new(Text::from(vec![
        Line::from(chips),
        Line::raw(overall_line),
        Line::styled(
            format!("batch {current_batch}/{total_batches}  concurrency {batch_size}"),
            Style::default().fg(theme_dim()),
        ),
    ]))
    .block(knott_block(" Progress ", theme_green()));
    frame.render_widget(stats, left[0]);

    let repo_loader = Loader::with_config(
        LoaderKind::Tqdm,
        LoaderConfig::default()
            .with_size(left[1].width.saturating_sub(34).max(10) as usize, 1)
            .with_label("repo")
            .with_unit("step")
            .with_fraction(true),
    );

    let mut rows = Vec::new();
    if ui_state.show_details {
        for (index, (name, repo_state)) in names.iter().zip(state.iter()).enumerate() {
            rows.push(repo_loader_row(
                &repo_loader,
                tick + index,
                name,
                *repo_state,
            ));
        }
    } else {
        rows.push(Line::styled(
            "details hidden; press t to show concurrent repo loaders",
            Style::default().fg(theme_dim()),
        ));
    }

    let repos = Paragraph::new(Text::from(rows))
        .block(knott_block(
            " Concurrent Pulls | Aisling Tqdm ",
            theme_blue(),
        ))
        .wrap(Wrap { trim: false });
    frame.render_widget(repos, left[1]);

    if body[1].width > 0 {
        let effect_height = body[1].height.saturating_sub(12).max(4) as usize;
        let effect_text =
            aisling_effect_text(effect_kind, tick, body[1].width as usize, effect_height);
        let mut side_lines = vec![
            section_line("Controls"),
            help_pair_line("q / Esc", "stop after current batch"),
            help_pair_line("t", "toggle repo rows"),
            help_pair_line("m", "matrix effect"),
            help_pair_line("b", "burn effect"),
            help_pair_line("w", "wipe effect"),
            Line::from(""),
            section_line(&format!("Aisling {}", effect_kind.name())),
        ];
        side_lines.extend(effect_text.lines().map(|line| Line::raw(line.to_string())));
        let side = Paragraph::new(Text::from(side_lines))
            .block(knott_block(" Effects ", theme_red()))
            .wrap(Wrap { trim: false });
        frame.render_widget(side, body[1]);
    }

    draw_thesa_bottom_bar(
        frame,
        root[2],
        status,
        &["q stop", "t details", "m matrix", "b burn", "w wipe"],
    );
}

fn aisling_effect_text(kind: EffectKind, tick: usize, width: usize, height: usize) -> String {
    let effect = Effect::with_config(
        kind,
        "thesa\npublic github repo collector",
        EffectConfig::default()
            .with_duration(72)
            .with_seed(17)
            .with_gradient(thesa_gradient(), GradientDirection::Diagonal)
            .with_canvas_size(width.saturating_sub(2).max(20), height.max(3)),
    );

    effect
        .iter()
        .nth(tick % 72)
        .map(|frame| frame.to_plain_string())
        .unwrap_or_else(|| "thesa".to_string())
}

fn repo_loader_row(loader: &Loader, tick: usize, name: &str, state: BatchState) -> Line<'static> {
    let done = matches!(
        state,
        BatchState::Done | BatchState::Skipped | BatchState::Failed
    );
    let loader_line = loader.line(tick, LoaderProgress::from_counts(done as u64, 1));
    let style = match state {
        BatchState::Pending => Style::default().fg(TuiColor::DarkGray),
        BatchState::Running => Style::default().fg(TuiColor::LightMagenta),
        BatchState::Done => Style::default().fg(TuiColor::Cyan),
        BatchState::Skipped => Style::default().fg(TuiColor::Yellow),
        BatchState::Failed => Style::default().fg(TuiColor::Red),
    };

    Line::from(vec![
        Span::styled(
            format!("{:<7}", state.label()),
            style.add_modifier(Modifier::BOLD),
        ),
        Span::raw(" "),
        Span::raw(loader_line),
        Span::raw(" "),
        Span::styled(name.to_string(), style),
    ])
}

fn clone_repositories_serial(
    repos: &[RepoItem],
    output: &Path,
    args: &Cli,
    ui_rx: &mpsc::Receiver<char>,
    ui_state: &mut UiState,
) -> Result<CloneSummary> {
    let spinner = Loader::with_config(
        LoaderKind::Tqdm,
        LoaderConfig::default()
            .with_size(54, 1)
            .with_label("thesa")
            .with_unit("repo")
            .with_fraction(true),
    );

    let mut stdout = io::stdout();
    let mut summary = CloneSummary::default();
    let total = repos.len();

    for (index, repo) in repos.iter().enumerate() {
        if ui_state.abort_requested {
            break;
        }

        apply_ui_events(ui_rx, ui_state);

        match clone_repository_with_progress(
            repo,
            output,
            args,
            &mut stdout,
            &spinner,
            index + 1,
            total,
        ) {
            Ok(CloneStatus::Cloned) => summary.cloned += 1,
            Ok(CloneStatus::Skipped) => summary.skipped += 1,
            Err(err) => summary.failed.push(format!("{}: {}", repo.full_name, err)),
        }
    }

    Ok(summary)
}

fn clone_repositories_batched(
    repos: &[RepoItem],
    output: &Path,
    args: &Cli,
    ui_rx: &mpsc::Receiver<char>,
    ui_state: &mut UiState,
) -> Result<CloneSummary> {
    let batch_size = args.concurrency.max(1);
    let total = repos.len();
    let mut summary = CloneSummary::default();

    let mut batch_index = 1;
    let total_batches = total.div_ceil(batch_size);
    let mut stop_after_batch = false;

    while batch_index <= total {
        let batch_end = (batch_index + batch_size - 1).min(total);
        let batch = &repos[batch_index - 1..batch_end];
        let batch_positions: Vec<String> =
            batch.iter().map(|repo| repo.full_name.clone()).collect();
        let (tx, rx) = mpsc::channel::<(usize, BatchEvent)>();

        println!(
            "Starting batch {}/{} ({})...",
            (batch_index - 1) / batch_size + 1,
            total_batches,
            batch.len()
        );

        let mut state = vec![BatchState::Pending; batch.len()];

        apply_ui_events(ui_rx, ui_state);
        if ui_state.abort_requested {
            break;
        }

        let mut handles = Vec::new();
        for (offset, repo) in batch.iter().enumerate() {
            let repo = repo.clone();
            let output = output.to_path_buf();
            let args = args.clone();
            let position = batch_index + offset;
            let tx = tx.clone();

            handles.push((
                position,
                repo.full_name.clone(),
                thread::spawn(move || {
                    let started = tx.send((offset, BatchEvent::Started));
                    if started.is_err() {
                        return;
                    }

                    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                        clone_repository(&repo, &output, &args)
                    }));

                    let event = match outcome {
                        Ok(Ok(CloneStatus::Cloned)) => BatchEvent::Result(BatchResult::Done),
                        Ok(Ok(CloneStatus::Skipped)) => BatchEvent::Result(BatchResult::Skipped),
                        Ok(Err(err)) => BatchEvent::Result(BatchResult::Error(err.to_string())),
                        Err(_) => BatchEvent::Result(BatchResult::Error(
                            "worker thread panicked while cloning".to_string(),
                        )),
                    };

                    let _ = tx.send((offset, event));
                }),
            ));
        }

        drop(tx);

        let mut completed = 0;
        while completed < batch.len() {
            let mut progress_updated = false;

            apply_ui_events(ui_rx, ui_state);
            if ui_state.abort_requested {
                stop_after_batch = true;
            }

            while let Ok((offset, event)) = rx.try_recv() {
                match event {
                    BatchEvent::Started => {
                        state[offset] = BatchState::Running;
                        progress_updated = true;
                    }
                    BatchEvent::Result(BatchResult::Done) => {
                        state[offset] = BatchState::Done;
                        summary.cloned += 1;
                        completed += 1;
                        progress_updated = true;
                    }
                    BatchEvent::Result(BatchResult::Skipped) => {
                        state[offset] = BatchState::Skipped;
                        summary.skipped += 1;
                        completed += 1;
                        progress_updated = true;
                    }
                    BatchEvent::Result(BatchResult::Error(message)) => {
                        state[offset] = BatchState::Failed;
                        summary
                            .failed
                            .push(format!("{}: {}", batch_positions[offset], message));
                        completed += 1;
                        progress_updated = true;
                    }
                }
            }

            if progress_updated {
                render_batch_state(
                    &mut io::stdout(),
                    (batch_index - 1) / batch_size + 1,
                    total_batches,
                    batch_positions.as_slice(),
                    state.as_slice(),
                    ui_state.show_details,
                    &mut ui_state.panel_lines,
                    batch.len(),
                    total,
                )?;
            }

            if completed < batch.len() {
                thread::sleep(Duration::from_millis(100));
            }
        }

        for (position, repo_name, handle) in handles {
            if handle.join().is_err() {
                summary.failed.push(format!(
                    "{repo_name} ({position}/{total}): worker thread panicked"
                ));
            }
        }

        if stop_after_batch {
            break;
        }

        batch_index = batch_end + 1;
    }

    Ok(summary)
}

#[derive(Debug, Clone)]
enum BatchResult {
    Done,
    Skipped,
    Error(String),
}

#[derive(Debug, Clone)]
enum BatchEvent {
    Started,
    Result(BatchResult),
}

fn render_batch_state(
    stdout: &mut io::Stdout,
    batch_number: usize,
    total_batches: usize,
    names: &[String],
    state: &[BatchState],
    show_details: bool,
    panel_lines: &mut usize,
    batch_size: usize,
    total_repos: usize,
) -> io::Result<()> {
    if !stdout.is_terminal() {
        return Ok(());
    }

    let mut done = 0;
    let mut skipped = 0;
    let mut failed = 0;
    let mut running = 0;
    let mut pending = 0;

    for entry in state {
        match entry {
            BatchState::Done => done += 1,
            BatchState::Skipped => skipped += 1,
            BatchState::Failed => failed += 1,
            BatchState::Running => running += 1,
            BatchState::Pending => pending += 1,
        }
    }

    let completed = done + skipped + failed;
    let loader = Loader::with_config(
        LoaderKind::SynthGrid,
        LoaderConfig::default()
            .with_size(58, 1)
            .with_label("thesa")
            .with_unit("repo")
            .with_fraction(true)
            .with_gradient(thesa_gradient(), GradientDirection::Horizontal),
    );
    let loader_line = loader
        .frame(
            completed + running,
            LoaderProgress::from_counts(completed as u64, batch_size as u64),
        )
        .to_ansi_string();

    let cyan = AislingColor::rgb(0, 255, 210);
    let violet = AislingColor::rgb(142, 92, 255);
    let amber = AislingColor::rgb(255, 196, 92);
    let rose = AislingColor::rgb(255, 80, 210);
    let slate = AislingColor::rgb(132, 150, 168);

    let mut lines = vec![
        paint(
            "+--------------------------------------------------------------------+",
            violet,
        ),
        format!(
            "| {} {} | {} |",
            paint_bold("THESA", cyan),
            paint("public-repo collector", slate),
            paint(
                format!("batch {batch_number}/{total_batches} total {total_repos}"),
                amber,
            )
        ),
        paint(
            "+--------------------------------------------------------------------+",
            violet,
        ),
        format!("| {} |", loader_line.trim_end()),
        format!(
            "| {} {}  {} {}  {} {}  {} {}  {} {} |",
            paint("done", cyan),
            done,
            paint("skip", amber),
            skipped,
            paint("fail", rose),
            failed,
            paint("run", violet),
            running,
            paint("wait", slate),
            pending,
        ),
        format!(
            "| {} |",
            paint(
                "keys: t + Enter toggles detail, q + Enter stops after batch",
                slate
            )
        ),
        paint(
            "+--------------------------------------------------------------------+",
            violet,
        ),
    ];

    if show_details {
        for (name, repo_state) in names.iter().zip(state.iter()) {
            lines.push(format!("  {} {}", repo_state.icon(), name));
        }
    } else {
        lines.push(paint(
            "  detail hidden; press t + Enter to show repos",
            slate,
        ));
    }

    lines.push(String::new());

    if *panel_lines > 0 {
        write!(stdout, "\x1b[{}A", *panel_lines)?;
    }

    let written_lines = (*panel_lines).max(lines.len());
    for index in 0..written_lines {
        let line = lines.get(index).map(String::as_str).unwrap_or("");
        write!(stdout, "\x1b[2K\r{line}\n")?;
    }

    *panel_lines = written_lines;

    stdout.flush()?;
    Ok(())
}

impl BatchState {
    fn label(self) -> &'static str {
        match self {
            BatchState::Pending => "queued",
            BatchState::Running => "pulling",
            BatchState::Done => "done",
            BatchState::Skipped => "skip",
            BatchState::Failed => "failed",
        }
    }

    fn icon(self) -> &'static str {
        match self {
            BatchState::Pending => " [ ]",
            BatchState::Running => "[-x]",
            BatchState::Done => " [*]",
            BatchState::Skipped => " [/]",
            BatchState::Failed => " [!]",
        }
    }
}

fn parse_target(input: &str) -> Result<Target> {
    let cleaned = input.trim().trim_end_matches(".git");
    if cleaned.is_empty() {
        return Err(GitArchiverError::InvalidTarget(input.to_string()));
    }

    if let Some(path) = cleaned.strip_prefix("https://github.com/") {
        return parse_owner_or_repo_path(path);
    }
    if let Some(path) = cleaned.strip_prefix("http://github.com/") {
        return parse_owner_or_repo_path(path);
    }
    if let Some(path) = cleaned.strip_prefix("git@github.com:") {
        return parse_owner_or_repo_path(path);
    }

    parse_owner_or_repo_path(cleaned)
}

fn parse_owner_or_repo_path(input: &str) -> Result<Target> {
    let parts: Vec<&str> = input
        .trim_matches('/')
        .split('/')
        .filter(|part| !part.is_empty())
        .collect();

    if parts.is_empty() {
        return Err(GitArchiverError::InvalidTarget(input.to_string()));
    }

    if parts.len() == 1 {
        return Ok(Target::Owner(parts[0].to_string()));
    }

    Ok(Target::Repo {
        owner: parts[0].to_string(),
        repo: parts[1].to_string(),
    })
}

fn prepare_output_dir(output: &Path) -> Result<PathBuf> {
    if output.exists() && !output.is_dir() {
        return Err(GitArchiverError::OutputNotDirectory(
            output.display().to_string(),
        ));
    }
    fs::create_dir_all(output)?;
    Ok(output.to_path_buf())
}

fn with_auth(
    mut request: reqwest::blocking::RequestBuilder,
    token: Option<&str>,
) -> reqwest::blocking::RequestBuilder {
    request = request.header("Accept", "application/vnd.github+json");
    if let Some(token) = token {
        request = request.bearer_auth(token);
    }
    request
}

fn parse_api_error(response: Response) -> GitArchiverError {
    let status = response.status();
    let body = response
        .text()
        .unwrap_or_else(|_| "<unable to read body>".to_string());
    GitArchiverError::ApiError {
        status: status.as_u16(),
        body,
    }
}

fn list_repos_for_owner(
    client: &Client,
    owner: &str,
    token: Option<&str>,
) -> Result<Vec<RepoItem>> {
    list_repos_with_scope(client, "users", owner, token)
}

fn list_repos_for_org(client: &Client, org: &str, token: Option<&str>) -> Result<Vec<RepoItem>> {
    list_repos_with_scope(client, "orgs", org, token)
}

fn list_repos_with_scope(
    client: &Client,
    scope: &str,
    entity: &str,
    token: Option<&str>,
) -> Result<Vec<RepoItem>> {
    let mut page = 1usize;
    let mut repos = Vec::new();

    loop {
        let url = format!(
            "{}/{}/{}/repos?type=public&per_page={}&page={}",
            GITHUB_API, scope, entity, DEFAULT_PAGE_SIZE, page
        );

        let response = with_auth(client.get(&url), token).send()?;
        let status = response.status();

        if status == StatusCode::NOT_FOUND {
            return Err(GitArchiverError::NotFound(entity.to_string()));
        }
        if !status.is_success() {
            return Err(parse_api_error(response));
        }

        let batch: Vec<RepoResponse> = response.json()?;
        let count = batch.len();
        repos.extend(batch.into_iter().filter_map(|repo| {
            if repo.private {
                return None;
            }

            Some(RepoItem {
                name: repo.name,
                full_name: repo.full_name,
                clone_url: repo.clone_url,
            })
        }));

        if count < DEFAULT_PAGE_SIZE {
            break;
        }

        page = page.saturating_add(1);
    }

    Ok(repos)
}

fn fetch_single_repo(
    client: &Client,
    owner: &str,
    repo: &str,
    token: Option<&str>,
) -> Result<RepoItem> {
    let url = format!("{}/repos/{}/{}", GITHUB_API, owner, repo);
    let response = with_auth(client.get(&url), token).send()?;

    if response.status() == StatusCode::NOT_FOUND {
        return Err(GitArchiverError::NotFound(format!("{owner}/{repo}")));
    }
    if !response.status().is_success() {
        return Err(parse_api_error(response));
    }

    let data: RepoResponse = response.json()?;
    if data.private {
        return Err(GitArchiverError::PrivateRepo(data.full_name));
    }

    Ok(RepoItem {
        name: data.name,
        full_name: data.full_name,
        clone_url: data.clone_url,
    })
}

fn clone_repository(repo: &RepoItem, output: &Path, args: &Cli) -> Result<CloneStatus> {
    match prepare_clone(repo, output, args)? {
        CloneAction::Skipped => Ok(CloneStatus::Skipped),
        CloneAction::Running(mut child) => {
            let status = child.wait()?;

            if status.success() {
                return Ok(CloneStatus::Cloned);
            }

            let code = status
                .code()
                .map_or_else(|| "signal/unknown".to_string(), |code| code.to_string());

            Err(GitArchiverError::CloneFailed {
                repo: repo.full_name.clone(),
                message: format!("exit code {code}"),
            })
        }
    }
}

fn clone_repository_with_progress(
    repo: &RepoItem,
    output: &Path,
    args: &Cli,
    stdout: &mut io::Stdout,
    spinner: &Loader,
    position: usize,
    total: usize,
) -> Result<CloneStatus> {
    let mut child = match prepare_clone(repo, output, args)? {
        CloneAction::Skipped => {
            write!(stdout, "\r\x1b[2K")?;
            println!("[skip] {} ({}/{}) exists", repo.full_name, position, total);
            return Ok(CloneStatus::Skipped);
        }
        CloneAction::Running(child) => child,
    };

    let mut tick = 0usize;
    loop {
        if let Some(status) = child.try_wait()? {
            if status.success() {
                write!(stdout, "\r\x1b[2K")?;
                println!("[done] {} ({}/{})", repo.full_name, position, total);
                return Ok(CloneStatus::Cloned);
            }

            let code = status
                .code()
                .map_or_else(|| "signal/unknown".to_string(), |code| code.to_string());
            return Err(GitArchiverError::CloneFailed {
                repo: repo.full_name.clone(),
                message: format!("exit code {code}"),
            });
        }

        let progress = LoaderProgress::from_counts(position as u64, total as u64);
        let frame = spinner.line(tick, progress);
        let status = format!("[{}/{}] cloning {}", position, total, repo.full_name);
        write!(stdout, "\r\x1b[2K{} {}", frame, status)?;
        stdout.flush()?;

        tick = tick.saturating_add(1);
        thread::sleep(Duration::from_millis(120));
    }
}

fn prepare_clone(repo: &RepoItem, output: &Path, args: &Cli) -> Result<CloneAction> {
    let destination = output.join(&repo.name);

    if destination.exists() {
        if args.skip_existing {
            return Ok(CloneAction::Skipped);
        }

        if destination.is_dir() {
            remove_dir_all(&destination)?;
        } else {
            remove_file(&destination)?;
        }
    }

    let child = spawn_git_clone(repo, output, args)?;
    Ok(CloneAction::Running(child))
}

fn spawn_git_clone(repo: &RepoItem, output: &Path, args: &Cli) -> Result<Child> {
    let mut command_args = vec!["clone".to_string()];

    if let Some(depth) = args.depth {
        command_args.push("--depth".to_string());
        command_args.push(depth.to_string());
    }

    command_args.push(repo.clone_url.clone());
    command_args.push(repo.name.clone());

    match Command::new("git")
        .current_dir(output)
        .args(&command_args)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
    {
        Ok(child) => Ok(child),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Err(GitArchiverError::MissingGit),
        Err(error) => Err(error.into()),
    }
}
