package click.kamil.springstatemachineexporter.analysis.flow.alloc;

import click.kamil.springstatemachineexporter.analysis.flow.CallSiteArgBinder;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Assignment;
import org.eclipse.jdt.core.dom.CastExpression;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldAccess;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.IBinding;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.ThisExpression;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;

import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

/**
 * Path-scoped allocation fact propagator: CIC → locals → callee parameters via call-edge args,
 * factory {@code return new T()}, and trivial getter→field→ctor-arg peels.
 * Optionally treats a unique Spring {@code @Bean}/component as an alloc substitute when
 * {@link InjectionPointAnalyzer} is wired (multi-bean / qualifier conflict → empty).
 * Fail-closed when facts are empty or ambiguous (multi-site).
 */
public final class AllocationFactPropagator {

    private static final int MAX_DEPTH = 8;

    private final CodebaseContext context;
    private final VariableTracer variableTracer;
    private final ConstructorAnalyzer constructorAnalyzer;
    private InjectionPointAnalyzer injectionAnalyzer;

    public AllocationFactPropagator(CodebaseContext context, VariableTracer variableTracer) {
        this(context, variableTracer, null);
    }

    public AllocationFactPropagator(
            CodebaseContext context,
            VariableTracer variableTracer,
            ConstructorAnalyzer constructorAnalyzer) {
        this.context = context;
        this.variableTracer = variableTracer;
        this.constructorAnalyzer = constructorAnalyzer;
    }

    public void setInjectionAnalyzer(InjectionPointAnalyzer injectionAnalyzer) {
        this.injectionAnalyzer = injectionAnalyzer;
    }

    /**
     * Allocation facts for {@code expression} within {@code enclosingMethod}, optionally
     * refined by walking {@code methodPath} call edges backward for parameter receivers.
     * Dominating {@code instanceof} / pattern narrowing is merged via {@link InstanceofNarrower}.
     */
    public ReceiverFacts factsAt(
            Expression expression,
            MethodDeclaration enclosingMethod,
            List<String> methodPath,
            Map<String, List<CallEdge>> callGraph) {
        ReceiverFacts base = factsAtRaw(expression, enclosingMethod, methodPath, callGraph, 0, new LinkedHashSet<>());
        return InstanceofNarrower.refine(base, expression, expression, context);
    }

    private ReceiverFacts factsAtRaw(
            Expression expression,
            MethodDeclaration enclosingMethod,
            List<String> methodPath,
            Map<String, List<CallEdge>> callGraph,
            int depth,
            Set<String> visited) {
        if (expression == null || depth > MAX_DEPTH) {
            return ReceiverFacts.empty();
        }
        Expression peeled = peel(expression);
        LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();

        if (peeled instanceof ClassInstanceCreation cic) {
            AllocationSite site = siteFromCic(cic);
            if (site != null) {
                sites.add(site);
            }
            return ReceiverFacts.ofAll(sites);
        }

        if (peeled instanceof MethodInvocation mi) {
            ReceiverFacts fromGetter = factsFromGetterPeel(
                    mi, enclosingMethod, methodPath, callGraph, depth, visited);
            if (!fromGetter.isEmpty()) {
                return fromGetter;
            }
            ReceiverFacts fromFactory = factsFromFactoryReturn(mi, depth, visited);
            if (!fromFactory.isEmpty()) {
                return fromFactory;
            }
        }

        if (variableTracer != null) {
            for (Expression def : variableTracer.traceVariableAll(peeled)) {
                Expression defPeeled = peel(def);
                if (defPeeled == null || defPeeled == peeled) {
                    continue;
                }
                if (defPeeled instanceof ClassInstanceCreation cic) {
                    AllocationSite site = siteFromCic(cic);
                    if (site != null) {
                        sites.add(site);
                    }
                } else {
                    // Follow aliases / casts / factories: RichEvent carrier = e; e = (RichEvent) o;
                    String defKey = "alias:" + System.identityHashCode(defPeeled);
                    if (visited.add(defKey)) {
                        ReceiverFacts nested = factsAtRaw(
                                defPeeled, enclosingMethod, methodPath, callGraph, depth + 1, visited);
                        sites.addAll(nested.sites());
                    }
                }
            }
        }

        if (!sites.isEmpty()) {
            return ReceiverFacts.ofAll(sites);
        }

        // Parameter: walk call path to find caller argument CIC.
        if (peeled instanceof SimpleName simpleName
                && enclosingMethod != null
                && methodPath != null
                && callGraph != null) {
            int paramIndex = parameterIndex(enclosingMethod, simpleName.getIdentifier());
            if (paramIndex >= 0) {
                String calleeFqn = methodFqn(enclosingMethod);
                if (calleeFqn != null && visited.add(calleeFqn + "#" + paramIndex)) {
                    ReceiverFacts fromCaller = factsFromCallerArgument(
                            calleeFqn, paramIndex, methodPath, callGraph, depth + 1, visited);
                    if (!fromCaller.isEmpty()) {
                        return fromCaller;
                    }
                }
            }
        }

        // Instance field: unique path-scoped write this.f = provenParam/CIC (or setF(...)).
        ReceiverFacts fromField = factsFromInstanceFieldStore(
                peeled, enclosingMethod, methodPath, callGraph, depth, visited);
        if (!fromField.isEmpty()) {
            return fromField;
        }

        // Unique Spring bean injected into an interface/abstract field (when analyzer is wired).
        ReceiverFacts fromBean = factsFromUniqueInjectedBean(peeled);
        if (!fromBean.isEmpty()) {
            return fromBean;
        }

        return ReceiverFacts.empty();
    }

    private ReceiverFacts factsFromUniqueInjectedBean(Expression expression) {
        if (injectionAnalyzer == null || expression == null) {
            return ReceiverFacts.empty();
        }
        IVariableBinding varBinding = null;
        Expression peeled = peel(expression);
        if (peeled instanceof SimpleName sn) {
            IBinding binding = sn.resolveBinding();
            if (binding instanceof IVariableBinding vb) {
                varBinding = vb;
            }
        } else if (peeled instanceof FieldAccess fa) {
            varBinding = fa.resolveFieldBinding();
        }
        if (varBinding == null) {
            return ReceiverFacts.empty();
        }
        String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
        if (concreteFqn == null || concreteFqn.isBlank()) {
            return ReceiverFacts.empty();
        }
        TypeDeclaration td = context.getTypeDeclaration(concreteFqn);
        if (td == null) {
            String simple = concreteFqn.contains(".")
                    ? concreteFqn.substring(concreteFqn.lastIndexOf('.') + 1)
                    : concreteFqn;
            td = context.getTypeDeclaration(simple);
            if (td != null) {
                concreteFqn = context.getFqn(td);
            }
        }
        if (td == null || td.isInterface() || Modifier.isAbstract(td.getModifiers())) {
            return ReceiverFacts.empty();
        }
        return ReceiverFacts.of(new AllocationSite(concreteFqn));
    }

    /**
     * Facts for the receiver of a virtual accessor call ({@code e.getType()}).
     * Uses the accessor invocation as instanceof use-site so then-branch narrowing applies.
     */
    public ReceiverFacts factsForAccessorReceiver(
            MethodInvocation accessorCall,
            List<String> methodPath,
            Map<String, List<CallEdge>> callGraph) {
        if (accessorCall == null) {
            return ReceiverFacts.empty();
        }
        Expression receiver = accessorCall.getExpression();
        if (receiver == null) {
            return ReceiverFacts.empty();
        }
        MethodDeclaration enclosing = findEnclosingMethod(accessorCall);
        ReceiverFacts base = factsAtRaw(
                receiver, enclosing, methodPath, callGraph, 0, new LinkedHashSet<>());
        return InstanceofNarrower.refine(base, receiver, accessorCall, context);
    }

    private ReceiverFacts factsFromFactoryReturn(MethodInvocation mi, int depth, Set<String> visited) {
        if (constructorAnalyzer == null || mi == null || depth > MAX_DEPTH) {
            return ReceiverFacts.empty();
        }
        String owner = resolveInvocationOwner(mi);
        if (owner == null || owner.isBlank()) {
            return ReceiverFacts.empty();
        }
        String key = "factory:" + owner + "#" + mi.getName().getIdentifier();
        if (!visited.add(key)) {
            return ReceiverFacts.empty();
        }
        try {
            ClassInstanceCreation cic = constructorAnalyzer.findReturnedCIC(
                    owner, mi.getName().getIdentifier(), context);
            if (cic == null) {
                return ReceiverFacts.empty();
            }
            AllocationSite site = siteFromCic(cic);
            return site == null ? ReceiverFacts.empty() : ReceiverFacts.of(site);
        } finally {
            visited.remove(key);
        }
    }

    private ReceiverFacts factsFromGetterPeel(
            MethodInvocation mi,
            MethodDeclaration enclosingMethod,
            List<String> methodPath,
            Map<String, List<CallEdge>> callGraph,
            int depth,
            Set<String> visited) {
        if (mi.getExpression() == null) {
            return ReceiverFacts.empty();
        }
        String peelKey = "getter:" + mi.getName().getIdentifier() + "@" + System.identityHashCode(mi);
        if (!visited.add(peelKey)) {
            return ReceiverFacts.empty();
        }
        try {
            ReceiverFacts receiverFacts = factsAtRaw(
                    mi.getExpression(), enclosingMethod, methodPath, callGraph, depth + 1, visited);
            AllocationSite unique = receiverFacts.uniqueOrNull();
            if (unique != null && unique.cic() != null) {
                Expression ctorArg = resolveGetterToCtorArg(unique, mi.getName().getIdentifier());
                if (ctorArg != null) {
                    return factsAtRaw(ctorArg, enclosingMethod, methodPath, callGraph, depth + 1, visited);
                }
            }
            // Trivial setter peel: w.setEvent(new T()); … w.getEvent()
            String fieldName = javaBeansFieldName(mi.getName().getIdentifier());
            ReceiverFacts fromSetter = factsFromUniqueSetterWrite(
                    mi.getExpression(),
                    fieldName,
                    mi.getName().getIdentifier(),
                    enclosingMethod,
                    methodPath,
                    callGraph,
                    depth,
                    visited);
            if (!fromSetter.isEmpty()) {
                return fromSetter;
            }
            return ReceiverFacts.empty();
        } finally {
            visited.remove(peelKey);
        }
    }

    /**
     * Path-scoped instance field store: {@code this.carrier = e} or {@code setCarrier(e)} with a
     * unique proven RHS on methods appearing on {@code methodPath}.
     */
    private ReceiverFacts factsFromInstanceFieldStore(
            Expression peeled,
            MethodDeclaration enclosingMethod,
            List<String> methodPath,
            Map<String, List<CallEdge>> callGraph,
            int depth,
            Set<String> visited) {
        if (enclosingMethod == null || methodPath == null || depth > MAX_DEPTH) {
            return ReceiverFacts.empty();
        }
        String fieldName = instanceFieldName(peeled);
        if (fieldName == null) {
            return ReceiverFacts.empty();
        }
        TypeDeclaration owner = findEnclosingType(enclosingMethod);
        if (owner == null || !ownerDeclaresField(owner, fieldName)) {
            return ReceiverFacts.empty();
        }
        String visitKey = "fieldStore:" + context.getFqn(owner) + "#" + fieldName;
        if (!visited.add(visitKey)) {
            return ReceiverFacts.empty();
        }
        try {
            LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
            int writeCount = 0;
            for (String hop : methodPath) {
                if (hop == null || !hopContainsType(hop, owner)) {
                    continue;
                }
                MethodDeclaration md = findMethodDeclaration(hop);
                if (md == null || md.getBody() == null) {
                    continue;
                }
                List<Expression> rhsList = collectFieldWrites(md, fieldName);
                for (Expression rhs : rhsList) {
                    writeCount++;
                    ReceiverFacts nested = factsAtRaw(rhs, md, methodPath, callGraph, depth + 1, visited);
                    sites.addAll(nested.sites());
                }
            }
            if (writeCount == 0) {
                // Unique ctor / @PostConstruct write on the owner type (path-independent init).
                return factsFromOwnerFieldInit(owner, fieldName, methodPath, callGraph, depth, visited);
            }
            if (sites.isEmpty()) {
                return ReceiverFacts.empty();
            }
            // Ambiguous: multiple writes that disagree → fail closed via ofAll size; also reject
            // multiple writes even if they agree on type (safer path-scoped uniqueness).
            if (writeCount != 1) {
                return ReceiverFacts.empty();
            }
            return ReceiverFacts.ofAll(sites);
        } finally {
            visited.remove(visitKey);
        }
    }

    /**
     * Exactly one field write across constructors and {@code @PostConstruct} methods on {@code owner}.
     */
    private ReceiverFacts factsFromOwnerFieldInit(
            TypeDeclaration owner,
            String fieldName,
            List<String> methodPath,
            Map<String, List<CallEdge>> callGraph,
            int depth,
            Set<String> visited) {
        if (owner == null || fieldName == null) {
            return ReceiverFacts.empty();
        }
        LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
        int writeCount = 0;
        for (FieldDeclaration fd : owner.getFields()) {
            for (Object fragObj : fd.fragments()) {
                if (fragObj instanceof VariableDeclarationFragment frag
                        && fieldName.equals(frag.getName().getIdentifier())
                        && frag.getInitializer() != null) {
                    writeCount++;
                    ReceiverFacts nested = factsAtRaw(
                            frag.getInitializer(), null, methodPath, callGraph, depth + 1, visited);
                    sites.addAll(nested.sites());
                }
            }
        }
        for (MethodDeclaration md : owner.getMethods()) {
            if (md.getBody() == null) {
                continue;
            }
            if (!md.isConstructor() && !hasPostConstructAnnotation(md)) {
                continue;
            }
            List<Expression> rhsList = collectFieldWrites(md, fieldName);
            for (Expression rhs : rhsList) {
                writeCount++;
                ReceiverFacts nested = factsAtRaw(rhs, md, methodPath, callGraph, depth + 1, visited);
                sites.addAll(nested.sites());
            }
        }
        if (writeCount != 1 || sites.isEmpty()) {
            return ReceiverFacts.empty();
        }
        return ReceiverFacts.ofAll(sites);
    }

    private static boolean hasPostConstructAnnotation(MethodDeclaration md) {
        if (md == null) {
            return false;
        }
        for (Object modObj : md.modifiers()) {
            if (modObj instanceof org.eclipse.jdt.core.dom.Annotation ann) {
                String name = ann.getTypeName().getFullyQualifiedName();
                if ("PostConstruct".equals(name)
                        || name.endsWith(".PostConstruct")
                        || "javax.annotation.PostConstruct".equals(name)
                        || "jakarta.annotation.PostConstruct".equals(name)) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Unique {@code receiver.setX(arg)} / {@code setX(arg)} on path whose arg has allocation facts.
     */
    private ReceiverFacts factsFromUniqueSetterWrite(
            Expression receiver,
            String fieldName,
            String getterName,
            MethodDeclaration enclosingMethod,
            List<String> methodPath,
            Map<String, List<CallEdge>> callGraph,
            int depth,
            Set<String> visited) {
        if (receiver == null || methodPath == null || fieldName == null || fieldName.isBlank()) {
            return ReceiverFacts.empty();
        }
        String setterName = "set" + Character.toUpperCase(fieldName.charAt(0))
                + (fieldName.length() > 1 ? fieldName.substring(1) : "");
        String receiverText = CallSiteArgBinder.normalizeExprText(receiver);
        LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
        int matchCount = 0;
        Set<Integer> seenBodies = new LinkedHashSet<>();
        for (String hop : methodPath) {
            MethodDeclaration md = findMethodDeclaration(hop);
            if (md == null || md.getBody() == null) {
                continue;
            }
            int bodyId = System.identityHashCode(md);
            if (!seenBodies.add(bodyId)) {
                continue;
            }
            List<Expression> setterArgs = collectSetterArgs(md, setterName, receiverText);
            for (Expression arg : setterArgs) {
                matchCount++;
                ReceiverFacts nested = factsAtRaw(arg, md, methodPath, callGraph, depth + 1, visited);
                sites.addAll(nested.sites());
            }
        }
        // Same-method setter before getter (path may not re-list enclosing).
        if (enclosingMethod != null && enclosingMethod.getBody() != null
                && seenBodies.add(System.identityHashCode(enclosingMethod))) {
            List<Expression> local = collectSetterArgs(enclosingMethod, setterName, receiverText);
            for (Expression arg : local) {
                matchCount++;
                ReceiverFacts nested = factsAtRaw(
                        arg, enclosingMethod, methodPath, callGraph, depth + 1, visited);
                sites.addAll(nested.sites());
            }
        }
        if (matchCount != 1 || sites.isEmpty()) {
            return ReceiverFacts.empty();
        }
        return ReceiverFacts.ofAll(sites);
    }

    private static String instanceFieldName(Expression peeled) {
        if (peeled instanceof FieldAccess fa
                && (fa.getExpression() instanceof ThisExpression || fa.getExpression() == null)) {
            return fa.getName().getIdentifier();
        }
        if (peeled instanceof SimpleName sn) {
            return sn.getIdentifier();
        }
        return null;
    }

    private static boolean ownerDeclaresField(TypeDeclaration owner, String fieldName) {
        if (owner == null || fieldName == null) {
            return false;
        }
        for (FieldDeclaration fd : owner.getFields()) {
            for (Object fragObj : fd.fragments()) {
                if (fragObj instanceof VariableDeclarationFragment frag
                        && fieldName.equals(frag.getName().getIdentifier())) {
                    return true;
                }
            }
        }
        return false;
    }

    private boolean hopContainsType(String methodFqn, TypeDeclaration owner) {
        if (methodFqn == null || owner == null) {
            return false;
        }
        String typeFqn = context.getFqn(owner);
        if (typeFqn != null && methodFqn.startsWith(typeFqn + ".")) {
            return true;
        }
        String simple = owner.getName().getIdentifier();
        return methodFqn.contains("." + simple + ".") || methodFqn.startsWith(simple + ".");
    }

    private static List<Expression> collectFieldWrites(MethodDeclaration method, String fieldName) {
        List<Expression> writes = new java.util.ArrayList<>();
        method.getBody().accept(new ASTVisitor() {
            @Override
            public boolean visit(Assignment node) {
                Expression lhs = node.getLeftHandSide();
                if (lhs instanceof SimpleName sn && fieldName.equals(sn.getIdentifier())) {
                    writes.add(node.getRightHandSide());
                } else if (lhs instanceof FieldAccess fa
                        && fieldName.equals(fa.getName().getIdentifier())
                        && (fa.getExpression() instanceof ThisExpression || fa.getExpression() == null)) {
                    writes.add(node.getRightHandSide());
                }
                return super.visit(node);
            }

            @Override
            public boolean visit(MethodInvocation node) {
                String name = node.getName().getIdentifier();
                if (name != null && name.startsWith("set") && name.length() > 3
                        && javaBeansFieldName(name).equals(fieldName)
                        && node.arguments().size() == 1
                        && (node.getExpression() == null || node.getExpression() instanceof ThisExpression)) {
                    writes.add((Expression) node.arguments().get(0));
                }
                return super.visit(node);
            }
        });
        return writes;
    }

    private static List<Expression> collectSetterArgs(
            MethodDeclaration method, String setterName, String receiverText) {
        List<Expression> args = new java.util.ArrayList<>();
        method.getBody().accept(new ASTVisitor() {
            @Override
            public boolean visit(MethodInvocation node) {
                if (!setterName.equals(node.getName().getIdentifier()) || node.arguments().size() != 1) {
                    return super.visit(node);
                }
                Expression recv = node.getExpression();
                if (recv == null) {
                    return super.visit(node);
                }
                if (receiverText != null && receiverText.equals(CallSiteArgBinder.normalizeExprText(recv))) {
                    args.add((Expression) node.arguments().get(0));
                }
                return super.visit(node);
            }
        });
        return args;
    }

    private Expression resolveGetterToCtorArg(AllocationSite site, String getterName) {
        ClassInstanceCreation cic = site.cic();
        if (cic == null || getterName == null) {
            return null;
        }
        TypeDeclaration td = resolveTypeDeclaration(site.typeFqn());
        if (td == null) {
            return null;
        }
        String fieldName = fieldNameForGetter(site.typeFqn(), getterName);
        if (fieldName == null || fieldName.isBlank()) {
            return null;
        }
        int idx = findCtorArgIndexForField(td, cic, fieldName);
        if (idx < 0 || idx >= cic.arguments().size()) {
            return null;
        }
        return (Expression) cic.arguments().get(idx);
    }

    private String fieldNameForGetter(String ownerFqn, String getterName) {
        if (context.getAccessorIndex() != null) {
            Optional<AccessorSummary> indexed = context.getAccessorIndex().lookup(ownerFqn, getterName);
            if (indexed.isPresent() && indexed.get().fieldName() != null) {
                return indexed.get().fieldName();
            }
            String simple = simplifyTypeName(ownerFqn);
            if (!simple.equals(ownerFqn)) {
                indexed = context.getAccessorIndex().lookup(simple, getterName);
                if (indexed.isPresent() && indexed.get().fieldName() != null) {
                    return indexed.get().fieldName();
                }
            }
        }
        return javaBeansFieldName(getterName);
    }

    private static String javaBeansFieldName(String getterName) {
        if (getterName == null) {
            return null;
        }
        if (getterName.startsWith("get") && getterName.length() > 3) {
            return Character.toLowerCase(getterName.charAt(3)) + getterName.substring(4);
        }
        if (getterName.startsWith("is") && getterName.length() > 2) {
            return Character.toLowerCase(getterName.charAt(2)) + getterName.substring(3);
        }
        return getterName;
    }

    private int findCtorArgIndexForField(TypeDeclaration td, ClassInstanceCreation cic, String fieldName) {
        IMethodBinding resolvedCtor = cic.resolveConstructorBinding();
        for (MethodDeclaration ctor : td.getMethods()) {
            if (!ctor.isConstructor() || ctor.getBody() == null) {
                continue;
            }
            boolean matches = false;
            if (resolvedCtor != null && ctor.resolveBinding() != null) {
                matches = resolvedCtor.isEqualTo(ctor.resolveBinding())
                        || resolvedCtor.getKey().equals(ctor.resolveBinding().getKey());
            }
            if (!matches) {
                matches = ctor.parameters().size() == cic.arguments().size();
            }
            if (!matches) {
                continue;
            }
            int fromBody = ctorParamIndexAssignedToField(ctor, fieldName);
            if (fromBody >= 0) {
                return fromBody;
            }
            // Same-name param fallback: EventWrapper(RichEvent event) { this.event = event; }
            for (int i = 0; i < ctor.parameters().size(); i++) {
                SingleVariableDeclaration svd = (SingleVariableDeclaration) ctor.parameters().get(i);
                if (fieldName.equals(svd.getName().getIdentifier())) {
                    return i;
                }
            }
        }
        if (constructorAnalyzer != null) {
            int lombokIdx = constructorAnalyzer.findConstructorArgumentIndexForLombokField(td, fieldName);
            if (lombokIdx >= 0) {
                return lombokIdx;
            }
        }
        // Single-arg ctor → assume it initializes the sole relevant field.
        if (cic.arguments().size() == 1 && td.getMethods() != null) {
            for (MethodDeclaration ctor : td.getMethods()) {
                if (ctor.isConstructor() && ctor.parameters().size() == 1) {
                    return 0;
                }
            }
        }
        return -1;
    }

    private static int ctorParamIndexAssignedToField(MethodDeclaration ctor, String fieldName) {
        int[] found = {-1};
        List<?> params = ctor.parameters();
        ctor.getBody().accept(new ASTVisitor() {
            @Override
            public boolean visit(Assignment node) {
                if (!(node.getRightHandSide() instanceof SimpleName rhs)) {
                    return super.visit(node);
                }
                String paramName = rhs.getIdentifier();
                int paramIdx = -1;
                for (int i = 0; i < params.size(); i++) {
                    if (params.get(i) instanceof SingleVariableDeclaration svd
                            && paramName.equals(svd.getName().getIdentifier())) {
                        paramIdx = i;
                        break;
                    }
                }
                if (paramIdx < 0) {
                    return super.visit(node);
                }
                Expression lhs = node.getLeftHandSide();
                if (lhs instanceof SimpleName sn && fieldName.equals(sn.getIdentifier())) {
                    found[0] = paramIdx;
                } else if (lhs instanceof FieldAccess fa
                        && fa.getExpression() instanceof ThisExpression
                        && fieldName.equals(fa.getName().getIdentifier())) {
                    found[0] = paramIdx;
                }
                return super.visit(node);
            }
        });
        return found[0];
    }

    private String resolveInvocationOwner(MethodInvocation mi) {
        Expression expr = mi.getExpression();
        if (expr == null) {
            MethodDeclaration enclosing = findEnclosingMethod(mi);
            TypeDeclaration td = enclosing != null ? findEnclosingType(enclosing) : null;
            return td != null ? context.getFqn(td) : null;
        }
        ITypeBinding binding = expr.resolveTypeBinding();
        if (binding != null) {
            ITypeBinding erasure = binding.getErasure();
            if (erasure != null && erasure.getQualifiedName() != null && !erasure.getQualifiedName().isBlank()) {
                // Static type name as qualifier often binds to the type itself.
                return erasure.getQualifiedName();
            }
        }
        if (expr instanceof SimpleName sn) {
            TypeDeclaration td = context.getTypeDeclaration(sn.getIdentifier());
            if (td != null) {
                String fqn = context.getFqn(td);
                if (fqn != null) {
                    return fqn;
                }
            }
            CompilationUnitAwareFqn local = resolveSimpleInEnclosingCu(mi, sn.getIdentifier());
            if (local != null) {
                return local.fqn();
            }
            return sn.getIdentifier();
        }
        if (expr instanceof Name name) {
            return name.getFullyQualifiedName();
        }
        return null;
    }

    private TypeDeclaration resolveTypeDeclaration(String typeFqn) {
        if (typeFqn == null || typeFqn.isBlank()) {
            return null;
        }
        TypeDeclaration td = context.getTypeDeclaration(typeFqn);
        if (td != null) {
            return td;
        }
        return context.getTypeDeclaration(simplifyTypeName(typeFqn));
    }

    private static String simplifyTypeName(String typeFqn) {
        if (typeFqn == null) {
            return null;
        }
        int lastDot = typeFqn.lastIndexOf('.');
        return lastDot >= 0 ? typeFqn.substring(lastDot + 1) : typeFqn;
    }

    private ReceiverFacts factsFromCallerArgument(
            String calleeMethodFqn,
            int paramIndex,
            List<String> methodPath,
            Map<String, List<CallEdge>> callGraph,
            int depth,
            Set<String> visited) {
        if (calleeMethodFqn == null || methodPath == null || callGraph == null) {
            return ReceiverFacts.empty();
        }
        int calleeIdx = methodPath.indexOf(calleeMethodFqn);
        if (calleeIdx <= 0) {
            calleeIdx = indexOfMethodOnPath(methodPath, calleeMethodFqn);
        }
        if (calleeIdx <= 0) {
            return ReceiverFacts.empty();
        }
        String callerMethodFqn = methodPath.get(calleeIdx - 1);
        List<CallEdge> edges = callGraph.get(callerMethodFqn);
        if (edges == null) {
            return ReceiverFacts.empty();
        }
        LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
        for (CallEdge edge : edges) {
            if (edge == null || edge.getTargetMethod() == null) {
                continue;
            }
            if (!CallSiteArgBinder.methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) {
                continue;
            }
            List<String> args = edge.getArguments();
            if (args == null || paramIndex >= args.size()) {
                continue;
            }
            // Prefer AST so nested CIC args stay attached for getter peels.
            MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn);
            ReceiverFacts nested = ReceiverFacts.empty();
            if (callerMd != null) {
                var bound = CallSiteArgBinder.findUniqueArgument(callerMd, edge, paramIndex);
                if (bound.isPresent()) {
                    nested = factsAtRaw(bound.get(), callerMd, methodPath, callGraph, depth + 1, visited);
                }
            }
            if (!nested.isEmpty()) {
                sites.addAll(nested.sites());
                continue;
            }
            AllocationSite fromArg = siteFromArgumentText(args.get(paramIndex));
            if (fromArg != null) {
                sites.add(fromArg);
            }
        }
        return ReceiverFacts.ofAll(sites);
    }

    private AllocationSite siteFromCic(ClassInstanceCreation cic) {
        if (cic == null) {
            return null;
        }
        ITypeBinding binding = cic.resolveTypeBinding();
        if (binding != null) {
            ITypeBinding erasure = binding.getErasure();
            if (erasure != null && erasure.getQualifiedName() != null && !erasure.getQualifiedName().isBlank()) {
                return new AllocationSite(erasure.getQualifiedName(), cic);
            }
        }
        if (cic.resolveConstructorBinding() != null
                && cic.resolveConstructorBinding().getDeclaringClass() != null) {
            String qn = cic.resolveConstructorBinding().getDeclaringClass().getErasure().getQualifiedName();
            if (qn != null && !qn.isBlank()) {
                return new AllocationSite(qn, cic);
            }
        }
        String simple = AstUtils.extractSimpleTypeName(cic.getType());
        if (simple == null || simple.isBlank()) {
            return null;
        }
        TypeDeclaration td = context.getTypeDeclaration(simple);
        if (td != null) {
            String fqn = context.getFqn(td);
            if (fqn != null && !fqn.isBlank()) {
                return new AllocationSite(fqn, cic);
            }
        }
        CompilationUnitAwareFqn resolved = resolveSimpleInEnclosingCu(cic, simple);
        if (resolved != null) {
            return new AllocationSite(resolved.fqn(), cic);
        }
        return new AllocationSite(simple, cic);
    }

    private record CompilationUnitAwareFqn(String fqn) {
    }

    private CompilationUnitAwareFqn resolveSimpleInEnclosingCu(ASTNode node, String simple) {
        if (node == null || simple == null) {
            return null;
        }
        ASTNode root = node.getRoot();
        if (!(root instanceof org.eclipse.jdt.core.dom.CompilationUnit cu)) {
            return null;
        }
        TypeDeclaration td = context.getTypeDeclaration(simple, cu);
        if (td == null) {
            return null;
        }
        String fqn = context.getFqn(td);
        return fqn == null || fqn.isBlank() ? null : new CompilationUnitAwareFqn(fqn);
    }

    private static AllocationSite siteFromArgumentText(String arg) {
        if (arg == null || arg.isBlank()) {
            return null;
        }
        String trimmed = arg.trim();
        if (!trimmed.startsWith("new ")) {
            return null;
        }
        String rest = trimmed.substring(4).trim();
        int paren = rest.indexOf('(');
        int brace = rest.indexOf('{');
        int end = rest.length();
        if (paren >= 0) {
            end = Math.min(end, paren);
        }
        if (brace >= 0) {
            end = Math.min(end, brace);
        }
        String type = rest.substring(0, end).trim();
        if (type.isBlank() || type.contains(" ")) {
            return null;
        }
        int generic = type.indexOf('<');
        if (generic >= 0) {
            type = type.substring(0, generic);
        }
        return new AllocationSite(type);
    }

    private static Expression peel(Expression expression) {
        Expression current = expression;
        while (current instanceof ParenthesizedExpression pe) {
            current = pe.getExpression();
        }
        if (current instanceof CastExpression ce) {
            return peel(ce.getExpression());
        }
        return current;
    }

    private static int parameterIndex(MethodDeclaration method, String name) {
        if (method == null || name == null) {
            return -1;
        }
        List<?> params = method.parameters();
        for (int i = 0; i < params.size(); i++) {
            if (params.get(i) instanceof SingleVariableDeclaration svd
                    && name.equals(svd.getName().getIdentifier())) {
                return i;
            }
        }
        return -1;
    }

    private String methodFqn(MethodDeclaration method) {
        if (method == null) {
            return null;
        }
        TypeDeclaration td = findEnclosingType(method);
        if (td == null) {
            return null;
        }
        String typeFqn = context.getFqn(td);
        if (typeFqn == null) {
            return null;
        }
        return typeFqn + "." + method.getName().getIdentifier();
    }

    private MethodDeclaration findMethodDeclaration(String methodFqn) {
        if (methodFqn == null || !methodFqn.contains(".")) {
            return null;
        }
        String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
        String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
        TypeDeclaration td = context.getTypeDeclaration(className);
        if (td == null) {
            return null;
        }
        return context.findMethodDeclaration(td, methodName, true);
    }

    private static MethodDeclaration findEnclosingMethod(ASTNode node) {
        ASTNode current = node;
        while (current != null) {
            if (current instanceof MethodDeclaration md) {
                return md;
            }
            current = current.getParent();
        }
        return null;
    }

    private static TypeDeclaration findEnclosingType(ASTNode node) {
        ASTNode current = node;
        while (current != null) {
            if (current instanceof TypeDeclaration td) {
                return td;
            }
            current = current.getParent();
        }
        return null;
    }

    private static String methodNameOf(String methodFqn) {
        return CallSiteArgBinder.methodNameOf(methodFqn);
    }

    private static boolean methodsMatch(String left, String right) {
        return CallSiteArgBinder.methodsMatch(left, right);
    }

    private static int indexOfMethodOnPath(List<String> methodPath, String methodFqn) {
        if (methodPath == null || methodFqn == null) {
            return -1;
        }
        String simple = CallSiteArgBinder.methodNameOf(methodFqn);
        for (int i = methodPath.size() - 1; i >= 0; i--) {
            String hop = methodPath.get(i);
            if (hop != null && (hop.equals(methodFqn) || hop.endsWith("." + simple))) {
                return i;
            }
        }
        return -1;
    }
}
