use std::fmt::Display;

use jsonschema::{Draft, JSONSchema};
use serde_json::{Map, Value};

pub struct Inspector {
    schema: JSONSchema,
}

impl Inspector {
    const PROP: &'static str = "properties";
    const REF: &'static str = "$ref";
    const AOF: &'static str = "allOf";

    /// Initialize an Inspector using the name of the type provided to the function using the turbofish syntax
    pub fn new_infer<T>(schemas: &Value, resolve_refs: bool) -> Result<Self, InitError> {
        // get target name from type inference
        let target = std::any::type_name::<T>()
            .split("::")
            .last()
            .ok_or(InitError::UnknownFailure)?;
        Self::new(target, schemas, resolve_refs)
    }

    /// Construct an instance of a schema `Inspector` using the provided OpenAPI spec's schemas (in the form of `serde_json::Value`)
    pub fn new(target: &str, schemas: &Value, resolve_refs: bool) -> Result<Self, InitError> {
        use InitError::*;
        // extract schemas object
        let Value::Object(schemas) = schemas else {
            return Err(InitError::UnknownFailure);
        };
        // extract target from schema
        let Value::Object(mut target) = schemas
            .get(target)
            .ok_or_else(|| SchemaNotFound(target.to_owned()))?
            .to_owned()
        else {
            return Err(InitError::UnknownFailure);
        };
        if resolve_refs {
            // extract properties
            let Value::Object(mut props) = target.remove(Self::PROP).ok_err()? else {
                return Err(InitError::UnknownFailure);
            };
            Self::resolve_references(schemas, &mut props)?;
            // reinsert resolved properties
            target.insert(Self::PROP.into(), Value::Object(props));
        }
        Ok(Self {
            schema: JSONSchema::options()
                .with_draft(Draft::Draft202012)
                .compile(&Value::Object(target))
                .ok()
                .ok_err()?,
        })
    }

    // Replace internal '$ref' references with the correct schemas
    fn resolve_references(
        schemas: &Map<String, Value>,
        props: &mut Map<String, Value>,
    ) -> Result<(), InitError> {
        let to_resolve: Vec<(String, String)> = props
            .iter()
            .filter_map(|(key, val)| {
                Self::extract_ref(val)
                    .or_else(|| {
                        // Get the first item of allOf array and get the "$ref" field from that object
                        val.get(Self::AOF)
                            .and_then(|aof| aof.get(0).and_then(Self::extract_ref))
                    })
                    .map(|schema_name| (key.to_owned(), schema_name.to_owned()))
            })
            .collect();
        // replace the references in props fields with the correct schemas
        for (field, schema) in &to_resolve {
            let inner = props.get_mut(field).expect("known field cannot fail");
            *inner = schemas
                .get(schema)
                .ok_or_else(|| InitError::ResolutionFailure(schema.to_owned()))?
                .to_owned();
            if let Some(Value::Object(inner_props)) = inner.get_mut(Self::PROP) {
                Self::resolve_references(schemas, inner_props)?;
            }
        }
        Ok(())
    }

    // Extract the fragment and pack it with the given field name
    #[inline]
    fn extract_ref(val: &Value) -> Option<String> {
        let refr = val.get(Self::REF)?;
        let Value::String(final_refr) = refr else {
            return None;
        };
        final_refr.split('/').last().map(ToOwned::to_owned)
    }

    /// Inspect (validate) the given JSON value to check if it conforms to the compiled schema
    ///
    /// [`SchemaError`] is returned upon failure which contains a detailed explanation of everything
    /// that is inconsistent with the compiled schema.
    pub fn inspect(&self, json: &Value) -> Result<(), SchemaError> {
        self.schema.validate(json).map_err(|err| {
            let mut cause = err.fold(String::new(), |mut acc, err| {
                acc.push_str(&err.to_string());
                acc.push_str(", ");
                acc
            });
            cause.pop();
            cause.pop();
            SchemaError { cause }
        })
    }
}

#[derive(Debug)]
pub struct SchemaError {
    cause: String,
}

impl std::error::Error for SchemaError {}

impl Display for SchemaError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "JSON doesn't match expected schema: {}", self.cause)
    }
}

#[derive(Debug)]
pub enum InitError {
    SchemaNotFound(String),
    ResolutionFailure(String),
    CompileFailure,
    UnknownFailure,
}

impl std::error::Error for InitError {}

impl Display for InitError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let base = "failed to initialize Inspector:";
        match self {
            Self::SchemaNotFound(schema) => {
                write!(f, "{base} {schema} was not found in the provided schemas")
            }
            Self::ResolutionFailure(schema) => write!(
                f,
                "{base} failed to resolve references: referenced schema {schema} was not found"
            ),
            Self::UnknownFailure => write!(f, "{base} unknown failure occured"),
            Self::CompileFailure => write!(f, "{base} failed to compile schema"),
        }
    }
}

trait TempMap<T>
where
    Self: Sized,
{
    fn ok_err(self) -> Result<T, InitError>;
}

impl<T> TempMap<T> for Option<T> {
    fn ok_err(self) -> Result<T, InitError> {
        self.ok_or(InitError::UnknownFailure)
    }
}