import {
    add_render_callback,
    flush,
    flush_render_callbacks,
    schedule_update,
    dirty_components
} from './scheduler.js';
import {
    current_component,
    set_current_component
} from './lifecycle.js';
import {
    blank_object,
    is_empty,
    is_function,
    run,
    run_all,
    noop
} from './utils.js';
import {
    children,
    detach,
    start_hydrating,
    end_hydrating,
    get_custom_elements_slots,
    insert,
    element,
    attr
} from './dom.js';
import {
    transition_in
} from './transitions.js';

/** @returns {void} */
export function bind(component, name, callback) {
    const index = component.$$.props[name];
    if (index !== undefined) {
        component.$$.bound[index] = callback;
        callback(component.$$.ctx[index]);
    }
}

/** @returns {void} */
export function create_component(block) {
    block && block.c();
}

/** @returns {void} */
export function claim_component(block, parent_nodes) {
    block && block.l(parent_nodes);
}

/** @returns {void} */
export function mount_component(component, target, anchor) {
    const {
        fragment,
        after_update
    } = component.$$;
    fragment && fragment.m(target, anchor);
    // onMount happens before the initial afterUpdate
    add_render_callback(() => {
        const new_on_destroy = component.$$.on_mount.map(run).filter(is_function);
        // if the component was destroyed immediately
        // it will update the `$$.on_destroy` reference to `null`.
        // the destructured on_destroy may still reference to the old array
        if (component.$$.on_destroy) {
            component.$$.on_destroy.push(...new_on_destroy);
        } else {
            // Edge case - component was destroyed immediately,
            // most likely as a result of a binding initialising
            run_all(new_on_destroy);
        }
        component.$$.on_mount = [];
    });
    after_update.forEach(add_render_callback);
}

/** @returns {void} */
export function destroy_component(component, detaching) {
    const $$ = component.$$;
    if ($$.fragment !== null) {
        flush_render_callbacks($$.after_update);
        run_all($$.on_destroy);
        $$.fragment && $$.fragment.d(detaching);
        // TODO null out other refs, including component.$$ (but need to
        // preserve final state?)
        $$.on_destroy = $$.fragment = null;
        $$.ctx = [];
    }
}

/** @returns {void} */
function make_dirty(component, i) {
    if (component.$$.dirty[0] === -1) {
        dirty_components.push(component);
        schedule_update();
        component.$$.dirty.fill(0);
    }
    component.$$.dirty[(i / 31) | 0] |= 1 << i % 31;
}

// TODO: Document the other params
/**
 * @param {SvelteComponent} component
 * @param {import('./public.js').ComponentConstructorOptions} options
 *
 * @param {import('./utils.js')['not_equal']} not_equal Used to compare props and state values.
 * @param {(target: Element | ShadowRoot) => void} [append_styles] Function that appends styles to the DOM when the component is first initialised.
 * This will be the `add_css` function from the compiled component.
 *
 * @returns {void}
 */
export function init(
    component,
    options,
    instance,
    create_fragment,
    not_equal,
    props,
    append_styles = null,
    dirty = [-1]
) {
    const parent_component = current_component;
    set_current_component(component);
    /** @type {import('./private.js').T$$} */
    const $$ = (component.$$ = {
        fragment: null,
        ctx: [],
        // state
        props,
        update: noop,
        not_equal,
        bound: blank_object(),
        // lifecycle
        on_mount: [],
        on_destroy: [],
        on_disconnect: [],
        before_update: [],
        after_update: [],
        context: new Map(options.context || (parent_component ? parent_component.$$.context : [])),
        // everything else
        callbacks: blank_object(),
        dirty,
        skip_bound: false,
        root: options.target || parent_component.$$.root
    });
    append_styles && append_styles($$.root);
    let ready = false;
    $$.ctx = instance ?
        instance(component, options.props || {}, (i, ret, ...rest) => {
            const value = rest.length ? rest[0] : ret;
            if ($$.ctx && not_equal($$.ctx[i], ($$.ctx[i] = value))) {
                if (!$$.skip_bound && $$.bound[i]) $$.bound[i](value);
                if (ready) make_dirty(component, i);
            }
            return ret;
        }) :
        [];
    $$.update();
    ready = true;
    run_all($$.before_update);
    // `false` as a special case of no DOM component
    $$.fragment = create_fragment ? create_fragment($$.ctx) : false;
    if (options.target) {
        if (options.hydrate) {
            start_hydrating();
            // TODO: what is the correct type here?
            // @ts-expect-error
            const nodes = children(options.target);
            $$.fragment && $$.fragment.l(nodes);
            nodes.forEach(detach);
        } else {
            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
            $$.fragment && $$.fragment.c();
        }
        if (options.intro) transition_in(component.$$.fragment);
        mount_component(component, options.target, options.anchor);
        end_hydrating();
        flush();
    }
    set_current_component(parent_component);
}

export let SvelteElement;

if (typeof HTMLElement === 'function') {
    SvelteElement = class extends HTMLElement {
        /** The Svelte component constructor */
        $$ctor;
        /** Slots */
        $$s;
        /** The Svelte component instance */
        $$c;
        /** Whether or not the custom element is connected */
        $$cn = false;
        /** Component props data */
        $$d = {};
        /** `true` if currently in the process of reflecting component props back to attributes */
        $$r = false;
        /** @type {Record<string, CustomElementPropDefinition>} Props definition (name, reflected, type etc) */
        $$p_d = {};
        /** @type {Record<string, Function[]>} Event listeners */
        $$l = {};
        /** @type {Map<Function, Function>} Event listener unsubscribe functions */
        $$l_u = new Map();

        constructor($$componentCtor, $$slots, use_shadow_dom) {
            super();
            this.$$ctor = $$componentCtor;
            this.$$s = $$slots;
            if (use_shadow_dom) {
                this.attachShadow({
                    mode: 'open'
                });
            }
        }

        addEventListener(type, listener, options) {
            // We can't determine upfront if the event is a custom event or not, so we have to
            // listen to both. If someone uses a custom event with the same name as a regular
            // browser event, this fires twice - we can't avoid that.
            this.$$l[type] = this.$$l[type] || [];
            this.$$l[type].push(listener);
            if (this.$$c) {
                const unsub = this.$$c.$on(type, listener);
                this.$$l_u.set(listener, unsub);
            }
            super.addEventListener(type, listener, options);
        }

        removeEventListener(type, listener, options) {
            super.removeEventListener(type, listener, options);
            if (this.$$c) {
                const unsub = this.$$l_u.get(listener);
                if (unsub) {
                    unsub();
                    this.$$l_u.delete(listener);
                }
            }
        }

        async connectedCallback() {
            this.$$cn = true;
            if (!this.$$c) {
                // We wait one tick to let possible child slot elements be created/mounted
                await Promise.resolve();
                if (!this.$$cn || this.$$c) {
                    return;
                }

                function create_slot(name) {
                    return () => {
                        let node;
                        const obj = {
                            c: function create() {
                                node = element('slot');
                                if (name !== 'default') {
                                    attr(node, 'name', name);
                                }
                            },
                            /**
                             * @param {HTMLElement} target
                             * @param {HTMLElement} [anchor]
                             */
                            m: function mount(target, anchor) {
                                insert(target, node, anchor);
                            },
                            d: function destroy(detaching) {
                                if (detaching) {
                                    detach(node);
                                }
                            }
                        };
                        return obj;
                    };
                }
                const $$slots = {};
                const existing_slots = get_custom_elements_slots(this);
                for (const name of this.$$s) {
                    if (name in existing_slots) {
                        $$slots[name] = [create_slot(name)];
                    }
                }
                for (const attribute of this.attributes) {
                    // this.$$data takes precedence over this.attributes
                    const name = this.$$g_p(attribute.name);
                    if (!(name in this.$$d)) {
                        this.$$d[name] = get_custom_element_value(name, attribute.value, this.$$p_d, 'toProp');
                    }
                }
                // Port over props that were set programmatically before ce was initialized
                for (const key in this.$$p_d) {
                    if (!(key in this.$$d) && this[key] !== undefined) {
                        this.$$d[key] = this[key]; // don't transform, these were set through JavaScript
                        delete this[key]; // remove the property that shadows the getter/setter
                    }
                }
                this.$$c = new this.$$ctor({
                    target: this.shadowRoot || this,
                    props: {
                        ...this.$$d,
                        $$slots,
                        $$scope: {
                            ctx: []
                        }
                    }
                });

                // Reflect component props as attributes
                const reflect_attributes = () => {
                    this.$$r = true;
                    for (const key in this.$$p_d) {
                        this.$$d[key] = this.$$c.$$.ctx[this.$$c.$$.props[key]];
                        if (this.$$p_d[key].reflect) {
                            const attribute_value = get_custom_element_value(
                                key,
                                this.$$d[key],
                                this.$$p_d,
                                'toAttribute'
                            );
                            if (attribute_value == null) {
                                this.removeAttribute(this.$$p_d[key].attribute || key);
                            } else {
                                this.setAttribute(this.$$p_d[key].attribute || key, attribute_value);
                            }
                        }
                    }
                    this.$$r = false;
                };
                this.$$c.$$.after_update.push(reflect_attributes);
                reflect_attributes(); // once initially because after_update is added too late for first render

                for (const type in this.$$l) {
                    for (const listener of this.$$l[type]) {
                        const unsub = this.$$c.$on(type, listener);
                        this.$$l_u.set(listener, unsub);
                    }
                }
                this.$$l = {};
            }
        }

        // We don't need this when working within Svelte code, but for compatibility of people using this outside of Svelte
        // and setting attributes through setAttribute etc, this is helpful
        attributeChangedCallback(attr, _oldValue, newValue) {
            if (this.$$r) return;
            attr = this.$$g_p(attr);
            this.$$d[attr] = get_custom_element_value(attr, newValue, this.$$p_d, 'toProp');
            this.$$c ? .$set({
                [attr]: this.$$d[attr]
            });
        }

        disconnectedCallback() {
            this.$$cn = false;
            // In a microtask, because this could be a move within the DOM
            Promise.resolve().then(() => {
                if (!this.$$cn && this.$$c) {
                    this.$$c.$destroy();
                    this.$$c = undefined;
                }
            });
        }

        $$g_p(attribute_name) {
            return (
                Object.keys(this.$$p_d).find(
                    (key) =>
                    this.$$p_d[key].attribute === attribute_name ||
                    (!this.$$p_d[key].attribute && key.toLowerCase() === attribute_name)
                ) || attribute_name
            );
        }
    };
}

/**
 * @param {string} prop
 * @param {any} value
 * @param {Record<string, CustomElementPropDefinition>} props_definition
 * @param {'toAttribute' | 'toProp'} [transform]
 */
function get_custom_element_value(prop, value, props_definition, transform) {
    const type = props_definition[prop] ? .type;
    value = type === 'Boolean' && typeof value !== 'boolean' ? value != null : value;
    if (!transform || !props_definition[prop]) {
        return value;
    } else if (transform === 'toAttribute') {
        switch (type) {
            case 'Object':
            case 'Array':
                return value == null ? null : JSON.stringify(value);
            case 'Boolean':
                return value ? '' : null;
            case 'Number':
                return value == null ? null : value;
            default:
                return value;
        }
    } else {
        switch (type) {
            case 'Object':
            case 'Array':
                return value && JSON.parse(value);
            case 'Boolean':
                return value; // conversion already handled above
            case 'Number':
                return value != null ? +value : value;
            default:
                return value;
        }
    }
}

/**
 * @internal
 *
 * Turn a Svelte component into a custom element.
 * @param {import('./public.js').ComponentType} Component  A Svelte component constructor
 * @param {Record<string, CustomElementPropDefinition>} props_definition  The props to observe
 * @param {string[]} slots  The slots to create
 * @param {string[]} accessors  Other accessors besides the ones for props the component has
 * @param {boolean} use_shadow_dom  Whether to use shadow DOM
 * @param {(ce: new () => HTMLElement) => new () => HTMLElement} [extend]
 */
export function create_custom_element(
    Component,
    props_definition,
    slots,
    accessors,
    use_shadow_dom,
    extend
) {
    let Class = class extends SvelteElement {
        constructor() {
            super(Component, slots, use_shadow_dom);
            this.$$p_d = props_definition;
        }
        static get observedAttributes() {
            return Object.keys(props_definition).map((key) =>
                (props_definition[key].attribute || key).toLowerCase()
            );
        }
    };
    Object.keys(props_definition).forEach((prop) => {
        Object.defineProperty(Class.prototype, prop, {
            get() {
                return this.$$c && prop in this.$$c ? this.$$c[prop] : this.$$d[prop];
            },
            set(value) {
                value = get_custom_element_value(prop, value, props_definition);
                this.$$d[prop] = value;
                this.$$c ? .$set({
                    [prop]: value
                });
            }
        });
    });
    accessors.forEach((accessor) => {
        Object.defineProperty(Class.prototype, accessor, {
            get() {
                return this.$$c ? .[accessor];
            }
        });
    });
    if (extend) {
        // @ts-expect-error - assigning here is fine
        Class = extend(Class);
    }
    Component.element = /** @type {any} */ (Class);
    return Class;
}

/**
 * Base class for Svelte components. Used when dev=false.
 *
 * @template {Record<string, any>} [Props=any]
 * @template {Record<string, any>} [Events=any]
 */
export class SvelteComponent {
    /**
     * ### PRIVATE API
     *
     * Do not use, may change at any time
     *
     * @type {any}
     */
    $$ = undefined;
    /**
     * ### PRIVATE API
     *
     * Do not use, may change at any time
     *
     * @type {any}
     */
    $$set = undefined;

    /** @returns {void} */
    $destroy() {
        destroy_component(this, 1);
        this.$destroy = noop;
    }

    /**
     * @template {Extract<keyof Events, string>} K
     * @param {K} type
     * @param {((e: Events[K]) => void) | null | undefined} callback
     * @returns {() => void}
     */
    $on(type, callback) {
        if (!is_function(callback)) {
            return noop;
        }
        const callbacks = this.$$.callbacks[type] || (this.$$.callbacks[type] = []);
        callbacks.push(callback);
        return () => {
            const index = callbacks.indexOf(callback);
            if (index !== -1) callbacks.splice(index, 1);
        };
    }

    /**
     * @param {Partial<Props>} props
     * @returns {void}
     */
    $set(props) {
        if (this.$$set && !is_empty(props)) {
            this.$$.skip_bound = true;
            this.$$set(props);
            this.$$.skip_bound = false;
        }
    }
}

/**
 * @typedef {Object} CustomElementPropDefinition
 * @property {string} [attribute]
 * @property {boolean} [reflect]
 * @property {'String'|'Boolean'|'Number'|'Array'|'Object'} [type]
 */