use kittybox_indieauth::{
    AuthorizationRequest, PKCEVerifier,
    PKCEChallenge, PKCEMethod, GrantRequest, Scope,
    AuthorizationResponse, TokenData, GrantResponse
};
use clap::Parser;
use std::{borrow::Cow, io::Write};

const DEFAULT_CLIENT_ID: &str = "https://kittybox.fireburn.ru/indieauth-helper";
const DEFAULT_REDIRECT_URI: &str = "http://localhost:60000/callback";

#[derive(Debug, thiserror::Error)]
enum Error {
    #[error("i/o error: {0}")]
    IO(#[from] std::io::Error),
    #[error("http request error: {0}")]
    HTTP(#[from] reqwest::Error),
    #[error("urlencoded encoding error: {0}")]
    UrlencodedEncoding(#[from] serde_urlencoded::ser::Error),
    #[error("url parsing error: {0}")]
    UrlParse(#[from] url::ParseError),
    #[error("indieauth flow error: {0}")]
    IndieAuth(Cow<'static, str>)
}

#[derive(Parser, Debug)]
#[clap(
    name = "kittybox-indieauth-helper",
    author = "Vika <vika@fireburn.ru>",
    version = env!("CARGO_PKG_VERSION"),
    about = "Retrieve an IndieAuth token for debugging",
    long_about = None
)]
struct Args {
    /// Profile URL to use for initiating IndieAuth metadata discovery.
    #[clap(value_parser)]
    me: url::Url,
    /// Scopes to request for the token.
    ///
    /// All IndieAuth scopes are supported, including arbitrary custom scopes.
    #[clap(short, long)]
    scope: Vec<Scope>,
    /// Client ID to use when requesting a token.
    #[clap(short, long, value_parser, default_value = DEFAULT_CLIENT_ID)]
    client_id: url::Url,
    /// Redirect URI to declare. Note: This will break the flow, use only for testing UI.
    #[clap(long, value_parser)]
    redirect_uri: Option<url::Url>
}

fn append_query_string<T: serde::Serialize>(
    url: &url::Url,
    query: T
) -> Result<url::Url, Error> {
    let mut new_url = url.clone();
    let mut query = serde_urlencoded::to_string(query)?;
    if let Some(old_query) = url.query() {
        query.push('&');
        query.push_str(old_query);
    }
    new_url.set_query(Some(&query));

    Ok(new_url)
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    let args = Args::parse();

    let http: reqwest::Client = {
        #[allow(unused_mut)]
        let mut builder = reqwest::Client::builder()
            .user_agent(concat!(
                env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")
            ));

        builder.build().unwrap()
    };

    let redirect_uri: url::Url = args.redirect_uri
        .clone()
        .unwrap_or_else(|| DEFAULT_REDIRECT_URI.parse().unwrap());
    
    eprintln!("Checking .well-known for metadata...");
    let metadata = http.get(args.me.join("/.well-known/oauth-authorization-server")?)
        .header("Accept", "application/json")
        .send()
        .await?
        .json::<kittybox_indieauth::Metadata>()
        .await?;

    let verifier = PKCEVerifier::new();
    
    let authorization_request = AuthorizationRequest {
        response_type: kittybox_indieauth::ResponseType::Code,
        client_id: args.client_id.clone(),
        redirect_uri: redirect_uri.clone(),
        state: kittybox_indieauth::State::new(),
        code_challenge: PKCEChallenge::new(&verifier, PKCEMethod::default()),
        scope: Some(kittybox_indieauth::Scopes::new(args.scope)),
        me: Some(args.me)
    };

    let indieauth_url = append_query_string(
        &metadata.authorization_endpoint,
        authorization_request
    )?;

    eprintln!("Please visit the following URL in your browser:\n\n   {}\n", indieauth_url.as_str());

    if args.redirect_uri.is_some() {
        eprintln!("Custom redirect URI specified, won't be able to catch authorization response.");
        std::process::exit(0);
    }
    
    // Prepare a callback
    let (tx, rx) = tokio::sync::oneshot::channel::<AuthorizationResponse>();
    let server = {
        use axum::{routing::get, extract::Query, response::IntoResponse};

        let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx)));
        
        let router = axum::Router::new()
            .route("/callback", axum::routing::get(
                move |query: Option<Query<AuthorizationResponse>>| async move {
                    if let Some(Query(response)) = query {
                        if let Some(tx) = tx.lock_owned().await.take() {
                            tx.send(response).unwrap();
                        
                            (axum::http::StatusCode::OK,
                             [("Content-Type", "text/plain")],
                             "Thank you! This window can now be closed.")
                                .into_response()
                        } else {
                            (axum::http::StatusCode::BAD_REQUEST,
                             [("Content-Type", "text/plain")],
                             "Oops. The callback was already received. Did you click twice?")
                                .into_response()
                        }
                    } else {
                        axum::http::StatusCode::BAD_REQUEST.into_response()
                    }
                }
            ));

        use std::net::{SocketAddr, IpAddr, Ipv4Addr};

        let server = hyper::server::Server::bind(
            &SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST),60000)
        )
            .serve(router.into_make_service());

        tokio::task::spawn(server)
    };
    
    let authorization_response = rx.await.unwrap();

    // Clean up after the server
    tokio::task::spawn(async move {
        // Wait for the server to settle -- it might need to send its response
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
        // Abort the future -- this should kill the server
        server.abort();
    });

    eprintln!("Got authorization response: {:#?}", authorization_response);
    eprint!("Checking issuer field...");
    std::io::stderr().lock().flush()?;
    
    if dbg!(authorization_response.iss.as_str()) == dbg!(metadata.issuer.as_str()) {
        eprintln!(" Done");
    } else {
        eprintln!(" Failed");
        #[cfg(not(debug_assertions))]
        std::process::exit(1);
    }
    let grant_response: GrantResponse = http.post(metadata.token_endpoint)
        .form(&GrantRequest::AuthorizationCode {
            code: authorization_response.code,
            client_id: args.client_id,
            redirect_uri,
            code_verifier: verifier
        })
        .header("Accept", "application/json")
        .send()
        .await?
        .json()
        .await?;

    if let GrantResponse::AccessToken {
        me,
        profile,
        access_token,
        expires_in,
        refresh_token,
        token_type,
        scope
    } = grant_response {
        eprintln!("Congratulations, {}, access token is ready! {}",
                  me.as_str(),
                  if let Some(exp) = expires_in {
                      format!("It expires in {exp} seconds.")
                  } else {
                      format!("It seems to have unlimited duration.")
                  }
        );
        println!("{}", access_token);
        if let Some(refresh_token) = refresh_token {
            eprintln!("Save this refresh token, it will come in handy:");
            println!("{}", refresh_token);
        };

        if let Some(profile) = profile {
            eprintln!("\nThe token endpoint returned some profile information:");
            if let Some(name) = profile.name {
                eprintln!(" - Name: {name}")
            }
            if let Some(url) = profile.url {
                eprintln!(" - URL: {url}")
            }
            if let Some(photo) = profile.photo {
                eprintln!(" - Photo: {photo}")
            }
            if let Some(email) = profile.email {
                eprintln!(" - Email: {email}")
            }
        }

        Ok(())
    } else {
        return Err(Error::IndieAuth(Cow::Borrowed("IndieAuth token endpoint did not return an access token grant.")));
    }
}