001package gudusoft.gsqlparser.resolver2;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.ETableSource;
005import gudusoft.gsqlparser.TSourceToken;
006import gudusoft.gsqlparser.nodes.TObjectName;
007import gudusoft.gsqlparser.nodes.TResultColumn;
008import gudusoft.gsqlparser.nodes.TResultColumnList;
009import gudusoft.gsqlparser.nodes.TTable;
010import gudusoft.gsqlparser.nodes.TTableList;
011import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
012import gudusoft.gsqlparser.resolver2.matcher.INameMatcher;
013import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource;
014import gudusoft.gsqlparser.resolver2.model.ColumnSource;
015import gudusoft.gsqlparser.resolver2.model.FieldPath;
016import gudusoft.gsqlparser.resolver2.model.ResolutionContext;
017import gudusoft.gsqlparser.resolver2.model.ResolutionResult;
018import gudusoft.gsqlparser.resolver2.namespace.INamespace;
019import gudusoft.gsqlparser.resolver2.namespace.UnnestNamespace;
020import gudusoft.gsqlparser.resolver2.scope.IScope;
021import gudusoft.gsqlparser.resolver2.scope.ResolvedImpl;
022import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
023import gudusoft.gsqlparser.util.SQLUtil;
024
025import java.util.ArrayList;
026import java.util.Arrays;
027import java.util.Collections;
028import java.util.Comparator;
029import java.util.IdentityHashMap;
030import java.util.LinkedHashSet;
031import java.util.List;
032import java.util.Set;
033
034/**
035 * Core component for resolving column references to their sources.
036 *
037 * Key responsibilities:
038 * 1. Resolve TObjectName (column references) using scope tree
039 * 2. Handle qualified and unqualified names
040 * 3. Detect and report ambiguities
041 * 4. Apply GUESS_COLUMN_STRATEGY for ambiguous columns
042 * 5. Update TObjectName with resolution results
043 * 6. Update ResolutionContext for global querying
044 */
045public class NameResolver {
046
047    private final INameMatcher nameMatcher;
048    private final ResolutionContext context;
049    private final TSQLResolverConfig config;
050
051    /**
052     * Create a NameResolver with full configuration.
053     *
054     * @param config The resolver configuration (includes name matcher and strategy)
055     * @param context The resolution context for tracking results
056     */
057    public NameResolver(TSQLResolverConfig config, ResolutionContext context) {
058        this.config = config;
059        this.nameMatcher = config.getNameMatcher();
060        this.context = context;
061    }
062
063    /**
064     * Binding-trace capture sink (dynamic-SQL publication proof, R4 step 3);
065     * {@code null} unless {@code TSQLResolverConfig.captureBindingTrace} —
066     * the hook below is then a single null check. Observation-only.
067     */
068    private gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry bindingTraceRegistry;
069
070    public void setBindingTraceRegistry(
071            gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry registry) {
072        this.bindingTraceRegistry = registry;
073    }
074
075    /**
076     * Record how {@code objName} was bound. The winning match's namespace,
077     * scope, path and remaining names are exactly the information
078     * {@code processResolvedMatches} discards when constructing
079     * {@link ResolutionResult}; the trace preserves them for the P3 proof.
080     * When the result did not come from the recorded matches (e.g. the
081     * struct-field fallback), the match data no longer corresponds and the
082     * trace comes out incomplete — fail-closed by construction.
083     */
084    private void captureBindingTrace(TObjectName objName, IScope startingScope,
085            ResolvedImpl resolved, ResolutionResult result, boolean fallbackUsed) {
086        ResolvedImpl.Match winning = resolved.getCount() > 0 ? resolved.getMatches().get(0) : null;
087        int climb = -1;
088        if (winning != null) {
089            int hops = 0;
090            for (IScope s = startingScope; s != null; s = s.getParent(), hops++) {
091                if (s == winning.scope) {
092                    climb = hops;
093                    break;
094                }
095            }
096        }
097        bindingTraceRegistry.register(gudusoft.gsqlparser.resolver2.binding.BindingTrace
098                .scopeResolution(objName,
099                        result.getStatus(),
100                        winning != null ? winning.namespace : null,
101                        winning != null ? definitionNodeOf(winning.namespace) : null,
102                        objName.getSourceTable(),
103                        String.valueOf(startingScope.getScopeType()),
104                        winning != null ? String.valueOf(winning.scope.getScopeType()) : null,
105                        winning != null ? String.valueOf(winning.path) : null,
106                        resolved.getCount(),
107                        climb,
108                        // A column reference matched via its container namespace
109                        // legitimately leaves ONE remaining segment (the column
110                        // itself); more than one means a partially-consumed path
111                        // (struct access) whose tail binding we did not see.
112                        winning != null && winning.remainingNames.size() <= 1,
113                        result.isExactMatch(),
114                        fallbackUsed));
115    }
116
117    /**
118     * The AST node that defines a namespace's binder — what the publication
119     * proof checks spans against. Unknown namespace kinds return null and the
120     * trace comes out incomplete (fail-closed).
121     */
122    private static Object definitionNodeOf(INamespace ns) {
123        if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.TableNamespace) {
124            return ((gudusoft.gsqlparser.resolver2.namespace.TableNamespace) ns).getTable();
125        }
126        if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.CTENamespace) {
127            return ((gudusoft.gsqlparser.resolver2.namespace.CTENamespace) ns).getCTE();
128        }
129        if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) {
130            return ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) ns).getSubquery();
131        }
132        return null;
133    }
134
135    /**
136     * Create a NameResolver with just name matcher (backward compatibility).
137     * Uses default configuration for GUESS_COLUMN_STRATEGY.
138     *
139     * @deprecated Use NameResolver(TSQLResolverConfig, ResolutionContext) instead
140     */
141    @Deprecated
142    public NameResolver(INameMatcher nameMatcher, ResolutionContext context) {
143        this.nameMatcher = nameMatcher;
144        this.context = context;
145        this.config = null; // Will use TBaseType.GUESS_COLUMN_STRATEGY
146    }
147
148    /**
149     * Get the effective GUESS_COLUMN_STRATEGY.
150     * Returns config value if available, otherwise TBaseType.GUESS_COLUMN_STRATEGY.
151     */
152    private int getGuessColumnStrategy() {
153        if (config != null) {
154            return config.getGuessColumnStrategy();
155        }
156        return gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY;
157    }
158
159    private static final boolean DEBUG_RESOLUTION = false;
160
161    /**
162     * Resolve a column reference (TObjectName) within a given scope.
163     *
164     * @param objName The column reference to resolve
165     * @param scope The scope where the reference appears
166     * @return Resolution result
167     */
168    public ResolutionResult resolve(TObjectName objName, IScope scope) {
169        if (objName == null || scope == null) {
170            return ResolutionResult.notFound("<null>");
171        }
172
173        // ClickHouse expression-CTE alias (WITH <expr> AS ident): phase 1
174        // resolved it to a scalar symbol (expressionCteRef). It is not a
175        // column of any relation — namespace matching would fabricate a
176        // binding to whichever FROM table happens to be in scope.
177        if (objName.getExpressionCteRef() != null && objName.getSourceTable() == null) {
178            return ResolutionResult.notFound(objName.toString(),
179                    "expression-CTE alias, resolved as a scalar symbol");
180        }
181
182        // Extract name parts from TObjectName
183        List<String> nameParts = extractNameParts(objName);
184        if (nameParts.isEmpty()) {
185            return ResolutionResult.notFound("<empty>");
186        }
187
188        if (DEBUG_RESOLUTION) {
189            System.out.println("[DEBUG-RESOLVE] Resolving: " + objName +
190                " nameParts=" + nameParts + " scopeType=" + scope.getScopeType());
191        }
192
193        // Use scope to resolve the name
194        ResolvedImpl resolved = new ResolvedImpl();
195        scope.resolve(nameParts, nameMatcher, false, resolved);
196
197        if (DEBUG_RESOLUTION) {
198            System.out.println("[DEBUG-RESOLVE]   Resolved matches: " + resolved.getCount());
199            if (resolved.getCount() > 1) {
200                for (ResolvedImpl.Match m : resolved.getMatches()) {
201                    System.out.println("[DEBUG-RESOLVE]     Match: " + m.namespace.getDisplayName() +
202                        " type=" + m.namespace.getClass().getSimpleName() +
203                        " id=" + System.identityHashCode(m.namespace) +
204                        " remaining=" + m.remainingNames +
205                        " scope=" + m.scope.getScopeType());
206                }
207            }
208        }
209
210        // Process resolution results
211        ResolutionResult result = processResolvedMatches(objName, nameParts, resolved);
212
213        // Delta 3: Struct-field fallback for BigQuery/Snowflake
214        // If resolution failed and we have a 2-part qualified name like "customer.customer_id",
215        // try interpreting it as column.field (struct field access) instead of table.column.
216        // Only for 2-part names; 3+ part no-alias case (e.g., customer.address.city) is not
217        // handled here to avoid changing resolution paths for existing 3-part BigQuery patterns
218        // (e.g., purchases.first.msts) that are correctly handled by DataFlowAnalyzer heuristics.
219        // The alias case (o.customer.address.city) IS handled by resolveColumnPath() in
220        // processResolvedMatches() and does not depend on this fallback.
221        boolean fallbackUsed = false;
222        if (!result.isExactMatch() && nameParts.size() == 2 && isStructFieldVendor()) {
223            ResolutionResult structFieldResult = tryStructFieldFallback(objName, nameParts, scope);
224            if (structFieldResult != null && structFieldResult.isExactMatch()) {
225                result = structFieldResult;
226                fallbackUsed = true;
227                if (DEBUG_RESOLUTION) {
228                    System.out.println("[DEBUG-RESOLVE]   Struct-field fallback succeeded for: " + objName);
229                }
230            }
231        }
232
233        // Delta 4: Side-channel hint for 3+ part no-alias struct access (BigQuery only).
234        // When resolution failed and we have 3+ parts (e.g., customer.address.city),
235        // set a StructFieldHint WITHOUT changing the resolution result or sourceTable.
236        // This provides struct path info to DataFlowAnalyzer without altering lineage topology.
237        if (!result.isExactMatch() && nameParts.size() >= 3 && isStructFieldHintVendor()) {
238            trySetStructFieldHint(objName, nameParts, scope);
239        }
240
241        if (DEBUG_RESOLUTION) {
242            System.out.println("[DEBUG-RESOLVE]   Result: " + result.getStatus() +
243                (result.isExactMatch() && result.getColumnSource() != null ?
244                    " source=" + result.getColumnSource().getExposedName() : ""));
245        }
246
247        // Update TObjectName and context
248        updateObjectNameWithResult(objName, result);
249
250        // Binding-trace capture (observation-only; null unless opted in).
251        // Runs AFTER the update so the trace observes the post-resolution
252        // sourceTable — the identity the registry's staleness check compares
253        // against when later correction handlers change or clear it.
254        if (bindingTraceRegistry != null) {
255            captureBindingTrace(objName, scope, resolved, result, fallbackUsed);
256        }
257
258        return result;
259    }
260
261    /**
262     * Check if the current vendor supports struct-field access syntax (column.field).
263     * Currently supported: BigQuery, Snowflake
264     */
265    private boolean isStructFieldVendor() {
266        if (config == null) {
267            return false;
268        }
269        EDbVendor vendor = config.getVendor();
270        return vendor == EDbVendor.dbvbigquery || vendor == EDbVendor.dbvsnowflake;
271    }
272
273    /**
274     * Check if the current vendor supports struct-field hint annotations.
275     * Currently BigQuery only (Snowflake uses schema.table.column for 3-part names).
276     */
277    private boolean isStructFieldHintVendor() {
278        if (config == null) {
279            return false;
280        }
281        return config.getVendor() == EDbVendor.dbvbigquery;
282    }
283
284    /**
285     * Delta 4: Try to set a StructFieldHint for 3+ part no-alias struct access.
286     *
287     * For "customer.address.city" (3 parts, no alias):
288     * - Treat the first part ("customer") as a potential base column
289     * - If found in any visible namespace, set a hint with fieldPath=["address", "city"]
290     * - Does NOT change ResolutionResult or sourceTable
291     *
292     * @param objName The TObjectName to annotate with a hint
293     * @param nameParts The extracted name parts (e.g., ["customer", "address", "city"])
294     * @param scope The scope to search in
295     */
296    private void trySetStructFieldHint(TObjectName objName, List<String> nameParts, IScope scope) {
297        String baseColumnName = nameParts.get(0);
298        List<String> fieldPathSegments = nameParts.subList(1, nameParts.size());
299
300        // Try to find the base column as an unqualified name
301        List<String> singlePartName = Collections.singletonList(baseColumnName);
302        ResolvedImpl resolved = new ResolvedImpl();
303        scope.resolve(singlePartName, nameMatcher, false, resolved);
304
305        if (resolved.isEmpty()) {
306            return; // Base column not found in any namespace
307        }
308
309        // Check if any namespace actually has this column
310        for (ResolvedImpl.Match match : resolved.getMatches()) {
311            gudusoft.gsqlparser.resolver2.model.ColumnSource baseColumnSource =
312                match.namespace.resolveColumn(baseColumnName);
313            if (baseColumnSource != null) {
314                // Found the base column - create hint
315                gudusoft.gsqlparser.resolver2.model.FieldPath fieldPath =
316                    gudusoft.gsqlparser.resolver2.model.FieldPath.of(fieldPathSegments);
317                gudusoft.gsqlparser.resolver2.model.StructFieldHint hint =
318                    new gudusoft.gsqlparser.resolver2.model.StructFieldHint(
319                        baseColumnName, fieldPath,
320                        "struct_field_hint_no_alias", 0.7);
321                objName.setStructFieldHint(hint);
322
323                if (DEBUG_RESOLUTION) {
324                    System.out.println("[DEBUG-RESOLVE]   StructFieldHint set for: " + objName +
325                        " -> base=" + baseColumnName + ", fieldPath=" + fieldPathSegments);
326                }
327                return;
328            }
329        }
330    }
331
332    /**
333     * Delta 3: Try to resolve a qualified name as struct-field access (column.field).
334     *
335     * In BigQuery/Snowflake, "customer.customer_id" might be:
336     * 1. Table "customer" with column "customer_id" (standard interpretation)
337     * 2. Column "customer" (STRUCT type) with field "customer_id" (struct-field access)
338     *
339     * If the standard interpretation failed, try the struct-field interpretation:
340     * - Treat the first part as an unqualified column name
341     * - If found, return the base column as the source (the STRUCT column)
342     * - Preserve the field path (segments beyond the base column) for downstream use
343     *
344     * @param objName The original TObjectName
345     * @param nameParts The extracted name parts (e.g., ["customer", "customer_id"])
346     * @param scope The scope to search in
347     * @return Resolution result if struct-field interpretation succeeds, null otherwise
348     */
349    private ResolutionResult tryStructFieldFallback(TObjectName objName, List<String> nameParts, IScope scope) {
350        // The base column is the first part (e.g., "customer" in "customer.customer_id")
351        String baseColumnName = nameParts.get(0);
352
353        // The field path is everything after the base column
354        // For "customer.customer_id", fieldPath = ["customer_id"]
355        // For "customer.address.city", fieldPath = ["address", "city"]
356        List<String> fieldPathSegments = nameParts.size() > 1
357            ? nameParts.subList(1, nameParts.size())
358            : Collections.emptyList();
359
360        // Try to resolve the base column as an unqualified name
361        List<String> singlePartName = Collections.singletonList(baseColumnName);
362        ResolvedImpl resolved = new ResolvedImpl();
363        scope.resolve(singlePartName, nameMatcher, false, resolved);
364
365        if (resolved.isEmpty()) {
366            return null; // Base column not found
367        }
368
369        // Found potential matches - check if any namespace has this column
370        for (ResolvedImpl.Match match : resolved.getMatches()) {
371            INamespace namespace = match.namespace;
372
373            // Try to resolve the base column name in this namespace
374            ColumnSource baseColumnSource = namespace.resolveColumn(baseColumnName);
375            if (baseColumnSource != null) {
376                // Found the base column - return it as the source with field path preserved
377                if (DEBUG_RESOLUTION) {
378                    System.out.println("[DEBUG-RESOLVE]   Struct-field: found base column '" +
379                        baseColumnName + "' in " + namespace.getDisplayName() +
380                        ", fieldPath=" + fieldPathSegments);
381                }
382
383                // Create a new ColumnSource with:
384                // 1. struct_field_access evidence marker (for backward compatibility)
385                // 2. fieldPath preserved (new in Improvement B)
386                FieldPath fieldPath = FieldPath.of(fieldPathSegments);
387                ColumnSource structFieldSource = baseColumnSource.withFieldPath(fieldPath, "struct_field_access");
388
389                return ResolutionResult.exactMatch(structFieldSource);
390            }
391        }
392
393        return null;
394    }
395
396    /**
397     * Extract name parts from TObjectName.
398     * Examples:
399     * - "col" -> ["col"]
400     * - "t.col" -> ["t", "col"]
401     * - "schema.table.col" -> ["schema", "table", "col"]
402     *
403     * For BigQuery/Snowflake struct field access like "customer.customer_id":
404     * - If schema/table tokens are not set, but toString() contains dots,
405     *   extract all parts from toString() to capture field paths.
406     */
407    private List<String> extractNameParts(TObjectName objName) {
408        List<String> parts = new ArrayList<>();
409
410        // Add schema if present, but NOT when databaseToken is also set.
411        // When databaseToken is present, the name is fully qualified (db.schema.table.column)
412        // and the schema position IS the schema, not a table alias. Including it would cause
413        // the resolver to incorrectly match the schema against table aliases. (Mantis #4268)
414        if (objName.getSchemaToken() != null && objName.getDatabaseToken() == null) {
415            parts.add(objName.getSchemaString());
416        }
417
418        // Add table/qualifier if present
419        if (objName.getTableToken() != null) {
420            parts.add(objName.getTableString());
421        }
422
423        // Add column name
424        String columnName = objName.getColumnNameOnly();
425        if (columnName != null) {
426            parts.add(columnName);
427        }
428
429        // Improvement B: Handle BigQuery/Snowflake struct field access
430        // If we only got a single part from standard extraction,
431        // but toString() contains more segments (dots), extract them
432        // This handles cases like "customer.customer_id" where:
433        // - getColumnNameOnly() returns "customer"
434        // - toString() returns "customer.customer_id"
435        if (isStructFieldVendor()) {
436            String fullName = objName.toString();
437            if (fullName != null && fullName.contains(".")) {
438                // Check if fullName has more segments than what we extracted
439                String[] fullParts = splitNameParts(fullName);
440                if (fullParts.length > parts.size() || hasConsecutiveDuplicates(parts)) {
441                    // Replace parts with full extracted segments
442                    parts.clear();
443                    Collections.addAll(parts, fullParts);
444                }
445            }
446        }
447
448        return parts;
449    }
450
451    /**
452     * Check if a list has consecutive duplicate elements.
453     * This detects parser bugs where segments are duplicated.
454     * Example: ["customer", "customer", "address"] returns true.
455     */
456    private boolean hasConsecutiveDuplicates(List<String> list) {
457        if (list == null || list.size() < 2) {
458            return false;
459        }
460        for (int i = 1; i < list.size(); i++) {
461            if (list.get(i) != null && list.get(i).equals(list.get(i - 1))) {
462                return true;
463            }
464        }
465        return false;
466    }
467
468
469    /**
470     * Split a dotted name into parts, handling quoted identifiers.
471     * Examples:
472     * - "a.b.c" -> ["a", "b", "c"]
473     * - "`a.b`.c" -> ["`a.b`", "c"]
474     * - "a.`b.c`" -> ["a", "`b.c`"]
475     *
476     * @param name The dotted name string
477     * @return Array of name parts
478     */
479    private String[] splitNameParts(String name) {
480        if (name == null || name.isEmpty()) {
481            return new String[0];
482        }
483
484        // Simple case: no quotes, just split on dots
485        if (!name.contains("`") && !name.contains("\"") && !name.contains("[")) {
486            return name.split("\\.");
487        }
488
489        // Complex case: handle quoted identifiers
490        List<String> parts = new ArrayList<>();
491        StringBuilder current = new StringBuilder();
492        char quoteChar = 0;
493
494        for (int i = 0; i < name.length(); i++) {
495            char c = name.charAt(i);
496
497            if (quoteChar != 0) {
498                // Inside a quoted identifier
499                current.append(c);
500                if (c == quoteChar) {
501                    // Check for escaped quote (doubled)
502                    if (i + 1 < name.length() && name.charAt(i + 1) == quoteChar) {
503                        current.append(name.charAt(++i));
504                    } else {
505                        // End of quoted identifier
506                        quoteChar = 0;
507                    }
508                }
509            } else if (c == '`' || c == '"' || c == '[') {
510                // Start of quoted identifier
511                quoteChar = (c == '[') ? ']' : c;
512                current.append(c);
513            } else if (c == '.') {
514                // Separator
515                if (current.length() > 0) {
516                    parts.add(current.toString());
517                    current.setLength(0);
518                }
519            } else {
520                current.append(c);
521            }
522        }
523
524        // Add last part
525        if (current.length() > 0) {
526            parts.add(current.toString());
527        }
528
529        return parts.toArray(new String[0]);
530    }
531
532    /**
533     * Process resolution matches and determine final result.
534     */
535    private ResolutionResult processResolvedMatches(TObjectName objName,
536                                                    List<String> nameParts,
537                                                    ResolvedImpl resolved) {
538        String columnName = nameParts.get(nameParts.size() - 1);
539
540        if (resolved.isEmpty()) {
541            // No matches found
542            return ResolutionResult.notFound(columnName);
543        }
544
545        // Deduplicate matches by namespace identity
546        // The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope)
547        // but it's still the same source - not truly ambiguous
548        List<ResolvedImpl.Match> uniqueMatches = deduplicateMatchesByNamespace(resolved.getMatches());
549
550        if (uniqueMatches.size() == 1) {
551            // Exactly one unique namespace - success!
552            ResolvedImpl.Match match = uniqueMatches.get(0);
553            INamespace namespace = match.namespace;
554
555            // For qualified names like "t.col", we need to resolve the column
556            // For unqualified names like "col", we need to find it in the namespace
557            ColumnSource columnSource;
558
559            if (!match.remainingNames.isEmpty()) {
560                // Still have parts to resolve (e.g., found table, need to find column)
561                // Phase B3: Support multi-segment paths (table.column.field...)
562                if (match.remainingNames.size() > 1 && isStructFieldVendor()) {
563                    // Multiple segments: use resolveColumnPath for deep field access
564                    // e.g., remainingNames = ["customer", "address", "city"]
565                    // -> base column "customer", fieldPath ["address", "city"]
566                    columnSource = namespace.resolveColumnPath(match.remainingNames);
567
568                    if (columnSource == null) {
569                        // Base column not found
570                        String baseColName = match.remainingNames.get(0);
571                        return ResolutionResult.notFound(baseColName,
572                            "Column '" + baseColName + "' not found in " + namespace.getDisplayName());
573                    }
574
575                    if (DEBUG_RESOLUTION) {
576                        System.out.println("[DEBUG-RESOLVE]   Deep path resolved: " +
577                            match.remainingNames + " -> base=" + columnSource.getExposedName() +
578                            ", fieldPath=" + (columnSource.hasFieldPath() ? columnSource.getFieldPath() : "none"));
579                    }
580                } else {
581                    // Single segment: regular column resolution
582                    String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1);
583                    columnSource = namespace.resolveColumn(remainingColName);
584
585                    if (columnSource == null) {
586                        // Column not found in the namespace
587                        return ResolutionResult.notFound(remainingColName,
588                            "Column '" + remainingColName + "' not found in " + namespace.getDisplayName());
589                    }
590                }
591            } else {
592                // Name resolved to a table/namespace directly with no remaining parts
593                // This happens when the column name matches the table alias exactly.
594                //
595                // For UNNEST tables (e.g., "UNNEST(arr) AS x"), the alias "x" is ALSO
596                // the implicit column name. When user writes "x" as a column reference,
597                // the scope resolution matches the table alias "x", leaving remainingNames empty.
598                // We should still try to resolve "x" as a column in the namespace.
599                //
600                // Example: SELECT x FROM UNNEST([1,2,3]) AS x
601                // Here "x" in SELECT refers to the implicit column, not the table.
602                if (namespace instanceof UnnestNamespace) {
603                    // For UNNEST, try to resolve the matched name as a column
604                    columnSource = namespace.resolveColumn(columnName);
605                    if (columnSource != null) {
606                        if (DEBUG_RESOLUTION) {
607                            System.out.println("[DEBUG-RESOLVE]   UNNEST implicit column resolved: " +
608                                columnName + " in " + namespace.getDisplayName());
609                        }
610                        return ResolutionResult.exactMatch(columnSource);
611                    }
612                }
613
614                // For other namespaces or if column not found, this is an error
615                return ResolutionResult.notFound(columnName,
616                    "Name '" + columnName + "' resolves to a table, not a column");
617            }
618
619            return ResolutionResult.exactMatch(columnSource);
620        }
621
622        // Multiple unique namespaces - truly ambiguous
623        List<ColumnSource> candidates = new ArrayList<>();
624        // Mantis 4550: set when we synthesize a placeholder candidate for an
625        // ambiguous-star subquery (see below). Such a candidate means the column
626        // is genuinely ambiguous across derived tables and must NOT be collapsed
627        // by GUESS_COLUMN_STRATEGY into a single guessed match.
628        boolean synthesizedAmbStar = false;
629
630        for (ResolvedImpl.Match match : uniqueMatches) {
631            INamespace namespace = match.namespace;
632
633            // Resolve column in this namespace
634            // Phase B3: Support multi-segment paths
635            ColumnSource columnSource;
636            if (!match.remainingNames.isEmpty()) {
637                if (match.remainingNames.size() > 1 && isStructFieldVendor()) {
638                    // Multi-segment: use resolveColumnPath
639                    columnSource = namespace.resolveColumnPath(match.remainingNames);
640                } else {
641                    // Single segment: use resolveColumn
642                    String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1);
643                    columnSource = namespace.resolveColumn(remainingColName);
644                }
645            } else {
646                columnSource = namespace.resolveColumn(columnName);
647            }
648
649            // Mantis 4550: an ambiguous-star subquery (SELECT * over a multi-table
650            // FROM) cannot pin an unqualified column to a single base table and,
651            // under the default NOT_PICKUP strategy, resolveColumn() returns null.
652            // SelectScope now still reports it as an outer-scope candidate. Anchor a
653            // placeholder ColumnSource to the subquery so it counts as a distinct
654            // candidate (getSourceNamespace().getSourceTable() -> the derived table).
655            // finalTable is left null; candidateTables reconciliation expands the
656            // derived table into its base tables. The placeholder is NOT cached as an
657            // inferred column on the namespace, so resolveColumn() itself is unchanged.
658            // Only single-segment column references qualify (no deep struct path);
659            // qualified single-table refs never reach this multi-namespace branch
660            // (they resolve via the uniqueMatches.size()==1 path above), so this
661            // cannot make "T0_2.col" falsely resolve.
662            boolean singleSegment = match.remainingNames.isEmpty()
663                    || match.remainingNames.size() == 1;
664            if (columnSource == null
665                    && singleSegment
666                    && namespace instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace
667                    && ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) namespace).hasAmbiguousStar()) {
668                String placeholderName = match.remainingNames.isEmpty()
669                        ? columnName
670                        : match.remainingNames.get(match.remainingNames.size() - 1);
671                columnSource = new ColumnSource(namespace, placeholderName, null, 0.3,
672                        "ambiguous_star_outer_candidate");
673                synthesizedAmbStar = true;
674            }
675
676            if (columnSource != null) {
677                candidates.add(columnSource);
678            }
679        }
680
681        if (candidates.isEmpty()) {
682            // Resolved to tables, but none have the column
683            return ResolutionResult.notFound(columnName,
684                "Column '" + columnName + "' not found in any of " + resolved.getCount() + " tables");
685        }
686
687        if (candidates.size() == 1) {
688            // Only one table actually has the column
689            return ResolutionResult.exactMatch(candidates.get(0));
690        }
691
692        // Mantis 4550: when a placeholder ambiguous-star candidate was synthesized,
693        // the column is genuinely ambiguous across the derived tables. Force an
694        // AMBIGUOUS result regardless of GUESS_COLUMN_STRATEGY (nearest/farthest),
695        // which would otherwise collapse a definite sibling + maybe-star sibling
696        // back into a single EXACT match.
697        if (synthesizedAmbStar) {
698            sortCandidatesByTablePosition(candidates);
699            AmbiguousColumnSource ambiguousResult = new AmbiguousColumnSource(columnName, candidates);
700            return ResolutionResult.ambiguous(ambiguousResult);
701        }
702
703        // Sort candidates by their table's position in the SQL text (FROM clause order)
704        // This ensures consistent ordering for both ambiguous results and GUESS_COLUMN_STRATEGY
705        sortCandidatesByTablePosition(candidates);
706
707        // Check if all candidates are "inferred" (from tables without DDL metadata)
708        // If so, return AMBIGUOUS regardless of GUESS_COLUMN_STRATEGY
709        // Only apply GUESS_COLUMN_STRATEGY when we have definite knowledge about the columns
710        //
711        // The determination uses configurable thresholds:
712        // - minDefiniteConfidence: minimum confidence to be considered "definite" (default 0.9)
713        // - allowGuessWhenAllInferred: if true, allow guessing even when all candidates are inferred
714        boolean hasDefiniteCandidate = false;
715        double highestConfidence = 0.0;
716        double minDefiniteConf = config != null ? config.getMinDefiniteConfidence() : 0.9;
717
718        for (ColumnSource candidate : candidates) {
719            // A candidate is "definite" if it has high confidence and is not just inferred from usage
720            String evidence = candidate.getEvidence();
721            double confidence = candidate.getConfidence();
722
723            // Track highest confidence among candidates
724            if (confidence > highestConfidence) {
725                highestConfidence = confidence;
726            }
727
728            // Use structured evidence if available, otherwise check legacy evidence
729            boolean isInferred;
730            if (candidate.getEvidenceDetail() != null) {
731                // Use the structured evidence's own determination
732                isInferred = !candidate.getEvidenceDetail().isHighConfidence() ||
733                             candidate.getEvidenceDetail().isInferred();
734            } else {
735                // Fallback to legacy logic
736                // "inferred_from_usage" means table without DDL metadata - not definite
737                // Low confidence (< minDefiniteConf) means we're guessing - not definite
738                // Null evidence with high confidence is also considered inferred
739                isInferred = (evidence == null || evidence.equals("inferred_from_usage")) ||
740                             confidence < minDefiniteConf;
741            }
742
743            if (!isInferred) {
744                hasDefiniteCandidate = true;
745                break;
746            }
747        }
748
749        // Check if we should allow guessing when all candidates are inferred
750        boolean allowGuessInferred = config != null && config.isAllowGuessWhenAllInferred();
751
752        if (!hasDefiniteCandidate && !allowGuessInferred) {
753            // All candidates are from tables without DDL metadata (all inferred)
754            // Don't guess - return as ambiguous so formatter can handle appropriately
755            if (DEBUG_RESOLUTION) {
756                System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (all inferred): " + columnName + " with " + candidates.size() + " candidates");
757                for (ColumnSource c : candidates) {
758                    System.out.println("[DEBUG-RESOLVE]     - " + (c.getSourceNamespace() != null ? c.getSourceNamespace().getDisplayName() : "null") +
759                        " evidence=" + c.getEvidence() + " confidence=" + c.getConfidence());
760                }
761            }
762            AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
763            return ResolutionResult.ambiguous(ambiguous);
764        }
765
766        // Additional check: even if we have definite candidates or allow inferred guessing,
767        // require at least one candidate to meet the minConfidenceToGuess threshold
768        double minConfToGuess = config != null ? config.getMinConfidenceToGuess() : 0.95;
769        if (highestConfidence < minConfToGuess && !allowGuessInferred) {
770            // No candidate has sufficient confidence for guessing
771            if (DEBUG_RESOLUTION) {
772                System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (confidence too low): " + columnName +
773                    " highest=" + highestConfidence + " required=" + minConfToGuess);
774            }
775            AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
776            return ResolutionResult.ambiguous(ambiguous);
777        }
778
779        // Multiple tables have the column - apply GUESS_COLUMN_STRATEGY
780        // Candidates are already sorted by table position (done earlier)
781        int strategy = getGuessColumnStrategy();
782
783        if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_NEAREST) {
784            // Pick the first candidate (nearest table in FROM clause order)
785            return ResolutionResult.exactMatch(candidates.get(0));
786        } else if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_FARTHEST) {
787            // Pick the last candidate (farthest table in FROM clause order)
788            return ResolutionResult.exactMatch(candidates.get(candidates.size() - 1));
789        }
790
791        // GUESS_COLUMN_STRATEGY_NOT_PICKUP: leave as ambiguous
792        if (DEBUG_RESOLUTION) {
793            System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (NOT_PICKUP strategy): " + columnName + " with " + candidates.size() + " candidates");
794        }
795        AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
796        return ResolutionResult.ambiguous(ambiguous);
797    }
798
799    /**
800     * Deduplicate matches by namespace identity.
801     * The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope)
802     * but it's still the same source - not truly ambiguous.
803     *
804     * @param matches All matches from scope resolution
805     * @return List of unique matches by namespace identity
806     */
807    private List<ResolvedImpl.Match> deduplicateMatchesByNamespace(List<ResolvedImpl.Match> matches) {
808        if (matches == null || matches.size() <= 1) {
809            return matches;
810        }
811
812        // Use identity-based set to track unique namespaces
813        java.util.IdentityHashMap<INamespace, ResolvedImpl.Match> uniqueByNamespace =
814            new java.util.IdentityHashMap<>();
815
816        for (ResolvedImpl.Match match : matches) {
817            if (match.namespace != null && !uniqueByNamespace.containsKey(match.namespace)) {
818                uniqueByNamespace.put(match.namespace, match);
819            }
820        }
821
822        return new ArrayList<>(uniqueByNamespace.values());
823    }
824
825    /**
826     * Sort candidates by their source table's position in the SQL text.
827     * This ensures that when GUESS_COLUMN_STRATEGY_NEAREST or FARTHEST is applied,
828     * the candidates are ordered according to their actual position in the FROM clause.
829     *
830     * Uses the table's start token (lineNo, columnNo) to determine position.
831     *
832     * @param candidates List of ColumnSource candidates to sort in place
833     */
834    private void sortCandidatesByTablePosition(List<ColumnSource> candidates) {
835        if (candidates == null || candidates.size() <= 1) {
836            return;
837        }
838
839        candidates.sort(new Comparator<ColumnSource>() {
840            @Override
841            public int compare(ColumnSource c1, ColumnSource c2) {
842                TTable t1 = c1.getFinalTable();
843                TTable t2 = c2.getFinalTable();
844
845                // If either table is null, maintain relative order
846                if (t1 == null && t2 == null) return 0;
847                if (t1 == null) return 1;  // null tables go to the end
848                if (t2 == null) return -1;
849
850                TSourceToken token1 = t1.getStartToken();
851                TSourceToken token2 = t2.getStartToken();
852
853                // If either token is null, maintain relative order
854                if (token1 == null && token2 == null) return 0;
855                if (token1 == null) return 1;
856                if (token2 == null) return -1;
857
858                // Compare by line number first
859                int lineCmp = Long.compare(token1.lineNo, token2.lineNo);
860                if (lineCmp != 0) {
861                    return lineCmp;
862                }
863
864                // If same line, compare by column number
865                return Long.compare(token1.columnNo, token2.columnNo);
866            }
867        });
868    }
869
870    /**
871     * Update TObjectName with the resolution result.
872     * Also registers with ResolutionContext.
873     *
874     * For ambiguous columns (multiple candidate tables), this also populates
875     * TObjectName.candidateTables with all possible source tables.
876     *
877     * IMPORTANT: When resolution fails (notFound) and the column's sourceTable
878     * was set during Phase 1 to an UNNEST table, we clear the sourceTable.
879     * This is because UNNEST tables have a fixed set of columns (implicit column,
880     * offset, struct fields) and should NOT have arbitrary columns inferred.
881     * Clearing sourceTable allows the formatter to treat these as "missed" columns.
882     */
883    private void updateObjectNameWithResult(TObjectName objName, ResolutionResult result) {
884        // Update TObjectName with resolution result
885        objName.setResolution(result);
886
887        // For notFound results, check if Phase 1 incorrectly linked to UNNEST table
888        // UNNEST tables have a fixed column set - don't allow inferred columns
889        if (!result.isExactMatch() && !result.isAmbiguous()) {
890            TTable currentSourceTable = objName.getSourceTable();
891            if (currentSourceTable != null &&
892                currentSourceTable.getTableType() == ETableSource.unnest) {
893                // Clear the incorrectly set sourceTable from Phase 1
894                // This column wasn't found in the UNNEST namespace, so it shouldn't
895                // be attributed to the UNNEST table
896                objName.setSourceTable(null);
897                if (DEBUG_RESOLUTION) {
898                    System.out.println("[DEBUG-RESOLVE]   Cleared incorrect UNNEST sourceTable for: " +
899                        objName + " (not found in UNNEST namespace)");
900                }
901            }
902        }
903
904        // For ambiguous results, populate candidateTables with all candidate tables
905        // IMPORTANT: Clear existing candidateTables first, as Phase 1 (linkColumnToTable)
906        // may have added candidates from incorrect scopes (e.g., MERGE target table for
907        // columns inside USING subquery). Phase 2 (NameResolver) has proper scope awareness
908        // and produces the authoritative candidate list.
909        if (result.isAmbiguous() && result.getAmbiguousSource() != null) {
910            AmbiguousColumnSource ambiguous = result.getAmbiguousSource();
911            // Clear Phase 1 candidates before adding Phase 2's scope-aware candidates
912            objName.getCandidateTables().clear();
913            for (ColumnSource candidate : ambiguous.getCandidates()) {
914                gudusoft.gsqlparser.nodes.TTable candidateTable = candidate.getFinalTable();
915                if (candidateTable != null) {
916                    objName.getCandidateTables().addTable(candidateTable);
917                    continue;
918                }
919                // Mantis 4550: an ambiguous-star subquery placeholder has no single
920                // finalTable. Add its immediate derived table (the subquery) so the
921                // reconcile pass below expands it into the physical base tables that
922                // can actually supply the column (e.g. T0_2 -> A.A2, A.A3).
923                gudusoft.gsqlparser.nodes.TTable derivedTable =
924                    candidate.getSourceNamespace() != null
925                        ? candidate.getSourceNamespace().getSourceTable() : null;
926                if (derivedTable != null) {
927                    objName.getCandidateTables().addTable(derivedTable);
928                    continue;
929                }
930                // A candidate that cannot collapse to a single physical table
931                // (e.g. a UNION-backed derived table) would otherwise be dropped
932                // here. Trace its set-operation branches projection-aware, by the
933                // requested column, so only branches that actually supply the
934                // column contribute base tables (a constant branch like
935                // "SELECT 1 AS a" adds nothing).
936                addBranchBaseTablesForColumn(objName, candidate);
937            }
938        }
939
940        // Mantis 4550: reconcile candidateTables so they point to the physical
941        // base tables that could actually supply the column, rather than the
942        // intermediate derived tables. Phase 1 (linkColumnToTable) may leave
943        // stale candidates that include derived tables which cannot supply the
944        // column (e.g. a subquery with an explicit column list that omits it),
945        // and the EXACT/guessed-match path above does not clear them. See
946        // reconcileCandidateTablesToBaseTables() for the full rationale.
947        reconcileCandidateTablesToBaseTables(objName);
948
949        // Register with context for global querying
950        context.registerResolution(objName, result);
951    }
952
953    /**
954     * Mantis 4550: rewrite {@code candidateTables} so each entry is a physical
955     * base table that could supply the column, expanding any intermediate
956     * derived table (subquery / CTE) into the underlying tables it exposes.
957     *
958     * <p>Background: for an unqualified column whose source is ambiguous across
959     * several derived tables in the FROM clause, Phase 1 ({@code linkColumnToTable})
960     * may populate {@code candidateTables} with the derived tables themselves.
961     * When Phase 2 resolves the column to a guessed EXACT match (rather than a
962     * truly AMBIGUOUS result) the scope-aware ambiguous branch that normally
963     * clears and rebuilds {@code candidateTables} is skipped, leaving those
964     * stale derived-table candidates in place. Some of them cannot even supply
965     * the column (e.g. a {@code SELECT x, ds FROM ...} subquery for column
966     * {@code S311}).</p>
967     *
968     * <p>This pass walks each candidate: physical tables are kept as-is, while
969     * a derived table is replaced by the set of physical base tables reachable
970     * through its projection that could supply the column. A derived table whose
971     * closed projection does not expose the column contributes nothing. Results
972     * are de-duplicated by qualified table name, preserving first-seen order, so
973     * the same physical table reached via two derived paths (or two aliases of a
974     * self-join) collapses to a single candidate.</p>
975     */
976    private void reconcileCandidateTablesToBaseTables(TObjectName objName) {
977        TTableList candidates = objName.getCandidateTables();
978        if (candidates == null || candidates.size() == 0) {
979            return;
980        }
981
982        String columnName = objName.getColumnNameOnly();
983        if (columnName == null || columnName.isEmpty()) {
984            return;
985        }
986
987        List<TTable> expanded = new ArrayList<TTable>();
988        Set<String> seenKeys = new LinkedHashSet<String>();
989        IdentityHashMap<TSelectSqlStatement, Boolean> visiting =
990            new IdentityHashMap<TSelectSqlStatement, Boolean>();
991        for (int i = 0; i < candidates.size(); i++) {
992            collectBaseTablesSupplyingColumn(candidates.getTable(i), columnName,
993                expanded, seenKeys, visiting);
994        }
995
996        // Only replace when expansion produced concrete base tables; otherwise
997        // keep the existing list rather than wiping resolution information.
998        if (expanded.isEmpty()) {
999            return;
1000        }
1001
1002        candidates.clear();
1003        for (TTable t : expanded) {
1004            candidates.addTable(t);
1005        }
1006    }
1007
1008    /**
1009     * Add, to {@code objName}'s candidate tables, the base tables that supply the
1010     * column for an ambiguous candidate whose {@link ColumnSource#getFinalTable()}
1011     * is {@code null} (typically a set-operation/UNION-backed namespace). The
1012     * namespace's SELECT statement is traced projection-aware by the requested
1013     * column, so a branch whose projection at that position is a constant or
1014     * expression contributes no base table. Falls back to every reachable final
1015     * table only when projection-aware tracing yields nothing.
1016     */
1017    private void addBranchBaseTablesForColumn(TObjectName objName, ColumnSource candidate) {
1018        INamespace ns = candidate.getSourceNamespace();
1019        if (ns == null) {
1020            return;
1021        }
1022
1023        List<TTable> base = new ArrayList<TTable>();
1024        Set<String> seenKeys = new LinkedHashSet<String>();
1025        IdentityHashMap<TSelectSqlStatement, Boolean> visiting =
1026            new IdentityHashMap<TSelectSqlStatement, Boolean>();
1027
1028        String columnName = objName.getColumnNameOnly();
1029        TSelectSqlStatement nsSelect = ns.getSelectStatement();
1030        boolean traced = false;
1031        if (nsSelect != null && columnName != null && !columnName.isEmpty()) {
1032            collectFromSelectBody(nsSelect, columnName, -1, base, seenKeys, visiting);
1033            traced = true;
1034        }
1035
1036        if (!traced) {
1037            // We could not trace the namespace projection-aware (no SELECT body to
1038            // inspect): report every reachable final table rather than dropping
1039            // the candidate entirely. When tracing DID run, an empty result means
1040            // "no physical table supplies this column" (e.g. all branches project
1041            // a constant), so we must not fall back and re-add those tables.
1042            List<TTable> all = ns.getAllFinalTables();
1043            if (all != null) {
1044                for (TTable t : all) {
1045                    if (t != null) {
1046                        base.add(t);
1047                    }
1048                }
1049            }
1050        }
1051
1052        for (TTable t : base) {
1053            objName.getCandidateTables().addTable(t);
1054        }
1055    }
1056
1057    /**
1058     * Collect, into {@code out}, the physical base tables reachable from
1059     * {@code table} that could supply {@code columnName}. Derived tables
1060     * (subqueries / CTEs) are expanded through their projection; physical
1061     * tables are added directly. De-duplicated by qualified table name via
1062     * {@code seenKeys}. {@code visiting} guards against cyclic CTE/subquery
1063     * references.
1064     */
1065    private void collectBaseTablesSupplyingColumn(TTable table, String columnName,
1066            List<TTable> out, Set<String> seenKeys,
1067            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1068        if (table == null) {
1069            return;
1070        }
1071
1072        TSelectSqlStatement body = getDerivedBody(table);
1073        if (body == null) {
1074            // Physical base table (or a reference we cannot trace further).
1075            addBaseTable(table, out, seenKeys);
1076            return;
1077        }
1078
1079        if (visiting.containsKey(body)) {
1080            return;
1081        }
1082        visiting.put(body, Boolean.TRUE);
1083        try {
1084            // A CTE with an explicit column list (e.g. WITH cte(a) AS (SELECT x
1085            // FROM t)) renames its outputs, so the requested name must be mapped
1086            // positionally onto the body's projection rather than matched by the
1087            // body's own display names.
1088            int forcedIndex = -1;
1089            gudusoft.gsqlparser.nodes.TObjectNameList cteColumns =
1090                (table.isCTEName() && table.getCTE() != null) ? table.getCTE().getColumnList() : null;
1091            if (cteColumns != null && cteColumns.size() > 0) {
1092                forcedIndex = indexOfMatchingName(cteColumns, columnName);
1093                if (forcedIndex < 0) {
1094                    // The CTE's column list does not expose this name.
1095                    return;
1096                }
1097            }
1098
1099            collectFromSelectBody(body, columnName, forcedIndex, out, seenKeys, visiting);
1100        } finally {
1101            visiting.remove(body);
1102        }
1103    }
1104
1105    /**
1106     * Collect base tables from a derived table's SELECT body. Handles set
1107     * operations (UNION / EXCEPT / INTERSECT) by flattening to leaf SELECTs and
1108     * mapping the column onto each leaf positionally, and plain SELECTs via
1109     * {@link #collectFromSimpleSelect}.
1110     *
1111     * @param forcedIndex when {@code >= 0}, the column is identified by position
1112     *     (e.g. a CTE explicit column list); when {@code -1}, by output name.
1113     */
1114    private void collectFromSelectBody(TSelectSqlStatement body, String columnName,
1115            int forcedIndex, List<TTable> out, Set<String> seenKeys,
1116            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1117        if (body == null) {
1118            return;
1119        }
1120
1121        if (body.isCombinedQuery()) {
1122            List<TSelectSqlStatement> leaves = new ArrayList<TSelectSqlStatement>();
1123            flattenSetOperationLeaves(body, leaves);
1124            if (leaves.isEmpty()) {
1125                return;
1126            }
1127            // All set operands share one output shape by position. Determine the
1128            // position from the first leaf (or the forced/CTE index) and trace
1129            // that ordinal in every operand.
1130            int idx = forcedIndex;
1131            if (idx < 0) {
1132                idx = indexOfNamedProjection(leaves.get(0).getResultColumnList(), columnName);
1133            }
1134            if (idx < 0) {
1135                // The output ordinal is unknown (e.g. the leftmost operand is
1136                // SELECT *), so the column cannot be mapped onto each operand by
1137                // position. Matching later operands by name would silently drop a
1138                // renamed branch, so over-report every FROM table of every operand
1139                // instead.
1140                for (TSelectSqlStatement leaf : leaves) {
1141                    expandAllFromTables(leaf, columnName, out, seenKeys, visiting);
1142                }
1143                return;
1144            }
1145            for (TSelectSqlStatement leaf : leaves) {
1146                collectFromSimpleSelect(leaf, columnName, idx, out, seenKeys, visiting);
1147            }
1148            return;
1149        }
1150
1151        collectFromSimpleSelect(body, columnName, forcedIndex, out, seenKeys, visiting);
1152    }
1153
1154    /**
1155     * Collect base tables from a single (non-combined) SELECT.
1156     *
1157     * <p>When {@code forcedIndex >= 0} the column is resolved by position onto
1158     * the projection (used for CTE column lists and set-operation operands);
1159     * otherwise it is matched by output name. A {@code SELECT *} or an
1160     * out-of-range/star position falls back to expanding every FROM table, since
1161     * any of them may supply the column.</p>
1162     */
1163    private void collectFromSimpleSelect(TSelectSqlStatement select, String columnName,
1164            int forcedIndex, List<TTable> out, Set<String> seenKeys,
1165            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1166        if (select == null) {
1167            return;
1168        }
1169        TResultColumnList rcl = select.getResultColumnList();
1170        if (rcl == null) {
1171            return;
1172        }
1173
1174        if (forcedIndex >= 0) {
1175            boolean hasStar = false;
1176            for (int i = 0; i < rcl.size(); i++) {
1177                if (starQualifier(rcl.getResultColumn(i)) != null) {
1178                    hasStar = true;
1179                    break;
1180                }
1181            }
1182            if (hasStar || forcedIndex >= rcl.size()) {
1183                // Positional mapping is unreliable: any FROM table may supply it.
1184                expandAllFromTables(select, columnName, out, seenKeys, visiting);
1185                return;
1186            }
1187            TResultColumn rc = rcl.getResultColumn(forcedIndex);
1188            TObjectName underlying = rc != null ? rc.getColumnFullname() : null;
1189            if (underlying != null) {
1190                traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting);
1191            }
1192            // A computed projection at this position has no physical base table.
1193            return;
1194        }
1195
1196        // Match by output name. Collect base tables from EVERY projection that
1197        // could expose the column: each matching named projection (there may be
1198        // several with the same output name, e.g. "SELECT t1.c, t2.c") AND every
1199        // star projection. We do not early-return on the first match, so
1200        // genuinely ambiguous derived tables surface all of their suppliers.
1201        boolean unqualifiedStar = false;
1202        List<String> qualifiedStarPrefixes = new ArrayList<String>();
1203        for (int i = 0; i < rcl.size(); i++) {
1204            TResultColumn rc = rcl.getResultColumn(i);
1205            if (rc == null) {
1206                continue;
1207            }
1208            String starPrefix = starQualifier(rc);
1209            if (starPrefix != null) {
1210                if (starPrefix.isEmpty()) {
1211                    unqualifiedStar = true;
1212                } else {
1213                    qualifiedStarPrefixes.add(starPrefix);
1214                }
1215                continue;
1216            }
1217
1218            String outName = rc.getDisplayName();
1219            if (outName != null && nameMatcher.matches(outName, columnName)) {
1220                TObjectName underlying = rc.getColumnFullname();
1221                if (underlying != null) {
1222                    traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting);
1223                }
1224                // A computed/expression column has no physical base table.
1225            }
1226        }
1227
1228        if (unqualifiedStar) {
1229            expandAllFromTables(select, columnName, out, seenKeys, visiting);
1230        }
1231        for (String prefix : qualifiedStarPrefixes) {
1232            TTable matched = findTableByAliasOrName(select, prefix);
1233            if (matched != null) {
1234                collectBaseTablesSupplyingColumn(matched, columnName, out, seenKeys, visiting);
1235            }
1236        }
1237        // No star and no named match: this SELECT cannot supply the column.
1238    }
1239
1240    /**
1241     * Expand every table in {@code select}'s FROM clause as a possible supplier
1242     * of {@code columnName}.
1243     */
1244    private void expandAllFromTables(TSelectSqlStatement select, String columnName,
1245            List<TTable> out, Set<String> seenKeys,
1246            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1247        if (select == null || select.tables == null) {
1248            return;
1249        }
1250        for (int i = 0; i < select.tables.size(); i++) {
1251            collectBaseTablesSupplyingColumn(select.tables.getTable(i),
1252                columnName, out, seenKeys, visiting);
1253        }
1254    }
1255
1256    /**
1257     * Iteratively flatten a set-operation tree into the leaf SELECTs that
1258     * actually supply output column values, left to right. Uses an explicit
1259     * stack to avoid StackOverflowError on deeply nested set-operation chains.
1260     *
1261     * <p>The operator matters: UNION and INTERSECT take values from both
1262     * operands, but EXCEPT / MINUS take output values from the LEFT operand only
1263     * (the right operand merely filters rows), so its right branch is not a
1264     * source of the selected column and is skipped.</p>
1265     */
1266    private void flattenSetOperationLeaves(TSelectSqlStatement stmt, List<TSelectSqlStatement> leaves) {
1267        java.util.Deque<TSelectSqlStatement> stack = new java.util.ArrayDeque<TSelectSqlStatement>();
1268        stack.push(stmt);
1269        while (!stack.isEmpty()) {
1270            TSelectSqlStatement cur = stack.pop();
1271            if (cur == null) {
1272                continue;
1273            }
1274            if (cur.isCombinedQuery()) {
1275                gudusoft.gsqlparser.ESetOperatorType op = cur.getSetOperatorType();
1276                boolean rightSuppliesValues =
1277                    op != gudusoft.gsqlparser.ESetOperatorType.except
1278                    && op != gudusoft.gsqlparser.ESetOperatorType.minus;
1279                // Push right first (when it supplies values) so the left operand
1280                // is processed first.
1281                if (rightSuppliesValues && cur.getRightStmt() != null) {
1282                    stack.push(cur.getRightStmt());
1283                }
1284                if (cur.getLeftStmt() != null) {
1285                    stack.push(cur.getLeftStmt());
1286                }
1287            } else {
1288                leaves.add(cur);
1289            }
1290        }
1291    }
1292
1293    /**
1294     * Index of the first projection in {@code rcl} whose output name matches
1295     * {@code columnName}, or {@code -1} if none matches or a star projection
1296     * makes positional indexing unreliable.
1297     */
1298    private int indexOfNamedProjection(TResultColumnList rcl, String columnName) {
1299        if (rcl == null) {
1300            return -1;
1301        }
1302        for (int i = 0; i < rcl.size(); i++) {
1303            TResultColumn rc = rcl.getResultColumn(i);
1304            if (rc == null) {
1305                continue;
1306            }
1307            if (starQualifier(rc) != null) {
1308                // A star shifts subsequent positions unpredictably.
1309                return -1;
1310            }
1311            String name = rc.getDisplayName();
1312            if (name != null && nameMatcher.matches(name, columnName)) {
1313                return i;
1314            }
1315        }
1316        return -1;
1317    }
1318
1319    /**
1320     * Index of the first name in {@code names} that matches {@code columnName}.
1321     */
1322    private int indexOfMatchingName(gudusoft.gsqlparser.nodes.TObjectNameList names, String columnName) {
1323        if (names == null) {
1324            return -1;
1325        }
1326        for (int i = 0; i < names.size(); i++) {
1327            TObjectName name = names.getObjectName(i);
1328            String n = name != null ? name.getColumnNameOnly() : null;
1329            if (n != null && nameMatcher.matches(n, columnName)) {
1330                return i;
1331            }
1332        }
1333        return -1;
1334    }
1335
1336    /**
1337     * Trace a named projection column (e.g. {@code t.col} or {@code col}) into
1338     * the FROM clause of {@code body} and collect the base tables that supply
1339     * the referenced column.
1340     */
1341    private void traceNamedColumnIntoFrom(TSelectSqlStatement body, TObjectName underlying,
1342            List<TTable> out, Set<String> seenKeys,
1343            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1344        if (body == null || body.tables == null || underlying == null) {
1345            return;
1346        }
1347        String underlyingName = underlying.getColumnNameOnly();
1348        if (underlyingName == null || underlyingName.isEmpty()) {
1349            return;
1350        }
1351        String tableQualifier = columnTableQualifier(underlying);
1352        if (tableQualifier != null && !tableQualifier.isEmpty()) {
1353            TTable matched = findTableByAliasOrName(body, tableQualifier);
1354            if (matched != null) {
1355                collectBaseTablesSupplyingColumn(matched, underlyingName, out,
1356                    seenKeys, visiting);
1357                return;
1358            }
1359        }
1360
1361        // If resolver2 already resolved this inner projection column to a single
1362        // source (e.g. via DDL/sqlenv metadata), trust that source rather than
1363        // broadening to every FROM table. This keeps candidateTables accurate
1364        // for "SELECT c FROM t1 JOIN t2" when only t1 actually exposes c.
1365        TTable resolvedSource = underlying.getSourceTable();
1366        if (resolvedSource != null && isInFromClause(body, resolvedSource)) {
1367            collectBaseTablesSupplyingColumn(resolvedSource, underlyingName, out,
1368                seenKeys, visiting);
1369            return;
1370        }
1371
1372        // Unqualified and unresolved: the underlying column may originate from
1373        // any FROM table.
1374        for (int i = 0; i < body.tables.size(); i++) {
1375            collectBaseTablesSupplyingColumn(body.tables.getTable(i),
1376                underlyingName, out, seenKeys, visiting);
1377        }
1378    }
1379
1380    /**
1381     * Whether {@code target} is one of the tables in {@code body}'s FROM clause
1382     * (by object identity). Used to ensure a resolved {@code sourceTable} really
1383     * belongs to this derived table's scope before trusting it.
1384     */
1385    private boolean isInFromClause(TSelectSqlStatement body, TTable target) {
1386        if (body == null || body.tables == null || target == null) {
1387            return false;
1388        }
1389        for (int i = 0; i < body.tables.size(); i++) {
1390            if (body.tables.getTable(i) == target) {
1391                return true;
1392            }
1393        }
1394        return false;
1395    }
1396
1397    /**
1398     * Return the SELECT body of a derived table (inline subquery or CTE
1399     * reference), or {@code null} if {@code table} is a physical table.
1400     */
1401    private TSelectSqlStatement getDerivedBody(TTable table) {
1402        if (table == null) {
1403            return null;
1404        }
1405        if (table.getSubquery() != null) {
1406            return table.getSubquery();
1407        }
1408        if (table.isCTEName() && table.getCTE() != null) {
1409            return table.getCTE().getSubquery();
1410        }
1411        return null;
1412    }
1413
1414    /**
1415     * Add a physical base table to {@code out}, de-duplicated by qualified name.
1416     */
1417    private void addBaseTable(TTable table, List<TTable> out, Set<String> seenKeys) {
1418        if (table == null) {
1419            return;
1420        }
1421        String key = baseTableKey(table);
1422        if (seenKeys.add(key)) {
1423            out.add(table);
1424        }
1425    }
1426
1427    private String baseTableKey(TTable table) {
1428        String name = table.getFullName();
1429        if (name == null || name.isEmpty()) {
1430            name = table.getName();
1431        }
1432        if (name == null) {
1433            name = table.toString();
1434        }
1435        return name == null ? "" : name.toLowerCase();
1436    }
1437
1438    /**
1439     * If {@code rc} is a star result column, return its qualifier: an empty
1440     * string for an unqualified {@code *}, or the table prefix for {@code q.*}.
1441     * Returns {@code null} when {@code rc} is not a star column.
1442     */
1443    private String starQualifier(TResultColumn rc) {
1444        if (rc == null || rc.getExpr() == null) {
1445            return null;
1446        }
1447        TObjectName operand = rc.getExpr().getObjectOperand();
1448        String text = operand != null ? operand.toString() : rc.toString();
1449        if (text == null) {
1450            return null;
1451        }
1452        text = text.trim();
1453        if ("*".equals(text)) {
1454            return "";
1455        }
1456        if (text.endsWith(".*")) {
1457            String prefix = text.substring(0, text.length() - 2).trim();
1458            // Keep only the last segment of a multi-part prefix (e.g. a.b.* -> b).
1459            int dot = prefix.lastIndexOf('.');
1460            if (dot >= 0) {
1461                prefix = prefix.substring(dot + 1).trim();
1462            }
1463            return prefix;
1464        }
1465        return null;
1466    }
1467
1468    /**
1469     * Return the table qualifier of a (possibly) qualified column reference,
1470     * e.g. {@code "t"} for {@code t.col} or {@code "t"} for {@code s.t.col}.
1471     * Returns {@code null} for an unqualified column.
1472     */
1473    private String columnTableQualifier(TObjectName column) {
1474        if (column == null) {
1475            return null;
1476        }
1477        String full = column.toString();
1478        if (full == null) {
1479            return null;
1480        }
1481        int lastDot = full.lastIndexOf('.');
1482        if (lastDot < 0) {
1483            return null;
1484        }
1485        String prefix = full.substring(0, lastDot).trim();
1486        int prevDot = prefix.lastIndexOf('.');
1487        if (prevDot >= 0) {
1488            prefix = prefix.substring(prevDot + 1).trim();
1489        }
1490        return prefix.isEmpty() ? null : prefix;
1491    }
1492
1493    /**
1494     * Find a table in {@code body}'s FROM clause by alias or (last segment of)
1495     * name, matching case-insensitively.
1496     */
1497    private TTable findTableByAliasOrName(TSelectSqlStatement body, String qualifier) {
1498        if (body == null || body.tables == null || qualifier == null) {
1499            return null;
1500        }
1501        for (int i = 0; i < body.tables.size(); i++) {
1502            TTable t = body.tables.getTable(i);
1503            if (t == null) {
1504                continue;
1505            }
1506            String alias = t.getAliasName();
1507            if (alias != null && tableNameMatches(alias, qualifier)) {
1508                return t;
1509            }
1510            String name = t.getName();
1511            if (name != null) {
1512                int dot = name.lastIndexOf('.');
1513                String shortName = dot >= 0 ? name.substring(dot + 1) : name;
1514                if (tableNameMatches(shortName, qualifier)) {
1515                    return t;
1516                }
1517            }
1518        }
1519        return null;
1520    }
1521
1522    /**
1523     * Table-name/alias equality routed through the unified façade; falls back to
1524     * case-insensitive comparison when no vendor is configured (synthetic scopes).
1525     */
1526    private boolean tableNameMatches(String a, String b) {
1527        if (a == null || b == null) {
1528            return false;
1529        }
1530        EDbVendor vendor = config != null ? config.getVendor() : null;
1531        return vendor == null ? a.equalsIgnoreCase(b)
1532                : SQLUtil.sameName(vendor, ESQLDataObjectType.dotTable, a, b);
1533    }
1534
1535    /**
1536     * Resolve a column within a specific namespace (for direct lookups).
1537     */
1538    public ResolutionResult resolveInNamespace(String columnName, INamespace namespace) {
1539        if (columnName == null || namespace == null) {
1540            return ResolutionResult.notFound("<null>");
1541        }
1542
1543        ColumnSource source = namespace.resolveColumn(columnName);
1544        if (source != null) {
1545            return ResolutionResult.exactMatch(source);
1546        }
1547
1548        return ResolutionResult.notFound(columnName);
1549    }
1550
1551    /**
1552     * Find all namespaces that contain a given column.
1553     * Used for implementing full candidate collection in ambiguous scenarios.
1554     */
1555    public List<INamespace> findNamespacesWithColumn(String columnName, IScope scope) {
1556        List<INamespace> result = new ArrayList<>();
1557
1558        for (INamespace ns : scope.getVisibleNamespaces()) {
1559            if (ns.hasColumn(columnName) == ColumnLevel.EXISTS) {
1560                result.add(ns);
1561            }
1562        }
1563
1564        return result;
1565    }
1566
1567    public INameMatcher getNameMatcher() {
1568        return nameMatcher;
1569    }
1570
1571    public ResolutionContext getContext() {
1572        return context;
1573    }
1574}