import { parseScript } from 'meriyah';
import { jsBuiltIns, memberBaseName, memberToString, walkAst } from './helpers.js';
/**
 * Performs dependency-aware extraction of variables inside an IIFE.
 */
export class JsAnalyzer {
    source;
    programAst;
    hasExtractions;
    extractionStates;
    dependentsTracker = new Map();
    pendingPrototypeAliasBinding = null;
    iifeParamName = null;
    declaredVariables = new Map();
    /**
     * Creates a new instance over the provided source.
     * @param code JavaScript source to parse and inspect.
     * @param options Optional traversal settings.
     */
    constructor(code, options = {}) {
        this.source = code;
        const extractionConfigs = options.extractions
            ? Array.isArray(options.extractions)
                ? options.extractions
                : [options.extractions]
            : [];
        this.extractionStates = extractionConfigs.map((config) => ({
            config: { collectDependencies: true, stopWhenReady: true, ...config },
            dependencies: new Set(),
            dependents: new Set(),
            ready: false
        }));
        this.hasExtractions = this.extractionStates.length > 0;
        this.programAst = parseScript(code, {
            ranges: true,
            loc: false,
            module: false
        });
        this.analyzeAst();
    }
    /**
     * Walks the AST to collect declarations and resolve initial targets.
     */
    analyzeAst() {
        let iifeBody;
        for (const statement of this.programAst.body) {
            if (statement.type === 'ExpressionStatement' && statement.expression.type === 'CallExpression') {
                const callExpr = statement.expression;
                if (callExpr.callee.type === 'FunctionExpression') {
                    const funcExpr = callExpr.callee;
                    const firstParam = funcExpr.params.length > 0 ? funcExpr.params[0] : null;
                    if (!this.iifeParamName && firstParam?.type === 'Identifier') {
                        this.iifeParamName = firstParam.name; // Maybe it should be an array?
                    }
                    if (funcExpr.body?.type === 'BlockStatement') {
                        iifeBody = funcExpr.body;
                        break; // Found it, no need to continue.
                    }
                }
            }
        }
        if (!iifeBody)
            return;
        for (const currentNode of iifeBody.body) {
            switch (currentNode.type) {
                case 'ExpressionStatement': {
                    const assignment = currentNode.expression;
                    if (assignment.type !== 'AssignmentExpression')
                        continue;
                    const left = assignment.left;
                    const right = assignment.right;
                    // Detect things like `a.b = g.c.prototype` so later `a.b.foo = ...` can be attributed back to `g.c`.
                    if (right.type === 'MemberExpression' &&
                        !right.computed &&
                        right.property.type === 'Identifier' &&
                        right.property.name === 'prototype') {
                        const prototypeSourceExpr = memberToString(right, this.source);
                        const aliasTargetExpr = left.type === 'Identifier' ? left.name : memberToString(left, this.source);
                        if (prototypeSourceExpr) {
                            const prototypeOwnerMeta = this.declaredVariables.get(prototypeSourceExpr.replace('.prototype', ''));
                            if (aliasTargetExpr && prototypeOwnerMeta) {
                                const aliasedPrototypeMembers = new Set();
                                const aliasExpr = `${aliasTargetExpr}.`; // Had to add a dot here so we can detect it later when matching member expressions..
                                // Activate an alias binding context, so subsequent member assignments to the alias (`a.b.foo = ...`) can be tracked.
                                // NOTE: This assumes that the alias members come right after this declaration and are grouped together in the code, hehe :)
                                this.pendingPrototypeAliasBinding = [aliasExpr, prototypeOwnerMeta];
                                prototypeOwnerMeta.prototypeAliases.set(aliasExpr, aliasedPrototypeMembers);
                            }
                        }
                    }
                    if (left.type === 'Identifier') {
                        // This identifier existing means it was a pre-declared and
                        // we just got to it.
                        const existingVariable = this.declaredVariables.get(left.name);
                        if (!existingVariable)
                            continue;
                        existingVariable.node.init = right;
                        if (this.needsDependencyAnalysis(right)) {
                            existingVariable.dependencies = this.findDependencies(assignment.right, left.name);
                        }
                        if (this.onMatch(existingVariable.node, existingVariable))
                            return;
                    }
                    else if (assignment.left.type === 'MemberExpression') {
                        const memberName = memberToString(assignment.left, this.source);
                        const activeAliasExpr = this.pendingPrototypeAliasBinding?.[0];
                        // While an alias binding is active, collect member assignments made through the alias (`g.q.foo = ...`).
                        if (activeAliasExpr && (memberName?.includes(activeAliasExpr) || memberName === activeAliasExpr.slice(0, -1))) {
                            const aliasOwnerMeta = this.declaredVariables.get(this.pendingPrototypeAliasBinding?.[1].name || '');
                            if (aliasOwnerMeta) {
                                const existingAliasedMembers = aliasOwnerMeta.prototypeAliases.get(activeAliasExpr);
                                const aliasedMemberMeta = {
                                    name: memberName,
                                    node: currentNode,
                                    dependents: this.dependentsTracker.get(memberName) || new Set(),
                                    predeclared: false,
                                    prototypeAliases: new Map(),
                                    dependencies: this.findDependencies(right, memberName)
                                };
                                if (existingAliasedMembers) {
                                    existingAliasedMembers.add(aliasedMemberMeta);
                                }
                                else {
                                    aliasOwnerMeta.prototypeAliases.set(activeAliasExpr, new Set([aliasedMemberMeta]));
                                }
                            }
                        }
                        else {
                            this.pendingPrototypeAliasBinding = null;
                        }
                        if (!memberName || this.declaredVariables.has(memberName))
                            continue;
                        const metadata = {
                            name: memberName,
                            node: currentNode,
                            dependents: this.dependentsTracker.get(memberName) || new Set(),
                            predeclared: false,
                            prototypeAliases: new Map(),
                            dependencies: this.findDependencies(right, memberName)
                        };
                        const baseName = memberBaseName(assignment.left, this.source);
                        if (baseName && baseName !== memberName && !baseName.startsWith('this.')) {
                            metadata.dependencies.add(baseName.replace('.prototype', ''));
                        }
                        if (this.dependentsTracker.has(memberName)) {
                            this.dependentsTracker.delete(memberName);
                        }
                        this.declaredVariables.set(memberName, metadata);
                        if (this.onMatch(currentNode, metadata))
                            return;
                    }
                    break;
                }
                case 'VariableDeclaration': {
                    this.pendingPrototypeAliasBinding = null;
                    for (const declaration of currentNode.declarations) {
                        if (declaration.id.type !== 'Identifier')
                            continue;
                        const metadata = {
                            name: declaration.id.name,
                            node: declaration,
                            dependents: this.dependentsTracker.get(declaration.id.name) || new Set(),
                            prototypeAliases: new Map(),
                            dependencies: new Set(),
                            predeclared: false
                        };
                        const init = declaration.init;
                        if (!init && currentNode.kind === 'var') {
                            metadata.predeclared = true; // "var x, y, z;"
                        }
                        else if (init && this.needsDependencyAnalysis(init)) {
                            metadata.dependencies = this.findDependencies(init, metadata.name);
                        }
                        if (this.dependentsTracker.has(metadata.name)) {
                            this.dependentsTracker.delete(metadata.name);
                        }
                        this.declaredVariables.set(metadata.name, metadata);
                        if (this.onMatch(declaration, metadata))
                            return;
                    }
                    break;
                }
            }
        }
    }
    /**
     * Quick check if node type requires dependency analysis
     */
    needsDependencyAnalysis(node) {
        if (!node)
            return false;
        switch (node.type) {
            case 'FunctionExpression':
            case 'ArrowFunctionExpression':
            case 'ArrayExpression':
            case 'LogicalExpression':
            case 'CallExpression':
            case 'NewExpression':
            case 'MemberExpression':
            case 'BinaryExpression':
            case 'ConditionalExpression':
            case 'ObjectExpression':
            case 'SequenceExpression':
            case 'ClassExpression':
            case 'Identifier':
                return true;
            default:
                return false;
        }
    }
    /**
     * Records a match, attaches metadata, and updates readiness state.
     * @returns True when traversal can stop as a result of the match.
     */
    onMatch(node, metadata) {
        if (!this.hasExtractions)
            return false;
        let matched = false;
        let result = false;
        for (const state of this.extractionStates) {
            if (!state.node) {
                if (node.type === 'VariableDeclarator' && !node.init)
                    continue;
                result = state.config.match(node);
                if (!result)
                    continue;
                state.node = node;
                matched = true;
                if (metadata) {
                    state.metadata = metadata;
                    state.dependents = metadata.dependents;
                    state.dependencies = metadata.dependencies;
                    if (typeof result !== 'boolean')
                        state.matchContext = result;
                }
                this.refreshExtractionState(state);
            }
            else if (state.node !== node) {
                // Use this as a chance to refresh readiness in case dependencies were resolved since last time
                // we checked.
                this.refreshExtractionState(state);
                if (this.shouldStopTraversal()) {
                    return true;
                }
            }
        }
        if (!matched)
            return false;
        return this.shouldStopTraversal();
    }
    /**
     * Refreshes the readiness state of an extraction target based on its dependencies
     * and/or configuration.
     * @param state - State to refresh.
     */
    refreshExtractionState(state) {
        if (!state.node) {
            state.ready = false;
            return;
        }
        if (state.config.collectDependencies === false) {
            state.ready = true;
            return;
        }
        if (!state.metadata) {
            state.ready = false;
            return;
        }
        state.ready = this.areDependenciesResolved(state.dependencies);
    }
    /**
     * Determines whether traversal should stop based on extraction states and configuration.
     */
    shouldStopTraversal() {
        if (!this.hasExtractions)
            return false;
        let hasStoppingTarget = false;
        for (const state of this.extractionStates) {
            if (state.config.stopWhenReady === false)
                continue;
            hasStoppingTarget = true;
            if (!state.node)
                return false;
            if (!state.ready)
                return false;
        }
        return hasStoppingTarget;
    }
    /**
     * Checks if every dependency resolves to a declaration or built-in symbol.
     * @param dependencies - Dependencies to validate.
     * @param seen - Tracks recursively visited identifiers.
     */
    areDependenciesResolved(dependencies, seen = new Set()) {
        if (!dependencies || dependencies.size === 0)
            return true;
        for (const dependency of dependencies) {
            if (!dependency)
                continue;
            if (jsBuiltIns.has(dependency))
                continue;
            if (dependency === this.iifeParamName)
                continue;
            if (seen.has(dependency))
                continue;
            const depMeta = this.declaredVariables.get(dependency);
            if (!depMeta)
                return false;
            seen.add(dependency);
            if (!this.areDependenciesResolved(depMeta.dependencies, seen)) {
                return false;
            }
        }
        return true;
    }
    /**
     * Collects free identifier dependencies reachable from the provided AST node.
     * @param rootNode - AST node to search for dependencies.
     * @param identifierName - Name of the identifier represented by `rootNode`, used for tracking dependents.
     */
    findDependencies(rootNode, identifierName) {
        const dependencies = new Set();
        if (!rootNode)
            return dependencies;
        const scopeStack = [
            {
                names: new Set(),
                type: 'block'
            }
        ];
        const currentScope = () => scopeStack[scopeStack.length - 1];
        const isInScope = (name) => {
            for (let i = scopeStack.length - 1; i >= 0; i--) {
                if (scopeStack[i].names.has(name))
                    return true;
            }
            return false;
        };
        const rootIdentifierName = 'id' in rootNode && rootNode?.id?.type === 'Identifier' ? rootNode.id.name : undefined;
        const collectBindingIdentifiers = (pattern, target) => {
            if (!pattern)
                return;
            switch (pattern.type) {
                case 'Identifier':
                    target.add(pattern.name);
                    break;
                case 'ObjectPattern':
                    for (const prop of pattern.properties) {
                        if (prop.type === 'RestElement') {
                            collectBindingIdentifiers(prop.argument, target);
                        }
                        else if (prop.type === 'Property') {
                            collectBindingIdentifiers(prop.value, target);
                        }
                    }
                    break;
                case 'ArrayPattern':
                    for (const el of pattern.elements) {
                        if (el)
                            collectBindingIdentifiers(el, target);
                    }
                    break;
                case 'RestElement':
                    collectBindingIdentifiers(pattern.argument, target);
                    break;
                case 'AssignmentPattern':
                    collectBindingIdentifiers(pattern.left, target);
                    break;
            }
        };
        const collectParams = (fnNode, target) => {
            if (!fnNode?.params)
                return;
            for (const p of fnNode.params)
                collectBindingIdentifiers(p, target);
        };
        walkAst(rootNode, {
            enter: (n, parent) => {
                switch (n.type) {
                    // Note for anybody debugging this in the future:
                    // *DO NOT* add MethodDefinition here.
                    // MethodDefinition.value is a FunctionExpression, so it is already handled...
                    case 'FunctionDeclaration':
                    case 'FunctionExpression':
                    case 'ArrowFunctionExpression': {
                        const isDecl = n.type === 'FunctionDeclaration';
                        const fnName = 'id' in n ? n.id?.name : undefined;
                        // Add func name to scope if it's a declaration, this way it won't be considered a dep itself.
                        if (isDecl && fnName) {
                            currentScope().names.add(fnName);
                        }
                        // Enter function scope.
                        const fnScope = { names: new Set(), type: 'function' };
                        if (n.type === 'FunctionExpression' && fnName) {
                            fnScope.names.add(fnName);
                        }
                        collectParams(n, fnScope.names);
                        scopeStack.push(fnScope);
                        break;
                    }
                    case 'BlockStatement': {
                        scopeStack.push({ names: new Set(), type: 'block' });
                        break;
                    }
                    case 'CatchClause': {
                        const s = new Set();
                        if (n.param)
                            collectBindingIdentifiers(n.param, s);
                        scopeStack.push({ names: s, type: 'block' });
                        break;
                    }
                    case 'VariableDeclaration': {
                        // var hoists to function scope...
                        const targetScope = n.kind === 'var'
                            ? scopeStack.findLast((s) => s.type === 'function') ?? currentScope()
                            : currentScope();
                        for (const d of n.declarations) {
                            collectBindingIdentifiers(d.id, targetScope.names);
                        }
                        break;
                    }
                    case 'ClassDeclaration': {
                        if (n.id?.name) {
                            currentScope().names.add(n.id.name);
                        }
                        break;
                    }
                    case 'LabeledStatement': { // YouTube uses these things for some reason.
                        if (n.label?.type === 'Identifier')
                            currentScope().names.add(n.label.name);
                        break;
                    }
                    case 'Identifier': {
                        if (n.name === rootIdentifierName)
                            return;
                        // Ignore if it's a property name (e.g., "obj.prop" or "{prop: 1}"", we don't care about the "prop" name itself).
                        if (parent?.type === 'Property' && parent.key === n && !parent.computed)
                            return;
                        // Ignore class method names. They are declarations, not external dependencies.
                        if (parent?.type === 'MethodDefinition' && parent.key === n && !parent.computed)
                            return;
                        if (parent?.type === 'MemberExpression' && parent.property === n && !parent.computed) {
                            if (parent.object.type === 'ThisExpression')
                                return; // Skip 'this.property', etc.
                            const full = memberToString(parent, this.source);
                            if (!full)
                                return;
                            const declaredVariable = this.declaredVariables.get(full);
                            if (declaredVariable) {
                                declaredVariable.dependents.add(identifierName);
                                dependencies.add(full);
                            }
                            else if (parent.object.type === 'Identifier') {
                                const baseName = parent.object.name;
                                const declaredBaseVariable = this.declaredVariables.get(baseName);
                                if ((declaredBaseVariable || baseName === this.iifeParamName) &&
                                    !isInScope(baseName) && !jsBuiltIns.has(baseName)) {
                                    declaredBaseVariable?.dependents.add(identifierName);
                                    dependencies.add(full);
                                    // We have this object, but not the full member chain.
                                    const existingTracker = this.dependentsTracker.get(full);
                                    if (existingTracker) {
                                        existingTracker.add(identifierName);
                                    }
                                    else {
                                        this.dependentsTracker.set(full, new Set([identifierName]));
                                    }
                                }
                            }
                            return;
                        }
                        if (parent?.type === 'MetaProperty') {
                            return; // Skip stuff like "new.target" or "import.meta"
                        }
                        if (isInScope(n.name) || jsBuiltIns.has(n.name))
                            return;
                        // It's a free variable, so it's a dependency.
                        dependencies.add(n.name);
                        const declaredVariable = this.declaredVariables.get(n.name);
                        if (declaredVariable) {
                            declaredVariable.dependents.add(identifierName);
                        }
                        else {
                            // Not declared yet, track for future resolution.
                            const existing = this.dependentsTracker.get(n.name);
                            if (existing) {
                                existing.add(identifierName);
                            }
                            else {
                                this.dependentsTracker.set(n.name, new Set([identifierName]));
                            }
                        }
                        break;
                    }
                    case 'ForStatement':
                    case 'ForInStatement':
                    case 'ForOfStatement': {
                        scopeStack.push({ names: new Set(), type: 'block' });
                        break;
                    }
                }
            },
            leave: (n) => {
                switch (n.type) {
                    case 'FunctionDeclaration':
                    case 'FunctionExpression':
                    case 'ArrowFunctionExpression':
                    case 'BlockStatement':
                    case 'CatchClause':
                    case 'ForStatement':
                    case 'ForInStatement':
                    case 'ForOfStatement':
                        if (scopeStack.length > 1)
                            scopeStack.pop();
                        break;
                }
            }
        });
        return dependencies;
    }
    /**
     * Returns the current set of matched extractions.
     */
    getExtractedMatches() {
        return this.extractionStates
            .filter((state) => !!state.node);
    }
    /**
     * Returns the raw, original source.
     */
    getSource() {
        return this.source;
    }
}
//# sourceMappingURL=JsAnalyzer.js.map