//! Client of the Obnam server API.

use log::trace;
use reqwest::{
    Method, StatusCode,
    blocking::{Client, Request, RequestBuilder, Response},
    header::InvalidHeaderValue,
};
use serde::Deserialize;
use url::{ParseError, Url};

use crate::{
    chunk::{Chunk, ChunkError},
    id::{Id, IdError},
    label::Label,
    repoxface::{BackupRepository, RepositoryError},
};

/// API client.
pub struct ApiClient {
    url: Url,
    token: String,
    client: Client,
}

impl ApiClient {
    /// Create a new client to access a given server API instance.
    pub fn new(url: &Url, token: &str) -> Result<Self, ApiClientError> {
        let client = Client::builder()
            .build()
            .map_err(ApiClientError::CreateClient)?;
        Ok(Self {
            url: url.clone(),
            token: token.into(),
            client,
        })
    }

    /// GET a chunk via server API.
    pub fn get(&self, id: &Id) -> Result<Chunk, ApiClientError> {
        let url = self
            .url
            .join(&format!("chunks/{id}"))
            .map_err(ApiClientError::Join)?;

        let request = self
            .request(Method::GET, &url)?
            .build()
            .map_err(ApiClientError::Build)?;

        let response = self
            .execute(request)
            .map_err(|err| ApiClientError::Get(url.to_string(), err))?;

        if response.status() == StatusCode::OK {
            let body = response.bytes().map_err(ApiClientError::Body)?;
            let chunk = Chunk::parse(&body).map_err(ApiClientError::ParseChunk)?;
            Ok(chunk)
        } else {
            Err(ApiClientError::Unwanted(response.status()))
        }
    }

    /// PUT a chunk via server API.
    pub fn put(&self, id: &Id, label: &Label, data: Vec<u8>) -> Result<(), ApiClientError> {
        let url = self
            .url
            .join(&format!("chunks/{id}"))
            .map_err(ApiClientError::Join)?;

        let request = self
            .request(Method::PUT, &url)?
            .body(data)
            .query(&[("label", label.to_string())])
            .build()
            .map_err(ApiClientError::Build)?;

        let response = self
            .execute(request)
            .map_err(|err| ApiClientError::Put(url.to_string(), err));

        let response = response?;
        if response.status() == StatusCode::CREATED {
            Ok(())
        } else {
            Err(ApiClientError::Unwanted(response.status()))
        }
    }

    /// Find chunks with a given label.
    pub fn find(&self, label: &Label) -> Result<Vec<Id>, ApiClientError> {
        let url = self.url.join("chunks").map_err(ApiClientError::Join)?;

        let request = self
            .request(Method::GET, &url)?
            .query(&[("label", label.to_string())])
            .build()
            .map_err(ApiClientError::Build)?;

        let response = self
            .execute(request)
            .map_err(|err| ApiClientError::Get(url.to_string(), err))?;

        if response.status() == StatusCode::OK {
            let ids: Ids = response.json().map_err(ApiClientError::Json)?;
            let ids: Result<Vec<Id>, IdError> = ids.chunk_ids.iter().map(Id::try_from).collect();
            Ok(ids.map_err(ApiClientError::ParseId)?)
        } else {
            Err(ApiClientError::Unwanted(response.status()))
        }
    }

    /// Delete a chunk.
    pub fn delete(&self, id: &Id) -> Result<(), ApiClientError> {
        let url = self
            .url
            .join(&format!("chunks/{id}"))
            .map_err(ApiClientError::Join)?;

        let request = self
            .request(Method::DELETE, &url)?
            .build()
            .map_err(ApiClientError::Build)?;

        let response = self
            .execute(request)
            .map_err(|err| ApiClientError::Delete(url.to_string(), err))?;

        if response.status() == StatusCode::NO_CONTENT {
            Ok(())
        } else {
            Err(ApiClientError::Unwanted(response.status()))
        }
    }

    fn request(&self, method: Method, url: &Url) -> Result<RequestBuilder, ApiClientError> {
        let builder = match method {
            Method::GET => self.client.get(url.clone()),
            Method::PUT => self.client.put(url.clone()),
            Method::DELETE => self.client.delete(url.clone()),
            _ => return Err(ApiClientError::HttpMethod(method)),
        };
        Ok(builder.bearer_auth(&self.token))
    }

    fn execute(&self, request: Request) -> Result<Response, reqwest::Error> {
        trace!("HTTP request: {request:#?}");
        let response = self.client.execute(request);
        trace!("HTTP response: {response:#?}");
        response
    }
}

impl BackupRepository for ApiClient {
    type Error = ApiClientError;

    fn get(&self, id: &Id) -> Result<Chunk, Self::Error> {
        self.get(id)
    }

    fn put(&self, chunk: &Chunk) -> Result<(), Self::Error> {
        let meta = chunk.metadata();
        self.put(
            meta.id(),
            meta.label(),
            chunk.serialize().map_err(ApiClientError::Serialize)?,
        )
    }

    fn delete(&self, id: &Id) -> Result<(), Self::Error> {
        self.delete(id)
    }

    fn find(&self, label: &Label) -> Result<Vec<Id>, Self::Error> {
        self.find(label)
    }
}

#[derive(Debug, Deserialize)]
#[allow(unused)] // this is used via JSON deserialization.
struct Ids {
    chunk_ids: Vec<String>,
}

/// Errors from [`ApiClient`].
#[derive(Debug, thiserror::Error)]
pub enum ApiClientError {
    /// Can't create HTTP client
    #[error("failed to create HTTP client")]
    CreateClient(#[source] reqwest::Error),

    /// Can't create header value.
    #[error("failed to create HTTP request header value")]
    HeaderValue(#[source] InvalidHeaderValue),

    /// Can't join to URL.
    #[error("failed to construct request URL")]
    Join(#[source] ParseError),

    /// Can't fetch from URL.
    #[error("failed to GET data from {0}")]
    Get(String, reqwest::Error),

    /// Can't upload data to URL.
    #[error("failed to PUT data to {0}")]
    Put(String, reqwest::Error),

    /// Can't delete via URL.
    #[error("failed to DELETE data at {0}")]
    Delete(String, reqwest::Error),

    /// Build request.
    #[error("failed to build HTTP request")]
    Build(#[source] reqwest::Error),

    /// Can't get response body.
    #[error("failed to get HTTP response body")]
    Body(#[source] reqwest::Error),

    /// Can't get response body as JSON.
    #[error("failed to get HTTP response body as JSON")]
    Json(#[source] reqwest::Error),

    /// Can't parse response body as a chunk.
    #[error("failed to understand HTTP response body as a chunk")]
    ParseChunk(#[source] ChunkError),

    /// Can't parse chuhk ID.
    #[error("failed to parse chunk ID")]
    ParseId(#[source] IdError),

    /// Unwanted response from server.
    #[error("server response is unwanted: {0:?}")]
    Unwanted(StatusCode),

    /// Can't serialize chunk.
    #[error("failed to serialize chunk to binary data")]
    Serialize(#[source] ChunkError),

    /// Unsupported HTTP method.
    #[error("unsupported HTTP method {0}")]
    HttpMethod(Method),
}

impl From<ApiClientError> for RepositoryError {
    fn from(value: ApiClientError) -> Self {
        RepositoryError::Underlying(Box::new(value))
    }
}
