IntEnumMultiFileRewriteOperation.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.multifile;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.EnumConstantDeclaration;
import org.eclipse.jdt.core.dom.EnumDeclaration;
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.InfixExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.PrimitiveType;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jdt.core.dom.rewrite.ListRewrite;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperationWithSourceRange;
import org.eclipse.jdt.internal.corext.fix.LinkedProposalModelCore;
import org.eclipse.jdt.internal.corext.refactoring.structure.CompilationUnitRewrite;

import org.eclipse.text.edits.TextEditGroup;

/** Local AST rewrite generated from a project-wide integer-state plan. */
final class IntEnumMultiFileRewriteOperation extends CompilationUnitRewriteOperationWithSourceRange {

	record ResolvedOwner(IntEnumCandidate candidate, TypeDeclaration type, MethodDeclaration method,
			SingleVariableDeclaration parameter, Map<FieldDeclaration, Set<String>> constantFragments) {
	}

	record Replacement(IntEnumCandidate candidate, String enumConstantName) {
	}

	record ResolvedPlan(String compilationUnitHandle, List<ResolvedOwner> owners,
			Map<Expression, Replacement> replacements, Set<ASTNode> processedNodes) {
	}

	private final ResolvedPlan plan;

	IntEnumMultiFileRewriteOperation(ResolvedPlan plan) {
		this.plan= plan;
	}

	static ResolvedPlan resolve(ICompilationUnit unit, org.eclipse.jdt.core.dom.CompilationUnit root,
			List<IntEnumCandidate> candidates) throws CoreException {
		String unitHandle= unit.getHandleIdentifier();
		Map<String, IntEnumCandidate> byMethod= new HashMap<>();
		Map<String, IntEnumCandidate> byConstant= new HashMap<>();
		for (IntEnumCandidate candidate : candidates) {
			byMethod.put(candidate.methodBindingKey(), candidate);
			candidate.constants().forEach(constant -> byConstant.put(constant.bindingKey(), candidate));
		}

		Map<IntEnumCandidate, TypeDeclaration> ownerTypes= new LinkedHashMap<>();
		Map<IntEnumCandidate, MethodDeclaration> ownerMethods= new LinkedHashMap<>();
		Map<IntEnumCandidate, Map<FieldDeclaration, Set<String>>> ownerFields= new LinkedHashMap<>();
		Map<Expression, Replacement> replacements= new IdentityHashMap<>();
		Map<IntEnumCandidate, Map<String, Integer>> referenceCounts= new HashMap<>();
		Map<IntEnumCandidate, Integer> callCounts= new HashMap<>();
		Set<ASTNode> processed= java.util.Collections.newSetFromMap(new IdentityHashMap<>());

		try {
			root.accept(new ASTVisitor() {
				@Override
				public boolean visit(TypeDeclaration node) {
					String key= typeKey(node.resolveBinding());
					for (IntEnumCandidate candidate : candidates) {
						if (unitHandle.equals(candidate.ownerCompilationUnitHandle())
								&& candidate.ownerTypeBindingKey().equals(key)) {
							ownerTypes.put(candidate, node);
							processed.add(node);
						}
					}
					return true;
				}

				@Override
				public boolean visit(MethodDeclaration node) {
					IMethodBinding binding= node.resolveBinding();
					String key= binding == null ? null : binding.getMethodDeclaration().getKey();
					IntEnumCandidate candidate= byMethod.get(key);
					if (candidate != null && unitHandle.equals(candidate.ownerCompilationUnitHandle())) {
						ownerMethods.put(candidate, node);
						processed.add(node);
					}
					return true;
				}

				@Override
				public boolean visit(FieldDeclaration node) {
					for (Object fragmentObject : node.fragments()) {
						VariableDeclarationFragment fragment= (VariableDeclarationFragment) fragmentObject;
						IVariableBinding binding= fragment.resolveBinding();
						String key= binding == null ? null : binding.getVariableDeclaration().getKey();
						IntEnumCandidate candidate= byConstant.get(key);
						if (candidate != null && unitHandle.equals(candidate.ownerCompilationUnitHandle())) {
							ownerFields.computeIfAbsent(candidate, ignored -> new LinkedHashMap<>())
									.computeIfAbsent(node, ignored -> new LinkedHashSet<>()).add(key);
							processed.add(node);
						}
					}
					return true;
				}

				@Override
				public boolean visit(MethodInvocation node) {
					IMethodBinding binding= node.resolveMethodBinding();
					String key= binding == null ? null : binding.getMethodDeclaration().getKey();
					IntEnumCandidate candidate= byMethod.get(key);
					if (candidate != null) {
						callCounts.merge(candidate, Integer.valueOf(1), Integer::sum);
					}
					return true;
				}

				@Override
				public boolean visit(SimpleName node) {
					IBinding binding= node.resolveBinding();
					if (!(binding instanceof IVariableBinding variable)) {
						return true;
					}
					String constantKey= variable.getVariableDeclaration().getKey();
					IntEnumCandidate candidate= byConstant.get(constantKey);
					if (candidate == null || isDeclarationName(node)) {
						return true;
					}
					Expression expression= containingExpression(node);
					if (!isRecognisedReference(expression, candidate)) {
						throw new StalePlanRuntimeException(
								stale(unit, "unexpected use of " + node.getIdentifier())); //$NON-NLS-1$
					}
					IntEnumConstant constant= candidate.constant(constantKey);
					replacements.put(expression, new Replacement(candidate, constant.enumName()));
					referenceCounts.computeIfAbsent(candidate, ignored -> new LinkedHashMap<>())
							.merge(constantKey, Integer.valueOf(1), Integer::sum);
					processed.add(expression);
					return true;
				}
			});
		} catch (StalePlanRuntimeException exception) {
			throw exception.coreException;
		}

		List<ResolvedOwner> owners= new ArrayList<>();
		for (IntEnumCandidate candidate : candidates) {
			Map<String, Integer> expectedReferences= candidate.expectedReferenceCountsByUnit()
					.getOrDefault(unitHandle, Map.of());
			Map<String, Integer> actualReferences= referenceCounts.getOrDefault(candidate, Map.of());
			if (!expectedReferences.equals(actualReferences)) {
				throw stale(unit, "constant reference count changed for " + candidate.enumTypeName()); //$NON-NLS-1$
			}
			int expectedCalls= candidate.expectedCallCountsByUnit().getOrDefault(unitHandle, Integer.valueOf(0))
					.intValue();
			if (expectedCalls != callCounts.getOrDefault(candidate, Integer.valueOf(0)).intValue()) {
				throw stale(unit, "method call count changed for " + candidate.enumTypeName()); //$NON-NLS-1$
			}
			if (unitHandle.equals(candidate.ownerCompilationUnitHandle())) {
				TypeDeclaration type= ownerTypes.get(candidate);
				MethodDeclaration method= ownerMethods.get(candidate);
				Map<FieldDeclaration, Set<String>> fields= ownerFields.getOrDefault(candidate, Map.of());
				if (type == null || method == null || candidate.parameterIndex() >= method.parameters().size()
						|| fields.values().stream().mapToInt(Set::size).sum() != candidate.constants().size()) {
					throw stale(unit, "owner declaration changed for " + candidate.enumTypeName()); //$NON-NLS-1$
				}
				SingleVariableDeclaration parameter= (SingleVariableDeclaration) method.parameters()
						.get(candidate.parameterIndex());
				if (!isInt(parameter.getType())) {
					throw stale(unit, "state parameter is no longer int"); //$NON-NLS-1$
				}
				owners.add(new ResolvedOwner(candidate, type, method, parameter, fields));
			}
		}
		return new ResolvedPlan(unitHandle, List.copyOf(owners), Map.copyOf(replacements), Set.copyOf(processed));
	}

	private static final class StalePlanRuntimeException extends RuntimeException {
		private static final long serialVersionUID= 1L;
		private final CoreException coreException;

		StalePlanRuntimeException(CoreException coreException) {
			this.coreException= coreException;
		}
	}

	@Override
	public void rewriteASTInternal(CompilationUnitRewrite cuRewrite, LinkedProposalModelCore linkedModel)
			throws CoreException {
		TextEditGroup group= createTextEditGroup("Convert package-scoped integer state domain to enum", cuRewrite); //$NON-NLS-1$
		AST ast= cuRewrite.getRoot().getAST();
		ASTRewrite rewrite= cuRewrite.getASTRewrite();
		ImportRewrite imports= cuRewrite.getImportRewrite();

		for (ResolvedOwner owner : plan.owners()) {
			insertEnum(owner, ast, rewrite, group);
			rewrite.replace(owner.parameter().getType(),
					ast.newSimpleType(ast.newSimpleName(owner.candidate().enumTypeName())), group);
			removeConstants(owner, rewrite, group);
		}
		for (Map.Entry<Expression, Replacement> entry : plan.replacements().entrySet()) {
			Replacement replacement= entry.getValue();
			IntEnumCandidate candidate= replacement.candidate();
			String enumQualifier;
			if (plan.compilationUnitHandle().equals(candidate.ownerCompilationUnitHandle())) {
				enumQualifier= candidate.enumTypeName();
			} else {
				String ownerReference= ownerReference(cuRewrite.getRoot(), candidate, imports);
				enumQualifier= ownerReference + '.' + candidate.enumTypeName();
			}
			Name name= ast.newName(enumQualifier + '.' + replacement.enumConstantName());
			rewrite.replace(entry.getKey(), name, group);
		}
	}

	private static String ownerReference(org.eclipse.jdt.core.dom.CompilationUnit root,
			IntEnumCandidate candidate, ImportRewrite imports) {
		String qualifiedName= candidate.ownerTypeQualifiedName();
		int separator= qualifiedName.lastIndexOf('.');
		String simpleName= separator < 0 ? qualifiedName : qualifiedName.substring(separator + 1);
		boolean[] conflict= { false };
		root.accept(new ASTVisitor() {
			@Override
			public void preVisit(ASTNode node) {
				if (conflict[0]) {
					return;
				}
				if (node instanceof org.eclipse.jdt.core.dom.AbstractTypeDeclaration declaration
						&& simpleName.equals(declaration.getName().getIdentifier())) {
					String key= typeKey(declaration.resolveBinding());
					conflict[0]= !candidate.ownerTypeBindingKey().equals(key);
				} else if (node instanceof org.eclipse.jdt.core.dom.TypeParameter parameter
						&& simpleName.equals(parameter.getName().getIdentifier())) {
					conflict[0]= true;
				}
			}
		});
		return conflict[0] ? qualifiedName : imports.addImport(qualifiedName);
	}

	private static void insertEnum(ResolvedOwner owner, AST ast, ASTRewrite rewrite, TextEditGroup group) {
		EnumDeclaration enumDeclaration= ast.newEnumDeclaration();
		enumDeclaration.setName(ast.newSimpleName(owner.candidate().enumTypeName()));
		for (IntEnumConstant constant : owner.candidate().constants()) {
			EnumConstantDeclaration enumConstant= ast.newEnumConstantDeclaration();
			enumConstant.setName(ast.newSimpleName(constant.enumName()));
			enumDeclaration.enumConstants().add(enumConstant);
		}
		FieldDeclaration firstField= owner.constantFragments().keySet().stream()
				.min(java.util.Comparator.comparingInt(ASTNode::getStartPosition)).orElseThrow();
		ListRewrite body= rewrite.getListRewrite(owner.type(), TypeDeclaration.BODY_DECLARATIONS_PROPERTY);
		body.insertBefore(enumDeclaration, firstField, group);
	}

	private static void removeConstants(ResolvedOwner owner, ASTRewrite rewrite, TextEditGroup group) {
		ListRewrite body= rewrite.getListRewrite(owner.type(), TypeDeclaration.BODY_DECLARATIONS_PROPERTY);
		for (Map.Entry<FieldDeclaration, Set<String>> entry : owner.constantFragments().entrySet()) {
			FieldDeclaration field= entry.getKey();
			Set<String> selected= entry.getValue();
			boolean all= true;
			for (Object fragmentObject : field.fragments()) {
				VariableDeclarationFragment fragment= (VariableDeclarationFragment) fragmentObject;
				IVariableBinding binding= fragment.resolveBinding();
				String key= binding == null ? null : binding.getVariableDeclaration().getKey();
				if (!selected.contains(key)) {
					all= false;
					break;
				}
			}
			if (all) {
				body.remove(field, group);
			} else {
				ListRewrite fragments= rewrite.getListRewrite(field, FieldDeclaration.FRAGMENTS_PROPERTY);
				for (Object fragmentObject : field.fragments()) {
					VariableDeclarationFragment fragment= (VariableDeclarationFragment) fragmentObject;
					IVariableBinding binding= fragment.resolveBinding();
					String key= binding == null ? null : binding.getVariableDeclaration().getKey();
					if (selected.contains(key)) {
						fragments.remove(fragment, group);
					}
				}
			}
		}
	}

	private static boolean isRecognisedReference(Expression expression, IntEnumCandidate candidate) {
		Expression outer= outerExpression(expression);
		ASTNode parent= outer.getParent();
		if (parent instanceof MethodInvocation invocation) {
			IMethodBinding binding= invocation.resolveMethodBinding();
			String key= binding == null ? null : binding.getMethodDeclaration().getKey();
			return candidate.methodBindingKey().equals(key) && candidate.parameterIndex() < invocation.arguments().size()
					&& outerExpression((Expression) invocation.arguments().get(candidate.parameterIndex())) == outer;
		}
		ASTNode current= outer.getParent();
		while (current != null && !(current instanceof MethodDeclaration)) {
			if (current instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
				Expression left= outerExpression(infix.getLeftOperand());
				Expression right= outerExpression(infix.getRightOperand());
				Expression other= left == outer ? right : right == outer ? left : null;
				IVariableBinding otherBinding= other == null ? null : resolveVariable(other);
				if (otherBinding != null && candidate.methodBindingKey().equals(enclosingMethodKey(infix))
						&& candidate.parameterIndex() >= 0) {
					MethodDeclaration method= enclosingMethod(infix);
					if (method != null && candidate.parameterIndex() < method.parameters().size()) {
						SingleVariableDeclaration parameter= (SingleVariableDeclaration) method.parameters()
								.get(candidate.parameterIndex());
						IVariableBinding parameterBinding= parameter.resolveBinding();
						return parameterBinding != null
								&& parameterBinding.getVariableDeclaration().isEqualTo(otherBinding.getVariableDeclaration());
					}
				}
			}
			current= current.getParent();
		}
		return false;
	}

	private static MethodDeclaration enclosingMethod(ASTNode node) {
		ASTNode current= node;
		while (current != null && !(current instanceof MethodDeclaration)) {
			current= current.getParent();
		}
		return (MethodDeclaration) current;
	}

	private static String enclosingMethodKey(ASTNode node) {
		MethodDeclaration method= enclosingMethod(node);
		IMethodBinding binding= method == null ? null : method.resolveBinding();
		return binding == null ? null : binding.getMethodDeclaration().getKey();
	}

	private static Expression containingExpression(SimpleName name) {
		ASTNode parent= name.getParent();
		Expression expression;
		if (parent instanceof QualifiedName qualified && qualified.getName() == name) {
			expression= qualified;
		} else if (parent instanceof FieldAccess access && access.getName() == name) {
			expression= access;
		} else {
			expression= name;
		}
		return outerExpression(expression);
	}

	private static Expression outerExpression(Expression expression) {
		Expression current= expression;
		while (current.getParent() instanceof ParenthesizedExpression parenthesized) {
			current= parenthesized;
		}
		return current;
	}

	private static IVariableBinding resolveVariable(Expression expression) {
		Expression current= expression;
		while (current instanceof ParenthesizedExpression parenthesized) {
			current= parenthesized.getExpression();
		}
		IBinding binding= null;
		if (current instanceof Name name) {
			binding= name.resolveBinding();
		} else if (current instanceof FieldAccess access) {
			binding= access.resolveFieldBinding();
		}
		return binding instanceof IVariableBinding variable ? variable.getVariableDeclaration() : null;
	}

	private static boolean isDeclarationName(SimpleName name) {
		return name.getParent() instanceof VariableDeclarationFragment fragment && fragment.getName() == name;
	}

	private static String typeKey(ITypeBinding binding) {
		return binding == null ? null : binding.getTypeDeclaration().getKey();
	}

	private static boolean isInt(Type type) {
		return type.isPrimitiveType()
				&& ((PrimitiveType) type).getPrimitiveTypeCode() == PrimitiveType.INT;
	}

	private static CoreException stale(ICompilationUnit unit, String detail) {
		String message= "The project-wide int-to-enum plan is stale for " + unit.getElementName() + ": " + detail; //$NON-NLS-1$ //$NON-NLS-2$
		return new CoreException(new Status(IStatus.ERROR, "sandbox_int_to_enum", message)); //$NON-NLS-1$
	}
}