/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */
import { assert, Lazy, fail, debugAssert } from "@fluidframework/core-utils/internal";
import { isFluidHandle } from "@fluidframework/runtime-utils/internal";
import { UsageError } from "@fluidframework/telemetry-utils/internal";
import { ObjectNodeStoredSchema, } from "../../../core/index.js";
import { FieldKinds, isTreeValue, } from "../../../feature-libraries/index.js";
import { brand } from "../../../util/index.js";
import { CompatibilityLevel, NodeKind, 
// eslint-disable-next-line import-x/no-deprecated
typeNameSymbol, typeSchemaSymbol, getInnerNode, TreeNodeValid, privateDataSymbol, createTreeNodeSchemaPrivateData, createField, } from "../../core/index.js";
import { getTreeNodeSchemaInitializedData, getUnhydratedContext, } from "../../createContext.js";
import { getStoredKey, getExplicitStoredKey, FieldSchemaAlpha, normalizeFieldSchema, FieldKind, extractFieldProvider, isConstant, } from "../../fieldSchema.js";
import { tryGetTreeNodeForField } from "../../getTreeNodeForField.js";
import { prepareForInsertion } from "../../prepareForInsertion.js";
import { convertFieldKind } from "../../toStoredSchema.js";
import { unhydratedFlexTreeFromInsertable, unhydratedFlexTreeFromInsertableNode, } from "../../unhydratedFlexTreeFromInsertable.js";
/**
 * Caches the mappings from property keys to stored keys for the provided object field schemas in {@link simpleKeyToFlexKeyCache}.
 */
function createFlexKeyMapping(fields) {
    const keyMap = new Map();
    for (const [propertyKey, fieldSchema] of Object.entries(fields)) {
        const schema = normalizeFieldSchema(fieldSchema);
        const storedKey = getStoredKey(propertyKey, schema);
        keyMap.set(propertyKey, { storedKey, schema });
    }
    return keyMap;
}
/**
 * Creates a proxy handler for the given schema.
 *
 * @param allowAdditionalProperties - If true, setting of unexpected properties will be forwarded to the target object.
 * Otherwise setting of unexpected properties will error.
 * TODO: consider implementing this using `Object.preventExtension` instead.
 * @param customTargetObject - Target object of the proxy.
 * If not provided `{}` is used for the target.
 */
function createProxyHandler(schema, allowAdditionalProperties) {
    // To satisfy 'deepEquals' level scrutiny, the target of the proxy must be an object with the same
    // prototype as an object literal '{}'.  This is because 'deepEquals' uses 'Object.getPrototypeOf'
    // as a way to quickly reject objects with different prototype chains.
    //
    // (Note that the prototype of an object literal appears as '[Object: null prototype] {}', not because
    // the prototype is null, but because the prototype object itself has a null prototype.)
    // TODO: Although the target is an object literal, it's still worthwhile to try experimenting with
    // a dispatch object to see if it improves performance.
    const handler = {
        get(target, propertyKey, proxy) {
            const fieldInfo = schema.flexKeyMap.get(propertyKey);
            if (fieldInfo !== undefined) {
                const flexNode = getInnerNode(proxy);
                debugAssert(() => !flexNode.context.isDisposed() || "FlexTreeNode is disposed");
                const field = flexNode.tryGetField(fieldInfo.storedKey);
                if (field !== undefined) {
                    return tryGetTreeNodeForField(field);
                }
                return undefined;
            }
            // POJO mode objects don't have TreeNode's built in members on their targets, so special case them:
            if (propertyKey === typeSchemaSymbol) {
                return schema;
            }
            // eslint-disable-next-line import-x/no-deprecated
            if (propertyKey === typeNameSymbol) {
                return schema.identifier;
            }
            // Pass the proxy as the receiver here, so that any methods on the prototype receive `proxy` as `this`.
            return Reflect.get(target, propertyKey, proxy);
        },
        set(target, propertyKey, value, proxy) {
            const fieldInfo = schema.flexKeyMap.get(propertyKey);
            if (fieldInfo === undefined) {
                // Pass the proxy as the receiver here, so that setters on the prototype receive `proxy` as `this`.
                return allowAdditionalProperties
                    ? Reflect.set(target, propertyKey, value, proxy)
                    : false;
            }
            applyFieldChange(schema, { kind: "proxy", node: proxy }, fieldInfo, value);
            return true;
        },
        deleteProperty(target, propertyKey) {
            const fieldInfo = schema.flexKeyMap.get(propertyKey);
            if (fieldInfo === undefined) {
                return allowAdditionalProperties ? Reflect.deleteProperty(target, propertyKey) : false;
            }
            applyFieldChange(schema, { kind: "target", node: target }, fieldInfo, undefined);
            return true;
        },
        has: (target, propertyKey) => {
            return (schema.flexKeyMap.has(propertyKey) ||
                (allowAdditionalProperties ? Reflect.has(target, propertyKey) : false));
        },
        ownKeys: (target) => {
            return [
                ...schema.flexKeyMap.keys(),
                ...(allowAdditionalProperties ? Reflect.ownKeys(target) : []),
            ];
        },
        getOwnPropertyDescriptor: (target, propertyKey) => {
            const fieldInfo = schema.flexKeyMap.get(propertyKey);
            if (fieldInfo === undefined) {
                return allowAdditionalProperties
                    ? Reflect.getOwnPropertyDescriptor(target, propertyKey)
                    : undefined;
            }
            // For some reason, the getOwnPropertyDescriptor is not passed in the receiver, so use a weak map.
            // If a refactoring is done to associated flex tree data with the target not the proxy, this extra map could be removed,
            // and the design would be more compatible with proxyless nodes.
            const proxy = targetToProxy.get(target) ?? fail(0xadd /* missing proxy */);
            const field = getInnerNode(proxy).tryGetField(fieldInfo.storedKey);
            const p = {
                value: field === undefined ? undefined : tryGetTreeNodeForField(field),
                writable: true,
                // Report empty fields as own properties so they shadow inherited properties (even when empty) to match TypeScript typing.
                // Make empty fields not enumerable so they get skipped when iterating over an object to better align with
                // JSON and deep equals with JSON compatible object (which can't have undefined fields).
                enumerable: field !== undefined,
                configurable: true, // Must be 'configurable' if property is absent from proxy target.
            };
            return p;
        },
    };
    return handler;
}
export function setField(field, simpleFieldSchema, value, destinationSchema) {
    const mapTree = prepareForInsertion(value, simpleFieldSchema, field.context, destinationSchema);
    switch (field.schema) {
        case FieldKinds.required.identifier: {
            assert(mapTree !== undefined, 0xa04 /* Cannot set a required field to undefined */);
            const typedField = field;
            typedField.editor.set(mapTree);
            break;
        }
        case FieldKinds.optional.identifier: {
            const typedField = field;
            typedField.editor.set(mapTree, typedField.length === 0);
            break;
        }
        default: {
            fail(0xade /* invalid FieldKind */);
        }
    }
}
/**
 * {@link FieldSchemaAlpha} including {@link SimpleObjectFieldSchema}.
 */
export class ObjectFieldSchema extends FieldSchemaAlpha {
    constructor(kind, allowedTypes, props) {
        super(kind, allowedTypes, props);
        this.storedKey = props.key;
    }
}
class CustomObjectNodeBase extends TreeNodeValid {
}
CustomObjectNodeBase.kind = NodeKind.Object;
/**
 * Define a {@link TreeNodeSchema} for a {@link TreeObjectNode}.
 *
 * @param name - Unique identifier for this schema within this factory's scope.
 * @param fields - Schema for fields of the object node's schema. Defines what children can be placed under each key.
 * @param persistedMetadata - Optional persisted metadata for the object node schema.
 */
export function objectSchema(identifier, info, implicitlyConstructable, nodeOptions) {
    // Field set can't be modified after this since derived data is stored in maps.
    Object.freeze(info);
    // Ensure no collisions between final set of property keys, and final set of stored keys (including those
    // implicitly derived from property keys)
    assertUniqueKeys(identifier, info);
    // Performance optimization: cache property key => stored key and schema.
    const flexKeyMap = createFlexKeyMapping(info);
    const identifierFieldKeys = [];
    for (const item of flexKeyMap.values()) {
        if (item.schema.kind === FieldKind.Identifier) {
            identifierFieldKeys.push(item.storedKey);
        }
    }
    const lazyChildTypes = new Lazy(() => new Set(Array.from(flexKeyMap.values(), (f) => [...f.schema.allowedTypeSet]).flat()));
    let privateData;
    let handler;
    let customizable;
    class CustomObjectNode extends CustomObjectNodeBase {
        static prepareInstance(instance, flexNode) {
            // Differentiate between the following cases:
            //
            // Case 1: Direct construction (POJO emulation)
            //
            //     const Foo = schemaFactory.object("Foo", {bar: schemaFactory.number});
            //
            //     assert.deepEqual(new Foo({ bar: 42 }), { bar: 42 },
            //		   "Prototype chain equivalent to POJO.");
            //
            // Case 2: Subclass construction (Customizable Object)
            //
            // 	   class Foo extends schemaFactory.object("Foo", {bar: schemaFactory.number}) {}
            //
            // 	   assert.notDeepEqual(new Foo({ bar: 42 }), { bar: 42 },
            // 	       "Subclass prototype chain differs from POJO.");
            //
            // In Case 1 (POJO emulation), the prototype chain match '{}' (proxyTarget = undefined)
            // In Case 2 (Customizable Object), the prototype chain include the user's subclass (proxyTarget = this)
            const proxyTarget = customizable ? instance : {};
            const proxy = new Proxy(proxyTarget, handler);
            targetToProxy.set(proxyTarget, proxy);
            return proxy;
        }
        static buildRawNode(instance, input) {
            return unhydratedFlexTreeFromInsertable(input, this);
        }
        static oneTimeSetup() {
            // One time initialization that required knowing the most derived type (from this.constructor) and thus has to be lazy.
            customizable = this !== CustomObjectNode;
            const schema = this;
            handler = createProxyHandler(schema, customizable);
            // First run, do extra validation.
            // TODO: provide a way for TreeConfiguration to trigger this same validation to ensure it gets run early.
            // Scan for shadowing inherited members which won't work, but stop scan early to allow shadowing built in (which seems to work ok).
            {
                let prototype = this.prototype;
                // There isn't a clear cleaner way to author this loop.
                while (prototype !== CustomObjectNode.prototype) {
                    for (const [key] of flexKeyMap) {
                        if (
                        // constructor is a special case, since one is built in on the derived type, and shadowing it works fine since we only use it before fields are applied.
                        key !== "constructor" &&
                            Reflect.getOwnPropertyDescriptor(prototype, key) !== undefined) {
                            throw new UsageError(`Schema ${identifier} defines an inherited property "${key.toString()}" which shadows a field. Since fields are exposed as own properties, this shadowing will not work, and is an error.`);
                        }
                    }
                    // Since this stops at CustomObjectNode, it should never see a null prototype, so this case is safe.
                    // Additionally, if the prototype chain is ever messed up such that CustomObjectNode is not in it,
                    // the null that would show up here does at least ensure this code throws instead of hanging.
                    prototype = Reflect.getPrototypeOf(prototype);
                }
            }
            return getTreeNodeSchemaInitializedData(schema, {
                shallowCompatibilityTest: (data) => shallowCompatibilityTest(data, schema),
                toFlexContent: (data, allowedTypes) => objectToFlexContent(data, schema),
            });
        }
        static get childTypes() {
            return lazyChildTypes.value;
        }
        // eslint-disable-next-line import-x/no-deprecated
        get [typeNameSymbol]() {
            return identifier;
        }
        get [typeSchemaSymbol]() {
            return CustomObjectNode.constructorCached?.constructor;
        }
        static get [privateDataSymbol]() {
            return (privateData ??= createTreeNodeSchemaPrivateData(this, Array.from(CustomObjectNode.fields.values(), (schema) => schema.allowedTypesFull)));
        }
    }
    CustomObjectNode.fields = new Map(Array.from(flexKeyMap, ([key, value]) => [
        key,
        new ObjectFieldSchema(value.schema.kind, value.schema.allowedTypes, {
            ...value.schema.props,
            key: getStoredKey(key, value.schema),
        }),
    ]));
    CustomObjectNode.flexKeyMap = flexKeyMap;
    CustomObjectNode.storedKeyToPropertyKey = new Map(Array.from(flexKeyMap, ([key, value]) => [
        value.storedKey,
        key,
    ]));
    CustomObjectNode.identifierFieldKeys = identifierFieldKeys;
    CustomObjectNode.allowUnknownOptionalFields = nodeOptions.allowUnknownOptionalFields ?? false;
    CustomObjectNode.constructorCached = undefined;
    CustomObjectNode.identifier = identifier;
    CustomObjectNode.info = info;
    CustomObjectNode.implicitlyConstructable = implicitlyConstructable;
    CustomObjectNode.metadata = nodeOptions.metadata ?? {};
    CustomObjectNode.persistedMetadata = nodeOptions.persistedMetadata;
    return CustomObjectNode;
}
const targetToProxy = new WeakMap();
/**
 * Ensures that the set of property keys in the schema is unique.
 * Also ensure that the final set of stored keys (including those implicitly derived from property keys) is unique.
 * @throws Throws a `UsageError` if either of the key uniqueness invariants is violated.
 */
function assertUniqueKeys(schemaName, fields) {
    // Verify that there are no duplicates among the explicitly specified stored keys.
    const explicitStoredKeys = new Set();
    for (const schema of Object.values(fields)) {
        const storedKey = getExplicitStoredKey(schema);
        if (storedKey === undefined) {
            continue;
        }
        if (explicitStoredKeys.has(storedKey)) {
            throw new UsageError(`Duplicate stored key "${storedKey}" in schema "${schemaName}". Stored keys must be unique within an object schema.`);
        }
        explicitStoredKeys.add(storedKey);
    }
    // Verify that there are no duplicates among the derived
    // (including those implicitly derived from property keys) stored keys.
    const derivedStoredKeys = new Set();
    for (const [propertyKey, schema] of Object.entries(fields)) {
        const storedKey = getStoredKey(propertyKey, schema);
        if (derivedStoredKeys.has(storedKey)) {
            throw new UsageError(`Stored key "${storedKey}" in schema "${schemaName}" conflicts with a property key of the same name, which is not overridden by a stored key. The final set of stored keys in an object schema must be unique.`);
        }
        derivedStoredKeys.add(storedKey);
    }
}
/**
 * {@link TreeNodeSchemaInitializedData.toFlexContent} for object nodes.
 *
 * @param data - The tree data to be transformed. Must be a Record-like object.
 * @param schema - The schema to comply with.
 */
function objectToFlexContent(data, schema) {
    if (typeof data !== "object" ||
        data === null ||
        Symbol.iterator in data ||
        isFluidHandle(data)) {
        // eslint-disable-next-line @typescript-eslint/no-base-to-string
        throw new UsageError(`Input data is incompatible with Object schema: ${data}`);
    }
    const fields = new Map();
    const context = getUnhydratedContext(schema).flexContext;
    for (const [key, fieldInfo] of schema.flexKeyMap) {
        const value = getFieldProperty(data, key);
        let children;
        if (value === undefined) {
            const defaultProvider = fieldInfo.schema.props?.defaultProvider ??
                fail(0xbb1 /* missing field has no default provider */);
            const fieldProvider = extractFieldProvider(defaultProvider);
            children = isConstant(fieldProvider) ? fieldProvider() : fieldProvider;
        }
        else {
            children = [
                unhydratedFlexTreeFromInsertableNode(value, fieldInfo.schema.allowedTypeSet),
            ];
        }
        const kind = convertFieldKind.get(fieldInfo.schema.kind) ?? fail(0xbb2 /* Invalid field kind */);
        fields.set(fieldInfo.storedKey, createField(context, kind.identifier, fieldInfo.storedKey, children));
    }
    return [{ type: brand(schema.identifier) }, fields];
}
/**
 * {@link TreeNodeSchemaInitializedData.shallowCompatibilityTest} for Object nodes.
 */
function shallowCompatibilityTest(data, schema) {
    if (isTreeValue(data)) {
        return CompatibilityLevel.None;
    }
    if (Symbol.iterator in data) {
        return CompatibilityLevel.None;
    }
    // TODO: Improve type inference by making this logic more thorough. Handle at least:
    // * Types which are strict subsets of other types in the same polymorphic union
    // * Types which have the same keys but different types for those keys in the polymorphic union
    // * Types which have the same required fields but different optional fields and enough of those optional fields are populated to disambiguate
    // TODO#7441: Consider allowing data to be inserted which has keys that are extraneous/unknown to the schema (those keys are ignored)
    // If the schema has a required key which is not present in the input object, reject it.
    for (const [fieldKey, fieldSchema] of schema.fields) {
        if (fieldSchema.requiresValue && getFieldProperty(data, fieldKey) === undefined) {
            return CompatibilityLevel.None;
        }
    }
    return CompatibilityLevel.Normal;
}
/**
 * Check {@link FactoryContentObject} for a property which could be store a field.
 *
 * @returns If the property exists, return its value. Otherwise, returns undefined.
 * @remarks
 * The currently policy is to only consider own properties.
 * See {@link InsertableObjectFromSchemaRecord} for where this policy is documented in the public API.
 *
 * Explicit undefined values are treated the same as missing properties to allow explicit use of undefined with defaulted identifiers.
 *
 * @privateRemarks
 * If we ever want to have an optional field which defaults to something other than undefined, this will need changes.
 * It would need to adjusting the handling of explicit undefined in contexts where undefined is allowed, and a default provider also exists.
 */
function getFieldProperty(data, key) {
    // This policy only allows own properties.
    if (Object.hasOwnProperty.call(data, key)) {
        return data[key];
    }
    return undefined;
}
function applyFieldChange(schema, from, fieldInfo, value) {
    const proxy = from.kind === "proxy"
        ? from.node
        : (targetToProxy.get(from.node) ?? fail(0xc95 /* missing proxy */));
    const inner = getInnerNode(proxy);
    const storedSchema = inner.context.schema.nodeSchema.get(brand(schema.identifier));
    assert(storedSchema instanceof ObjectNodeStoredSchema, 0xc96 /* Expected ObjectNodeStoredSchema */);
    if (value === undefined && inner.tryGetField(fieldInfo.storedKey) === undefined) {
        return;
    }
    setField(inner.getBoxed(fieldInfo.storedKey), fieldInfo.schema, value, storedSchema.getFieldSchema(fieldInfo.storedKey));
}
//# sourceMappingURL=objectNode.js.map