use std::fs;
use std::path::PathBuf;

use boa_engine::error::JsNativeError;
use boa_engine::js_string;
use boa_engine::native_function::NativeFunction;
use boa_engine::object::{JsObject, ObjectInitializer};
use boa_engine::property::Attribute;
use boa_engine::{Context, JsError, JsResult, JsString, JsValue};

#[derive(Debug, Clone, Copy)]
enum Encoding {
    Utf8,
}

pub(crate) struct Fs;

impl Fs {
    pub const NAME: JsString = js_string!("fs");

    pub fn init(context: &mut Context) -> JsObject {
        ObjectInitializer::new(context)
            .property(
                js_string!("name"),
                JsString::from(Self::NAME),
                Attribute::READONLY,
            )
            .function(
                NativeFunction::from_fn_ptr(Self::read_file_sync),
                js_string!("readFileSync"),
                2,
            )
            .function(
                NativeFunction::from_fn_ptr(Self::write_file_sync),
                js_string!("writeFileSync"),
                2,
            )
            .function(
                NativeFunction::from_fn_ptr(Self::exists_sync),
                js_string!("existsSync"),
                1,
            )
            .function(
                NativeFunction::from_fn_ptr(Self::mkdir_sync),
                js_string!("mkdirSync"),
                1,
            )
            .function(
                NativeFunction::from_fn_ptr(Self::readdir_sync),
                js_string!("readdirSync"),
                1,
            )
            .function(
                NativeFunction::from_fn_ptr(Self::stat_sync),
                js_string!("statSync"),
                1,
            )
            .build()
    }

    fn read_file_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
        let path = Self::path_from_args(args, 0, "path", "readFileSync", context)?;
        let encoding = Self::encoding_from_arg(args.get(1), context)?;

        let content = fs::read(&path).map_err(JsError::from_rust)?;

        match encoding {
            Encoding::Utf8 => {
                let text = String::from_utf8(content).map_err(JsError::from_rust)?;
                Ok(JsValue::new(JsString::from(text)))
            }
        }
    }

    fn write_file_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
        let path = Self::path_from_args(args, 0, "path", "writeFileSync", context)?;
        let data = Self::string_from_arg(args, 1, "data", "writeFileSync", context)?;
        let encoding = Self::encoding_from_arg(args.get(2), context)?;

        match encoding {
            Encoding::Utf8 => {
                fs::write(path, data).map_err(JsError::from_rust)?;
                Ok(JsValue::undefined())
            }
        }
    }

    fn exists_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
        let path = Self::path_from_args(args, 0, "path", "existsSync", context)?;
        Ok(JsValue::new(path.exists()))
    }

    fn mkdir_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
        let path = Self::path_from_args(args, 0, "path", "mkdirSync", context)?;
        let recursive = Self::recursive_from_arg(args.get(1), context)?;

        if recursive {
            fs::create_dir_all(path).map_err(JsError::from_rust)?;
        } else {
            fs::create_dir(path).map_err(JsError::from_rust)?;
        }

        Ok(JsValue::undefined())
    }

    fn readdir_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
        let path = Self::path_from_args(args, 0, "path", "readdirSync", context)?;
        let entries = fs::read_dir(path).map_err(JsError::from_rust)?;

        let names = entries
            .map(|entry| {
                entry
                    .map(|dir_entry| {
                        let name = dir_entry.file_name();
                        JsValue::new(JsString::from(name.to_string_lossy().into_owned()))
                    })
                    .map_err(JsError::from_rust)
            })
            .collect::<JsResult<Vec<_>>>()?;

        Self::array_from_values(names, context)
    }

    fn stat_sync(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
        let path = Self::path_from_args(args, 0, "path", "statSync", context)?;
        let metadata = fs::metadata(path).map_err(JsError::from_rust)?;

        let mut stats = ObjectInitializer::new(context);
        stats
            .property(
                js_string!("size"),
                metadata.len() as f64,
                Attribute::READONLY | Attribute::ENUMERABLE,
            )
            .property(
                js_string!("isFile"),
                metadata.is_file(),
                Attribute::READONLY | Attribute::ENUMERABLE,
            )
            .property(
                js_string!("isDirectory"),
                metadata.is_dir(),
                Attribute::READONLY | Attribute::ENUMERABLE,
            );

        Ok(stats.build().into())
    }

    fn path_from_args(
        args: &[JsValue],
        index: usize,
        name: &str,
        method: &str,
        context: &mut Context,
    ) -> JsResult<PathBuf> {
        let value = args
            .get(index)
            .ok_or_else(|| Self::missing_argument(name, method))?;
        if value.is_undefined() || value.is_null() {
            return Err(Self::missing_argument(name, method));
        }

        let string = value.to_string(context)?.to_std_string_escaped();
        Ok(PathBuf::from(string))
    }

    fn string_from_arg(
        args: &[JsValue],
        index: usize,
        name: &str,
        method: &str,
        context: &mut Context,
    ) -> JsResult<String> {
        let value = args
            .get(index)
            .ok_or_else(|| Self::missing_argument(name, method))?;
        if value.is_undefined() || value.is_null() {
            return Err(Self::missing_argument(name, method));
        }

        Ok(value.to_string(context)?.to_std_string_escaped())
    }

    fn encoding_from_arg(arg: Option<&JsValue>, context: &mut Context) -> JsResult<Encoding> {
        match arg {
            None => Ok(Encoding::Utf8),
            Some(value) if value.is_undefined() => Ok(Encoding::Utf8),
            Some(value) => {
                if let Some(object) = value.as_object() {
                    let enc_value = object.get(JsString::from("encoding"), context)?;
                    if enc_value.is_undefined() {
                        return Ok(Encoding::Utf8);
                    }
                    return Self::encoding_from_value(&enc_value, context);
                }
                Self::encoding_from_value(value, context)
            }
        }
    }

    fn encoding_from_value(value: &JsValue, context: &mut Context) -> JsResult<Encoding> {
        if value.is_undefined() {
            return Ok(Encoding::Utf8);
        }

        let requested = value
            .to_string(context)?
            .to_std_string_escaped()
            .to_ascii_lowercase();

        match requested.as_str() {
            "utf8" | "utf-8" | "utf" => Ok(Encoding::Utf8),
            other => Err(JsNativeError::range()
                .with_message(format!("Unsupported encoding '{other}'"))
                .into()),
        }
    }

    fn recursive_from_arg(option: Option<&JsValue>, context: &mut Context) -> JsResult<bool> {
        match option {
            None => Ok(false),
            Some(value) if value.is_object() => {
                let object = value.as_object().expect("checked object");
                let flag = object.get(JsString::from("recursive"), context)?;
                Ok(flag.to_boolean())
            }
            Some(value) => Ok(value.to_boolean()),
        }
    }

    fn missing_argument(name: &str, method: &str) -> JsError {
        JsNativeError::typ()
            .with_message(format!("{method}: missing required argument `{name}`"))
            .into()
    }

    fn array_from_values(values: Vec<JsValue>, context: &mut Context) -> JsResult<JsValue> {
        let constructor = context
            .intrinsics()
            .constructors()
            .array()
            .constructor()
            .clone();

        let array_value = constructor.construct(&[], None, context)?;
        for (index, value) in values.into_iter().enumerate() {
            array_value.create_data_property_or_throw(index, value, context)?;
        }

        Ok(array_value.into())
    }
}
