/** @import { ProxyMetadata, ProxyStateObject, Source } from '#client' */
import {
    DEV
} from 'esm-env';
import {
    get,
    component_context,
    active_effect
} from './runtime.js';
import {
    array_prototype,
    get_descriptor,
    get_prototype_of,
    is_array,
    object_prototype
} from '../shared/utils.js';
import {
    check_ownership,
    widen_ownership
} from './dev/ownership.js';
import {
    source,
    set
} from './reactivity/sources.js';
import {
    STATE_SYMBOL,
    STATE_SYMBOL_METADATA
} from './constants.js';
import {
    UNINITIALIZED
} from '../../constants.js';
import * as e from './errors.js';
import {
    get_stack
} from './dev/tracing.js';
import {
    tracing_mode_flag
} from '../flags/index.js';

/**
 * @template T
 * @param {T} value
 * @param {ProxyMetadata | null} [parent]
 * @param {Source<T>} [prev] dev mode only
 * @returns {T}
 */
export function proxy(value, parent = null, prev) {
    /** @type {Error | null} */
    var stack = null;
    if (DEV && tracing_mode_flag) {
        stack = get_stack('CreatedAt');
    }
    // if non-proxyable, or is already a proxy, return `value`
    if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
        return value;
    }

    const prototype = get_prototype_of(value);

    if (prototype !== object_prototype && prototype !== array_prototype) {
        return value;
    }

    /** @type {Map<any, Source<any>>} */
    var sources = new Map();
    var is_proxied_array = is_array(value);
    var version = source(0);

    if (is_proxied_array) {
        // We need to create the length source eagerly to ensure that
        // mutations to the array are properly synced with our proxy
        sources.set('length', source( /** @type {any[]} */ (value).length, stack));
    }

    /** @type {ProxyMetadata} */
    var metadata;

    if (DEV) {
        metadata = {
            parent,
            owners: null
        };

        if (prev) {
            // Reuse owners from previous state; necessary because reassignment is not guaranteed to have correct component context.
            // If no previous proxy exists we play it safe and assume ownerless state
            // @ts-expect-error
            const prev_owners = prev.v ? .[STATE_SYMBOL_METADATA] ? .owners;
            metadata.owners = prev_owners ? new Set(prev_owners) : null;
        } else {
            metadata.owners =
                parent === null ?
                component_context !== null ?
                new Set([component_context.function]) :
                null :
                new Set();
        }
    }

    return new Proxy( /** @type {any} */ (value), {
        defineProperty(_, prop, descriptor) {
            if (!('value' in descriptor) ||
                descriptor.configurable === false ||
                descriptor.enumerable === false ||
                descriptor.writable === false
            ) {
                // we disallow non-basic descriptors, because unless they are applied to the
                // target object — which we avoid, so that state can be forked — we will run
                // afoul of the various invariants
                // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/getOwnPropertyDescriptor#invariants
                e.state_descriptors_fixed();
            }

            var s = sources.get(prop);

            if (s === undefined) {
                s = source(descriptor.value, stack);
                sources.set(prop, s);
            } else {
                set(s, proxy(descriptor.value, metadata));
            }

            return true;
        },

        deleteProperty(target, prop) {
            var s = sources.get(prop);

            if (s === undefined) {
                if (prop in target) {
                    sources.set(prop, source(UNINITIALIZED, stack));
                }
            } else {
                // When working with arrays, we need to also ensure we update the length when removing
                // an indexed property
                if (is_proxied_array && typeof prop === 'string') {
                    var ls = /** @type {Source<number>} */ (sources.get('length'));
                    var n = Number(prop);

                    if (Number.isInteger(n) && n < ls.v) {
                        set(ls, n);
                    }
                }
                set(s, UNINITIALIZED);
                update_version(version);
            }

            return true;
        },

        get(target, prop, receiver) {
            if (DEV && prop === STATE_SYMBOL_METADATA) {
                return metadata;
            }

            if (prop === STATE_SYMBOL) {
                return value;
            }

            var s = sources.get(prop);
            var exists = prop in target;

            // create a source, but only if it's an own property and not a prototype property
            if (s === undefined && (!exists || get_descriptor(target, prop) ? .writable)) {
                s = source(proxy(exists ? target[prop] : UNINITIALIZED, metadata), stack);
                sources.set(prop, s);
            }

            if (s !== undefined) {
                var v = get(s);

                // In case of something like `foo = bar.map(...)`, foo would have ownership
                // of the array itself, while the individual items would have ownership
                // of the component that created bar. That means if we later do `foo[0].baz = 42`,
                // we could get a false-positive ownership violation, since the two proxies
                // are not connected to each other via the parent metadata relationship.
                // For this reason, we need to widen the ownership of the children
                // upon access when we detect they are not connected.
                if (DEV) {
                    /** @type {ProxyMetadata | undefined} */
                    var prop_metadata = v ? .[STATE_SYMBOL_METADATA];
                    if (prop_metadata && prop_metadata ? .parent !== metadata) {
                        widen_ownership(metadata, prop_metadata);
                    }
                }

                return v === UNINITIALIZED ? undefined : v;
            }

            return Reflect.get(target, prop, receiver);
        },

        getOwnPropertyDescriptor(target, prop) {
            var descriptor = Reflect.getOwnPropertyDescriptor(target, prop);

            if (descriptor && 'value' in descriptor) {
                var s = sources.get(prop);
                if (s) descriptor.value = get(s);
            } else if (descriptor === undefined) {
                var source = sources.get(prop);
                var value = source ? .v;

                if (source !== undefined && value !== UNINITIALIZED) {
                    return {
                        enumerable: true,
                        configurable: true,
                        value,
                        writable: true
                    };
                }
            }

            return descriptor;
        },

        has(target, prop) {
            if (DEV && prop === STATE_SYMBOL_METADATA) {
                return true;
            }

            if (prop === STATE_SYMBOL) {
                return true;
            }

            var s = sources.get(prop);
            var has = (s !== undefined && s.v !== UNINITIALIZED) || Reflect.has(target, prop);

            if (
                s !== undefined ||
                (active_effect !== null && (!has || get_descriptor(target, prop) ? .writable))
            ) {
                if (s === undefined) {
                    s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED, stack);
                    sources.set(prop, s);
                }

                var value = get(s);
                if (value === UNINITIALIZED) {
                    return false;
                }
            }

            return has;
        },

        set(target, prop, value, receiver) {
            var s = sources.get(prop);
            var has = prop in target;

            // variable.length = value -> clear all signals with index >= value
            if (is_proxied_array && prop === 'length') {
                for (var i = value; i < /** @type {Source<number>} */ (s).v; i += 1) {
                    var other_s = sources.get(i + '');
                    if (other_s !== undefined) {
                        set(other_s, UNINITIALIZED);
                    } else if (i in target) {
                        // If the item exists in the original, we need to create a uninitialized source,
                        // else a later read of the property would result in a source being created with
                        // the value of the original item at that index.
                        other_s = source(UNINITIALIZED, stack);
                        sources.set(i + '', other_s);
                    }
                }
            }

            // If we haven't yet created a source for this property, we need to ensure
            // we do so otherwise if we read it later, then the write won't be tracked and
            // the heuristics of effects will be different vs if we had read the proxied
            // object property before writing to that property.
            if (s === undefined) {
                if (!has || get_descriptor(target, prop) ? .writable) {
                    s = source(undefined, stack);
                    set(s, proxy(value, metadata));
                    sources.set(prop, s);
                }
            } else {
                has = s.v !== UNINITIALIZED;
                set(s, proxy(value, metadata));
            }

            if (DEV) {
                /** @type {ProxyMetadata | undefined} */
                var prop_metadata = value ? .[STATE_SYMBOL_METADATA];
                if (prop_metadata && prop_metadata ? .parent !== metadata) {
                    widen_ownership(metadata, prop_metadata);
                }
                check_ownership(metadata);
            }

            var descriptor = Reflect.getOwnPropertyDescriptor(target, prop);

            // Set the new value before updating any signals so that any listeners get the new value
            if (descriptor ? .set) {
                descriptor.set.call(receiver, value);
            }

            if (!has) {
                // If we have mutated an array directly, we might need to
                // signal that length has also changed. Do it before updating metadata
                // to ensure that iterating over the array as a result of a metadata update
                // will not cause the length to be out of sync.
                if (is_proxied_array && typeof prop === 'string') {
                    var ls = /** @type {Source<number>} */ (sources.get('length'));
                    var n = Number(prop);

                    if (Number.isInteger(n) && n >= ls.v) {
                        set(ls, n + 1);
                    }
                }

                update_version(version);
            }

            return true;
        },

        ownKeys(target) {
            get(version);

            var own_keys = Reflect.ownKeys(target).filter((key) => {
                var source = sources.get(key);
                return source === undefined || source.v !== UNINITIALIZED;
            });

            for (var [key, source] of sources) {
                if (source.v !== UNINITIALIZED && !(key in target)) {
                    own_keys.push(key);
                }
            }

            return own_keys;
        },

        setPrototypeOf() {
            e.state_prototype_fixed();
        }
    });
}

/**
 * @param {Source<number>} signal
 * @param {1 | -1} [d]
 */
function update_version(signal, d = 1) {
    set(signal, signal.v + d);
}

/**
 * @param {any} value
 */
export function get_proxied_value(value) {
    if (value !== null && typeof value === 'object' && STATE_SYMBOL in value) {
        return value[STATE_SYMBOL];
    }

    return value;
}

/**
 * @param {any} a
 * @param {any} b
 */
export function is(a, b) {
    return Object.is(get_proxied_value(a), get_proxied_value(b));
}