// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
use core::fmt;
use std::fmt::Debug;

use llrt_utils::{
    object::define_subclass,
    option::Undefined,
    primordials::{BasePrimordials, Primordial},
};
use rquickjs::{
    atom::PredefinedAtom,
    class::{
        impl_::{CloneTrait, CloneWrapper},
        JsClass, Trace,
    },
    function::{Constructor, Opt},
    object::{Accessor, Property},
    prelude::{Func, This},
    qjs, Class, Coerced, Ctx, Error, Exception, FromJs, IntoJs, JsLifetime, Object, Result, Value,
};

#[derive(JsLifetime)]
struct ExceptionPrimordials<'js> {
    constructor_dom_exception: Constructor<'js>,
    constructor_quota_exceeded_error: Constructor<'js>,
}

impl<'js> Primordial<'js> for ExceptionPrimordials<'js> {
    fn new(ctx: &Ctx<'js>) -> Result<Self> {
        let globals = ctx.globals();
        Ok(Self {
            constructor_dom_exception: globals.get(DOMException::NAME)?,
            constructor_quota_exceeded_error: globals.get("QuotaExceededError")?,
        })
    }
}

#[derive(Trace, JsLifetime, Debug)]
pub struct DOMException {
    name: String,
    message: String,
    stack: String,
    code: u8,
}

impl fmt::Display for DOMException {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("DOMException")
            .field("name", &self.name())
            .field("message", &self.message())
            .field("stack", &self.stack)
            .finish()
    }
}

fn add_constants(obj: &Object<'_>) -> Result<()> {
    const CONSTANTS: [(&str, u8); 25] = [
        ("INDEX_SIZE_ERR", 1),
        ("DOMSTRING_SIZE_ERR", 2),
        ("HIERARCHY_REQUEST_ERR", 3),
        ("WRONG_DOCUMENT_ERR", 4),
        ("INVALID_CHARACTER_ERR", 5),
        ("NO_DATA_ALLOWED_ERR", 6),
        ("NO_MODIFICATION_ALLOWED_ERR", 7),
        ("NOT_FOUND_ERR", 8),
        ("NOT_SUPPORTED_ERR", 9),
        ("INUSE_ATTRIBUTE_ERR", 10),
        ("INVALID_STATE_ERR", 11),
        ("SYNTAX_ERR", 12),
        ("INVALID_MODIFICATION_ERR", 13),
        ("NAMESPACE_ERR", 14),
        ("INVALID_ACCESS_ERR", 15),
        ("VALIDATION_ERR", 16),
        ("TYPE_MISMATCH_ERR", 17),
        ("SECURITY_ERR", 18),
        ("NETWORK_ERR", 19),
        ("ABORT_ERR", 20),
        ("URL_MISMATCH_ERR", 21),
        ("QUOTA_EXCEEDED_ERR", 22),
        ("TIMEOUT_ERR", 23),
        ("INVALID_NODE_TYPE_ERR", 24),
        ("DATA_CLONE_ERR", 25),
    ];

    for (key, value) in CONSTANTS {
        obj.prop(key, Property::from(value).enumerable())?;
    }

    Ok(())
}

impl<'js> JsClass<'js> for DOMException {
    const NAME: &'static str = "DOMException";
    type Mutable = rquickjs::class::Writable;
    fn prototype(ctx: &Ctx<'js>) -> rquickjs::Result<Option<Object<'js>>> {
        use rquickjs::class::impl_::{MethodImpl, MethodImplementor};
        let proto = Object::new(ctx.clone())?;
        let implementor = MethodImpl::<Self>::new();
        implementor.implement(&proto)?;
        add_constants(&proto)?;

        Ok(Some(proto))
    }
    fn constructor(ctx: &Ctx<'js>) -> Result<Option<Constructor<'js>>> {
        use rquickjs::class::impl_::{ConstructorCreate, ConstructorCreator};
        let implementor = ConstructorCreate::<Self>::new();
        let constructor = implementor
            .create_constructor(ctx)?
            .expect("DOMException must have a constructor");
        add_constants(&constructor)?;

        Ok(Some(constructor))
    }
}

impl<'js> IntoJs<'js> for DOMException {
    fn into_js(self, ctx: &rquickjs::Ctx<'js>) -> Result<Value<'js>> {
        let cls = Class::<Self>::instance(ctx.clone(), self)?;
        IntoJs::into_js(cls, ctx)
    }
}

impl<'js> FromJs<'js> for DOMException
where
    for<'a> CloneWrapper<'a, Self>: CloneTrait<Self>,
{
    fn from_js(ctx: &Ctx<'js>, value: Value<'js>) -> Result<Self> {
        let value = Class::<Self>::from_js(ctx, value)?;
        let borrow = value.try_borrow()?;
        Ok(CloneWrapper(&*borrow).wrap_clone())
    }
}

#[rquickjs::methods]
impl DOMException {
    #[qjs(constructor)]
    pub fn new<'js>(
        ctx: Ctx<'js>,
        this: This<Value<'js>>,
        message: Opt<Undefined<Coerced<String>>>,
        name: Opt<Undefined<Coerced<String>>>,
    ) -> Result<Self> {
        // When called with `new`, rquickjs passes the constructor function
        // as `this`. Without `new` this is undefined or the global object.
        if this.0.as_function().is_none() {
            return Err(Exception::throw_type(
                &ctx,
                "Cannot call the DOMException constructor without 'new'",
            ));
        }

        let message = match message.0 {
            Some(Undefined(Some(message))) => message.0,
            _ => String::new(),
        };

        let name = match name.0 {
            Some(Undefined(Some(message))) => DOMExceptionName::from(message.0),
            _ => DOMExceptionName::Error,
        };

        Self::new_with_name(&ctx, name, message)
    }

    #[qjs(skip)]
    pub fn new_with_name(ctx: &Ctx<'_>, name: DOMExceptionName, message: String) -> Result<Self> {
        let primordials = BasePrimordials::get(ctx)?;

        let new: Object = primordials
            .constructor_error
            .construct((message.clone(),))?;

        Ok(Self {
            name: name.as_str().to_string(),
            code: name.code(),
            message,
            stack: new.get::<_, String>(PredefinedAtom::Stack)?,
        })
    }

    #[qjs(get, enumerable, configurable)]
    fn message(&self) -> &str {
        self.message.as_str()
    }

    #[qjs(get, enumerable, configurable)]
    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    #[qjs(get, enumerable, configurable)]
    pub fn code(&self) -> u8 {
        self.code
    }

    #[qjs(prop, rename = PredefinedAtom::SymbolToStringTag, configurable)]
    pub fn to_string_tag() -> &'static str {
        stringify!(DOMException)
    }
}

impl<'js> DOMException {
    fn create(
        ctx: &Ctx<'js>,
        name: DOMExceptionName,
        message: impl Into<String>,
    ) -> Result<Value<'js>> {
        let primordials = ExceptionPrimordials::get(ctx)?;
        let ctor = match name {
            DOMExceptionName::QuotaExceededError => &primordials.constructor_quota_exceeded_error,
            _ => &primordials.constructor_dom_exception,
        };
        ctor.construct((message.into(), name.as_str()))
    }

    fn throw_value(ctx: &Ctx<'js>, value: Value<'js>) -> Error {
        unsafe {
            let dup = qjs::JS_DupValue(ctx.as_raw().as_ptr(), value.as_raw());
            qjs::JS_Throw(ctx.as_raw().as_ptr(), dup);
        }
        Error::Exception
    }

    fn create_error(ctx: &Ctx<'js>, name: DOMExceptionName, message: impl Into<String>) -> Error {
        let value = Self::create(ctx, name, message).expect("failed to create DOMException");
        Self::throw_value(ctx, value)
    }

    pub fn not_supported_error(ctx: &Ctx<'js>, message: impl Into<String>) -> Error {
        Self::create_error(ctx, DOMExceptionName::NotSupportedError, message)
    }

    pub fn type_mismatch_error(ctx: &Ctx<'js>, message: impl Into<String>) -> Error {
        Self::create_error(ctx, DOMExceptionName::TypeMismatchError, message)
    }

    pub fn operation_error(ctx: &Ctx<'js>, message: impl Into<String>) -> Error {
        Self::create_error(ctx, DOMExceptionName::OperationError, message)
    }

    pub fn quota_exceeded_error(ctx: &Ctx<'js>, message: impl Into<String>) -> Error {
        Self::create_error(ctx, DOMExceptionName::QuotaExceededError, message)
    }

    pub fn data_error(ctx: &Ctx<'js>, message: impl Into<String>) -> Error {
        Self::create_error(ctx, DOMExceptionName::DataError, message)
    }

    pub fn invalid_access_error(ctx: &Ctx<'js>, message: impl Into<String>) -> Error {
        Self::create_error(ctx, DOMExceptionName::InvalidAccessError, message)
    }

    fn define_quota_exceeded_error(ctx: &Ctx<'js>) -> Result<()> {
        let dom_exception: Constructor = ctx.globals().get(Self::NAME)?;
        let quota_exceeded_error = define_subclass(
            ctx,
            "QuotaExceededError",
            &dom_exception,
            |ctx, message: Opt<Undefined<Coerced<String>>>| {
                let message = match message.0 {
                    Some(Undefined(Some(m))) => m.0,
                    _ => String::new(),
                };
                Self::new_with_name(&ctx, DOMExceptionName::QuotaExceededError, message)
            },
        )?;
        let null = Value::new_null(ctx.clone());
        let proto: Object = quota_exceeded_error.get(PredefinedAtom::Prototype)?;
        proto.prop(
            "requested",
            Property::from(null.clone()).enumerable().configurable(),
        )?;
        proto.prop("quota", Property::from(null).enumerable().configurable())?;
        ctx.globals().prop(
            "QuotaExceededError",
            Property::from(quota_exceeded_error)
                .writable()
                .configurable(),
        )
    }
}

macro_rules! create_dom_exception {
    ($name:ident, $($variant:ident),+ $(,)?) => {
        #[derive(Debug)]
        pub enum $name {
            $(
                $variant,
            )+
            Other(String),
        }

        impl $name {
            pub fn as_str(&self) -> &str {
                match self {
                    $(
                        Self::$variant => stringify!($variant),
                    )+
                    Self::Other(value) => value,
                }
            }
        }

        impl From<String> for $name {
            fn from(value: String) -> Self {
                match value.as_str() {
                    $(
                        stringify!($variant) => Self::$variant,
                    )+
                    _ => Self::Other(value),
                }
            }
        }
    };
}

// https://webidl.spec.whatwg.org/#dfn-error-names-table
create_dom_exception!(
    DOMExceptionName,
    IndexSizeError,
    HierarchyRequestError,
    WrongDocumentError,
    InvalidCharacterError,
    NoModificationAllowedError,
    NotFoundError,
    NotSupportedError,
    InUseAttributeError,
    InvalidStateError,
    SyntaxError,
    InvalidModificationError,
    NamespaceError,
    InvalidAccessError,
    TypeMismatchError,
    SecurityError,
    NetworkError,
    AbortError,
    URLMismatchError,
    QuotaExceededError,
    TimeoutError,
    InvalidNodeTypeError,
    DataCloneError,
    EncodingError,
    NotReadableError,
    UnknownError,
    ConstraintError,
    DataError,
    TransactionInactiveError,
    ReadOnlyError,
    VersionError,
    OperationError,
    NotAllowedError,
    Error,
);

impl DOMExceptionName {
    fn code(&self) -> u8 {
        match self {
            DOMExceptionName::IndexSizeError => 1,
            DOMExceptionName::HierarchyRequestError => 3,
            DOMExceptionName::WrongDocumentError => 4,
            DOMExceptionName::InvalidCharacterError => 5,
            DOMExceptionName::NoModificationAllowedError => 7,
            DOMExceptionName::NotFoundError => 8,
            DOMExceptionName::NotSupportedError => 9,
            DOMExceptionName::InUseAttributeError => 10,
            DOMExceptionName::InvalidStateError => 11,
            DOMExceptionName::SyntaxError => 12,
            DOMExceptionName::InvalidModificationError => 13,
            DOMExceptionName::NamespaceError => 14,
            DOMExceptionName::InvalidAccessError => 15,
            DOMExceptionName::TypeMismatchError => 17,
            DOMExceptionName::SecurityError => 18,
            DOMExceptionName::NetworkError => 19,
            DOMExceptionName::AbortError => 20,
            DOMExceptionName::URLMismatchError => 21,
            DOMExceptionName::QuotaExceededError => 22,
            DOMExceptionName::TimeoutError => 23,
            DOMExceptionName::InvalidNodeTypeError => 24,
            DOMExceptionName::DataCloneError => 25,
            _ => 0,
        }
    }
}

pub fn init(ctx: &Ctx<'_>) -> Result<()> {
    let globals = ctx.globals();

    BasePrimordials::init(ctx)?;

    if let Some(constructor) = Class::<DOMException>::create_constructor(ctx)? {
        // the wpt tests expect this particular property descriptor
        globals.prop(
            DOMException::NAME,
            Property::from(constructor).writable().configurable(),
        )?;
    }

    let dom_ex_proto = Class::<DOMException>::prototype(ctx)?.unwrap();
    dom_ex_proto.set_prototype(Some(&BasePrimordials::get(ctx)?.prototype_error))?;

    DOMException::define_quota_exceeded_error(ctx)?;
    ExceptionPrimordials::init(ctx)?;

    // `Error.isError(v)` only returns `true` for objects with QuickJS's
    // `[[ErrorData]]` internal slot (class id `JS_CLASS_ERROR`). There is
    // no public rquickjs API to tag a class-derived instance with that
    // slot, so we replace `Error.isError` with a version that also
    // recognizes `DOMException` instances (and its subclasses) via
    // `instanceof`.
    BasePrimordials::get(ctx)?
        .constructor_error
        .set("isError", Func::from(is_error))?;

    define_error_stack_accessor(ctx)?;

    Ok(())
}

// https://tc39.es/proposal-error-stack-accessor/ moves `stack` to an accessor
// on `Error.prototype`, so DOMException inherits it instead of exposing its own.
// QuickJS still gives plain Error instances an own `stack` data property, which
// shadows this accessor, so the getter only runs for DOMException instances.
fn define_error_stack_accessor<'js>(ctx: &Ctx<'js>) -> Result<()> {
    let prototype_error = BasePrimordials::get(ctx)?.prototype_error.clone();
    prototype_error.prop(
        PredefinedAtom::Stack,
        Accessor::new(
            |this: This<Value<'js>>| -> Result<String> {
                let stack = Class::<DOMException>::from_value(&this.0)
                    .ok()
                    .map(|cls| cls.borrow().stack.clone());
                Ok(stack.unwrap_or_default())
            },
            |ctx: Ctx<'js>, this: This<Value<'js>>, value: Value<'js>| -> Result<()> {
                // SetterThatIgnoresPrototypeProperties: never install on the
                // home object itself.
                let Some(obj) = this.0.as_object() else {
                    return Ok(());
                };
                if *obj == BasePrimordials::get(&ctx)?.prototype_error {
                    return Ok(());
                }
                obj.prop(
                    PredefinedAtom::Stack,
                    Property::from(value).writable().enumerable().configurable(),
                )
            },
        )
        .configurable(),
    )
}

fn is_error<'js>(ctx: Ctx<'js>, value: Value<'js>) -> Result<bool> {
    if value.is_error() {
        return Ok(true);
    }
    let Some(obj) = value.as_object() else {
        return Ok(false);
    };
    let dom_exception: Value = ctx.globals().get(DOMException::NAME)?;
    Ok(obj.is_instance_of(&dom_exception))
}
