mod actions;
mod config;
mod work_items;

use std::{env, error::Error, process};

use config::{Config, RepositoryScope, Selection, server_url};
use gotcha_gitea::{Client, Method, Provider, Repository, ServerVersion, User};
use serde_json::Value;

const ROOT_HELP: &str = "\
Gotcha CLI

Usage:
  gotcha [--server SERVER | --url URL] COMMAND

Commands:
  auth      Manage server authentication
  server    Inspect the selected Gitea or Forgejo server
  user      Work with the authenticated user
  repo      Work with repositories
  issue     Work with issues and comments
  milestone Work with repository milestones
  pull      Work with pull requests
  action    Work with Actions workflows and runs
  api       Send a raw server API request

Run `gotcha COMMAND` to list that command's subcommands.

Without --server or --url, Gotcha selects a profile from the current Git
repository's remotes. SERVER is a hostname, optionally followed by a port.
GITEA_URL is the environment equivalent of --url.";

const AUTH_HELP: &str = "\
Usage: gotcha auth SUBCOMMAND

Subcommands:
  login SERVER [--provider gitea|forgejo]
                  Verify and store a token read from standard input
  list            List configured servers without showing tokens
  status          Show the selected server and repository scope
  logout SERVER   Remove a stored server and token";

const SERVER_HELP: &str = "\
Usage: gotcha server SUBCOMMAND

Subcommands:
  version         Show the selected server version";

const USER_HELP: &str = "\
Usage: gotcha user SUBCOMMAND

Subcommands:
  show            Show the authenticated user";

const REPO_HELP: &str = "\
Usage: gotcha repo SUBCOMMAND

Subcommands:
  list            List repositories belonging to the authenticated user
  show [OWNER/REPOSITORY]
                  Show a repository; defaults to the current Git repository";

const API_HELP: &str = "\
Usage: gotcha api SUBCOMMAND

Subcommands:
  request METHOD ENDPOINT [JSON]
                  Send an API-relative request";

#[tokio::main]
async fn main() {
    if let Err(error) = run().await {
        eprintln!("gotcha: {error}");
        process::exit(1);
    }
}

async fn run() -> Result<(), Box<dyn Error>> {
    let arguments: Vec<_> = env::args().skip(1).collect();
    let args = Args::parse(arguments, env::var("GITEA_URL").ok())?;
    if let Some(help) = requested_help(&args.command)? {
        println!("{help}");
        return Ok(());
    }

    let mut config = Config::load()?;
    if let Some((name, fallback)) = login_command(&args.command)? {
        let url = server_url(name)?;
        let token = rpassword::prompt_password("Token: ")?;
        if token.is_empty() {
            return Err("token must not be empty".into());
        }
        let client = Client::discover(&url, Some(&token), fallback).await?;
        let user = client.current_user().await?;
        config.login(name, &token, client.provider())?;
        println!(
            "Logged in to {url} as {} ({name}, {}).",
            user.login.as_deref().unwrap_or("unknown"),
            client.provider()
        );
        return Ok(());
    }
    match args.command.as_slice() {
        [domain, command] if domain == "auth" && command == "list" => {
            for (name, server) in &config.servers {
                println!("{name}\t{}\t{}", server.provider, server.url);
            }
            return Ok(());
        }
        [domain, command, name] if domain == "auth" && command == "logout" => {
            config.logout(name)?;
            println!("Removed server profile {name}.");
            return Ok(());
        }
        _ => {}
    }

    let selection = config.select(args.server.as_deref(), args.url.as_deref())?;
    if matches!(args.command.as_slice(), [domain, command] if domain == "auth" && command == "status")
    {
        print_status(&selection);
        return Ok(());
    }

    let client = Client::with_provider(
        &selection.url,
        selection.token.as_deref(),
        selection.provider,
    )?;
    if actions::run(&args.command, &selection, &client).await? {
        return Ok(());
    }
    if work_items::run(&args.command, &selection, &client).await? {
        return Ok(());
    }
    match args.command.as_slice() {
        [domain, command] if domain == "server" && command == "version" => {
            print_version(&client.version().await?);
        }
        [domain, command] if domain == "user" && command == "show" => {
            print_user(&client.current_user().await?);
        }
        [domain, command] if domain == "repo" && command == "list" => {
            print_repositories(&client.current_user_repositories().await?);
        }
        [domain, command] if domain == "repo" && command == "show" => {
            let scope = selection
                .repository
                .as_ref()
                .ok_or("cannot infer a repository; run inside one or pass OWNER/REPOSITORY")?;
            print_repository(&client.repository(&scope.owner, &scope.repository).await?);
        }
        [domain, command, repository_name] if domain == "repo" && command == "show" => {
            let scope = RepositoryScope::parse(repository_name)?;
            print_repository(&client.repository(&scope.owner, &scope.repository).await?);
        }
        [domain, command, method, endpoint] if domain == "api" && command == "request" => {
            print_json(&request(&client, method, endpoint, None).await?)?;
        }
        [domain, command, method, endpoint, body] if domain == "api" && command == "request" => {
            print_json(
                &request(&client, method, endpoint, Some(serde_json::from_str(body)?)).await?,
            )?;
        }
        _ => return Err(invalid_subcommand(&args.command).into()),
    }
    Ok(())
}

fn print_version(version: &ServerVersion) {
    println!("{}", version.version.as_deref().unwrap_or("unknown"));
}

fn login_command(command: &[String]) -> Result<Option<(&str, Provider)>, Box<dyn Error>> {
    match command {
        [domain, action, name] if domain == "auth" && action == "login" => {
            Ok(Some((name, Provider::Gitea)))
        }
        [domain, action, name, option, provider]
            if domain == "auth" && action == "login" && option == "--provider" =>
        {
            Ok(Some((name, provider.parse()?)))
        }
        _ => Ok(None),
    }
}

fn print_user(user: &User) {
    field("login", user.login.as_deref());
    field("name", user.full_name.as_deref());
    field("email", user.email.as_deref());
    field("profile", user.html_url.as_deref());
    field("visibility", user.visibility.as_deref());
}

fn print_repositories(repositories: &[Repository]) {
    print_table(
        &[
            ("REPOSITORY", 40),
            ("VISIBILITY", 10),
            ("UPDATED", 25),
            ("DESCRIPTION", 60),
        ],
        repositories
            .iter()
            .map(|repository| {
                vec![
                    repository.full_name.as_deref().unwrap_or("unknown").into(),
                    if repository.private.unwrap_or(false) {
                        "private"
                    } else {
                        "public"
                    }
                    .into(),
                    repository.updated_at.as_deref().unwrap_or("-").into(),
                    repository.description.as_deref().unwrap_or("").into(),
                ]
            })
            .collect(),
    );
}

fn print_repository(repository: &Repository) {
    field("repository", repository.full_name.as_deref());
    field("description", repository.description.as_deref());
    field("url", repository.html_url.as_deref());
    field("default branch", repository.default_branch.as_deref());
    println!(
        "visibility: {}",
        if repository.private.unwrap_or(false) {
            "private"
        } else {
            "public"
        }
    );
    println!("stars: {}", repository.stars_count.unwrap_or_default());
    println!(
        "open issues: {}",
        repository.open_issues_count.unwrap_or_default()
    );
}

fn field(name: &str, value: Option<&str>) {
    if let Some(value) = value.filter(|value| !value.is_empty()) {
        println!("{name}: {value}");
    }
}

fn print_json(value: &Value) -> Result<(), serde_json::Error> {
    println!("{}", serde_json::to_string_pretty(value)?);
    Ok(())
}

fn print_table(columns: &[(&str, usize)], rows: Vec<Vec<String>>) {
    println!("{}", format_table(columns, &rows, terminal_width()));
}

fn format_table(columns: &[(&str, usize)], rows: &[Vec<String>], width: usize) -> String {
    let minimums = columns
        .iter()
        .map(|(header, _)| header.chars().count())
        .collect::<Vec<_>>();
    let mut widths = minimums.clone();
    for (column, (_, maximum)) in columns.iter().enumerate() {
        widths[column] = rows
            .iter()
            .filter_map(|row| row.get(column))
            .map(|value| value.chars().count())
            .max()
            .unwrap_or_default()
            .max(widths[column])
            .min(*maximum);
    }
    let separator_width = columns.len().saturating_sub(1) * 2;
    while widths.iter().sum::<usize>() + separator_width > width {
        let Some(column) = widths
            .iter()
            .zip(&minimums)
            .enumerate()
            .filter(|(_, (current, minimum))| current > minimum)
            .max_by_key(|(_, (current, minimum))| *current - *minimum)
            .map(|(column, _)| column)
        else {
            break;
        };
        widths[column] -= 1;
    }

    let mut lines = Vec::with_capacity(rows.len() + 2);
    lines.push(table_row(
        &columns
            .iter()
            .map(|(header, _)| (*header).to_owned())
            .collect::<Vec<_>>(),
        &widths,
    ));
    lines.push(
        widths
            .iter()
            .map(|width| "-".repeat(*width))
            .collect::<Vec<_>>()
            .join("  "),
    );
    lines.extend(rows.iter().map(|row| table_row(row, &widths)));
    lines.join("\n")
}

fn table_row(values: &[String], widths: &[usize]) -> String {
    values
        .iter()
        .zip(widths)
        .map(|(value, width)| table_cell(value, *width))
        .collect::<Vec<_>>()
        .join("  ")
        .trim_end()
        .to_owned()
}

fn table_cell(value: &str, width: usize) -> String {
    let value = value.lines().next().unwrap_or_default();
    let length = value.chars().count();
    if length <= width {
        return format!("{value}{}", " ".repeat(width - length));
    }
    let mut shortened = value
        .chars()
        .take(width.saturating_sub(1))
        .collect::<String>();
    shortened.push('…');
    shortened
}

fn terminal_width() -> usize {
    env::var("COLUMNS")
        .ok()
        .and_then(|value| value.parse().ok())
        .filter(|width| *width >= 40)
        .unwrap_or(100)
}

fn requested_help(command: &[String]) -> Result<Option<&'static str>, String> {
    match command {
        [] => Ok(Some(ROOT_HELP)),
        [help] if matches!(help.as_str(), "help" | "-h" | "--help") => Ok(Some(ROOT_HELP)),
        [domain] => domain_help(domain)
            .map(Some)
            .ok_or_else(|| format!("unknown command {domain:?}\n\n{ROOT_HELP}")),
        [domain, help] if matches!(help.as_str(), "-h" | "--help") => domain_help(domain)
            .map(Some)
            .ok_or_else(|| format!("unknown command {domain:?}\n\n{ROOT_HELP}")),
        [domain, subcommand, help] if matches!(help.as_str(), "-h" | "--help") => {
            subcommand_help(domain, subcommand)
                .map(Some)
                .ok_or_else(|| {
                    format!(
                        "unknown subcommand {domain} {subcommand}\n\n{}",
                        domain_help(domain).unwrap_or(ROOT_HELP)
                    )
                })
        }
        [domain, ..] if domain_help(domain).is_none() => {
            Err(format!("unknown command {domain:?}\n\n{ROOT_HELP}"))
        }
        _ => Ok(None),
    }
}

fn domain_help(domain: &str) -> Option<&'static str> {
    match domain {
        "auth" => Some(AUTH_HELP),
        "server" => Some(SERVER_HELP),
        "user" => Some(USER_HELP),
        "repo" => Some(REPO_HELP),
        "api" => Some(API_HELP),
        _ => actions::domain_help(domain).or_else(|| work_items::domain_help(domain)),
    }
}

fn subcommand_help(domain: &str, subcommand: &str) -> Option<&'static str> {
    match (domain, subcommand) {
        ("auth", "login") => Some(
            "Usage: gotcha auth login SERVER [--provider gitea|forgejo]\n\nPrompts for a token, discovers and verifies the provider, and stores the server in the YAML config.",
        ),
        ("auth", "list") => {
            Some("Usage: gotcha auth list\n\nLists configured servers without tokens.")
        }
        ("auth", "status") => Some(
            "Usage: gotcha [--server SERVER] auth status\n\nShows the selected server and inferred repository scope.",
        ),
        ("auth", "logout") => Some(
            "Usage: gotcha auth logout SERVER\n\nRemoves the server and its token from the YAML config.",
        ),
        ("server", "version") => Some("Usage: gotcha server version\n\nShows the server version."),
        ("user", "show") => Some("Usage: gotcha user show\n\nShows the authenticated user."),
        ("repo", "list") => Some(
            "Usage: gotcha repo list\n\nLists repositories belonging to the authenticated user.",
        ),
        ("repo", "show") => Some(
            "Usage: gotcha repo show [OWNER/REPOSITORY]\n\nShows a repository, inferred from Git when omitted.",
        ),
        ("api", "request") => Some(
            "Usage: gotcha api request METHOD ENDPOINT [JSON]\n\nSends an API-relative request using the selected server and token.",
        ),
        _ => actions::subcommand_help(domain, subcommand)
            .or_else(|| work_items::subcommand_help(domain, subcommand)),
    }
}

fn invalid_subcommand(command: &[String]) -> String {
    let domain = command.first().map(String::as_str).unwrap_or_default();
    format!(
        "unknown or invalid {domain} subcommand\n\n{}",
        domain_help(domain).unwrap_or(ROOT_HELP)
    )
}

fn print_status(selection: &Selection) {
    println!(
        "server: {} ({})",
        selection.name.as_deref().unwrap_or("custom"),
        selection.url
    );
    println!(
        "authenticated: {}",
        if selection.token.is_some() {
            "yes"
        } else {
            "no"
        }
    );
    println!("provider: {}", selection.provider);
    if let Some(scope) = &selection.repository {
        println!("repository: {}/{}", scope.owner, scope.repository);
    }
}

async fn request(
    client: &Client,
    method: &str,
    endpoint: &str,
    body: Option<Value>,
) -> Result<Value, Box<dyn Error>> {
    let mut request = client.request(method.parse::<Method>()?, endpoint)?;
    if let Some(body) = body {
        request = request.json(&body);
    }

    let response = client.execute(request).await?;
    if response.status() == 204 {
        return Ok(Value::Null);
    }
    Ok(response.json().await?)
}

#[derive(Debug)]
struct Args {
    url: Option<String>,
    server: Option<String>,
    command: Vec<String>,
}

impl Args {
    fn parse(
        args: impl IntoIterator<Item = String>,
        environment_url: Option<String>,
    ) -> Result<Self, String> {
        let mut args = args.into_iter();
        let mut url = environment_url;
        let mut server = None;
        let mut command = Vec::new();

        while let Some(argument) = args.next() {
            match argument.as_str() {
                "--url" if command.is_empty() => {
                    url = Some(args.next().ok_or("--url requires an instance URL")?);
                }
                "--server" if command.is_empty() => {
                    server = Some(args.next().ok_or("--server requires a profile name")?);
                }
                _ => command.push(argument),
            }
        }

        Ok(Self {
            url,
            server,
            command,
        })
    }
}

#[cfg(test)]
mod tests;
