use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand, ValueEnum};
use reqwest::{StatusCode, Url};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::io::{self, IsTerminal, Read};
use std::path::PathBuf;
use std::process::Command;
use tempfile::Builder;

const DEFAULT_SERVER: &str = "http://localhost:8080";

#[derive(Debug, Parser)]
#[command(author, version, about)]
struct Cli {
    #[arg(
        long,
        global = true,
        env = "PASTR_URL",
        default_value = DEFAULT_SERVER,
        help = "Base URL for the pastr server"
    )]
    server: String,

    #[command(subcommand)]
    command: CommandKind,
}

#[derive(Debug, Subcommand)]
enum CommandKind {
    #[command(alias = "create")]
    Paste(PasteArgs),
    Get(GetArgs),
    Raw(GetArgs),
    Update(UpdateArgs),
    Delete(IdArgs),
}

#[derive(Debug, Parser)]
struct PasteArgs {
    #[arg(value_name = "FILE", help = "File to paste, or '-' for stdin")]
    file: Option<PathBuf>,

    #[arg(short, long, help = "Manual language hint or extension")]
    language: Option<String>,

    #[arg(short, long, help = "Create a private paste")]
    private: bool,

    #[arg(
        long,
        help = "Create a signed private URL with a TTL such as 30m, 24h, 7d"
    )]
    ttl: Option<String>,

    #[arg(short, long, help = "Open $VISUAL or $EDITOR for interactive input")]
    edit: bool,

    #[arg(long, value_enum, default_value_t = OutputFormat::Url)]
    output: OutputFormat,
}

#[derive(Debug, Parser)]
struct UpdateArgs {
    id: String,

    #[arg(value_name = "FILE", help = "File to upload, or '-' for stdin")]
    file: Option<PathBuf>,

    #[arg(short, long, help = "Manual language hint or extension")]
    language: Option<String>,

    #[arg(short, long, help = "Open $VISUAL or $EDITOR for interactive input")]
    edit: bool,
}

#[derive(Debug, Parser)]
struct GetArgs {
    id: String,
}

#[derive(Debug, Parser)]
struct IdArgs {
    id: String,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
    Url,
    RawUrl,
    SignedUrl,
    Id,
    Json,
}

#[derive(Debug, Serialize)]
struct CreatePasteRequest {
    content: String,
    language: Option<String>,
    private: bool,
    ttl: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
struct CreatePasteResponse {
    id: String,
    url: String,
    raw_url: String,
    signed_url: Option<String>,
}

#[derive(Debug, Serialize)]
struct UpdatePasteRequest {
    content: String,
    language: Option<String>,
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();
    let server = normalize_server(&cli.server)?;
    let client = reqwest::Client::new();

    match cli.command {
        CommandKind::Paste(args) => paste(&client, &server, args).await,
        CommandKind::Get(args) => {
            get_text(
                &client,
                server.join(&format!("api/pastes/{}", id(&args.id)?))?,
            )
            .await
        }
        CommandKind::Raw(args) => {
            get_text(&client, server.join(&format!("raw/{}", id(&args.id)?))?).await
        }
        CommandKind::Update(args) => update(&client, &server, args).await,
        CommandKind::Delete(args) => delete(&client, &server, args).await,
    }
}

async fn paste(client: &reqwest::Client, server: &Url, args: PasteArgs) -> Result<()> {
    let content = read_input(args.file, args.edit)?;
    let request = CreatePasteRequest {
        content,
        language: args.language,
        private: args.private,
        ttl: args.ttl,
    };
    let response: CreatePasteResponse = send_json(
        client.post(server.join("api/pastes")?).json(&request),
        &[StatusCode::CREATED],
    )
    .await?;

    match args.output {
        OutputFormat::Url => println!("{}", response.url),
        OutputFormat::RawUrl => println!("{}", response.raw_url),
        OutputFormat::SignedUrl => println!(
            "{}",
            response
                .signed_url
                .as_deref()
                .ok_or_else(|| anyhow!("server did not return a signed URL; pass --ttl"))?
        ),
        OutputFormat::Id => println!("{}", response.id),
        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&response)?),
    }
    Ok(())
}

async fn update(client: &reqwest::Client, server: &Url, args: UpdateArgs) -> Result<()> {
    let paste_id = id(&args.id)?;
    let request = UpdatePasteRequest {
        content: read_input(args.file, args.edit)?,
        language: args.language,
    };
    put_text(
        client,
        server.join(&format!("api/pastes/{paste_id}"))?,
        &request,
    )
    .await
}

async fn delete(client: &reqwest::Client, server: &Url, args: IdArgs) -> Result<()> {
    let paste_id = id(&args.id)?;
    let response = client
        .delete(server.join(&format!("api/pastes/{paste_id}"))?)
        .send()
        .await?;
    ensure_status(response, &[StatusCode::NO_CONTENT]).await?;
    Ok(())
}

async fn get_text(client: &reqwest::Client, url: Url) -> Result<()> {
    let response = client.get(url).send().await?;
    let response = ensure_success(response).await?;
    print!("{}", response.text().await?);
    Ok(())
}

async fn put_text<T: Serialize + ?Sized>(
    client: &reqwest::Client,
    url: Url,
    body: &T,
) -> Result<()> {
    let response = client.put(url).json(body).send().await?;
    let response = ensure_success(response).await?;
    print!("{}", response.text().await?);
    Ok(())
}

async fn send_json<T: for<'de> Deserialize<'de>>(
    builder: reqwest::RequestBuilder,
    accepted: &[StatusCode],
) -> Result<T> {
    let response = builder.send().await?;
    let response = ensure_status(response, accepted).await?;
    response.json().await.context("decoding server response")
}

async fn ensure_success(response: reqwest::Response) -> Result<reqwest::Response> {
    if response.status().is_success() {
        Ok(response)
    } else {
        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        bail!("server returned {status}: {}", body.trim());
    }
}

async fn ensure_status(
    response: reqwest::Response,
    accepted: &[StatusCode],
) -> Result<reqwest::Response> {
    if accepted.contains(&response.status()) {
        Ok(response)
    } else {
        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        bail!("server returned {status}: {}", body.trim());
    }
}

fn read_input(file: Option<PathBuf>, edit: bool) -> Result<String> {
    match file {
        Some(path) if path.as_os_str() == "-" => read_stdin(),
        Some(path) => fs::read_to_string(&path)
            .with_context(|| format!("reading paste content from {}", path.display())),
        None if edit || io::stdin().is_terminal() => read_editor(),
        None => read_stdin(),
    }
}

fn read_stdin() -> Result<String> {
    let mut content = String::new();
    io::stdin().read_to_string(&mut content)?;
    if content.is_empty() {
        bail!("empty input");
    }
    Ok(content)
}

fn read_editor() -> Result<String> {
    let file = Builder::new().prefix("pastr-").suffix(".txt").tempfile()?;
    let path = file.path().to_owned();
    let editor = env::var("VISUAL")
        .or_else(|_| env::var("EDITOR"))
        .unwrap_or_else(|_| "vi".to_string());
    let status = Command::new(&editor)
        .arg(&path)
        .status()
        .with_context(|| format!("starting editor {editor}"))?;
    if !status.success() {
        bail!("editor exited with {status}");
    }
    let content = fs::read_to_string(&path)?;
    file.close()?;
    if content.is_empty() {
        bail!("empty input");
    }
    Ok(content)
}

fn normalize_server(server: &str) -> Result<Url> {
    let trimmed = server.trim().trim_end_matches('/');
    let mut url = Url::parse(&format!("{trimmed}/"))
        .with_context(|| format!("invalid pastr server URL {server:?}"))?;
    url.set_query(None);
    url.set_fragment(None);
    Ok(url)
}

fn id(value: &str) -> Result<String> {
    if let Ok(url) = Url::parse(value) {
        let mut segments = url
            .path_segments()
            .ok_or_else(|| anyhow!("URL does not contain a paste id"))?
            .filter(|segment| !segment.is_empty());
        return segments
            .next_back()
            .map(urlencoding::decode)
            .transpose()?
            .map(|decoded| decoded.into_owned())
            .ok_or_else(|| anyhow!("URL does not contain a paste id"));
    }
    Ok(value.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extracts_ids_from_urls() {
        assert_eq!(
            id("https://paste.example/f/abc123?exp=1&sig=x").unwrap(),
            "abc123"
        );
        assert_eq!(id("https://paste.example/raw/xyz").unwrap(), "xyz");
        assert_eq!(id("plain-id").unwrap(), "plain-id");
    }

    #[test]
    fn normalizes_server_url() {
        assert_eq!(
            normalize_server("https://paste.example///")
                .unwrap()
                .as_str(),
            "https://paste.example/"
        );
    }
}
