AnalysisModel.java

package org.egothor.methodatlas.gui.model;

import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Central observable model for the MethodAtlas GUI.
 *
 * <p>All mutating methods must be called on the Swing Event Dispatch Thread
 * (EDT).  {@code AnalysisService} (in {@code methodatlas-gui})
 * publishes every update through
 * {@link javax.swing.SwingWorker#process SwingWorker.process}, which
 * already runs on the EDT, so callers outside the EDT must use
 * {@link javax.swing.SwingUtilities#invokeLater invokeLater}.
 * Observers attach via {@link #addPropertyChangeListener}.</p>
 *
 * <p>Read accessors must also be called on the EDT: the model is backed by
 * non-thread-safe collections and holds no internal synchronisation, so it is
 * not safe to read from a background thread while the EDT mutates it.</p>
 *
 * <h2>Fired property names</h2>
 * <ul>
 *   <li>{@code "entries"} — one or more {@link MethodEntry} objects were
 *       added or had their AI suggestion updated; the new value is the
 *       affected {@code MethodEntry}</li>
 *   <li>{@code "status"} — the analysis {@link Status} changed; old and
 *       new values are the before/after {@code Status} constants</li>
 *   <li>{@code "statusMessage"} — the human-readable status text changed;
 *       the new value is the updated {@code String}</li>
 *   <li>{@code "progress"} — AI processing advanced; the old value is the
 *       previous {@link #getProgressCurrent()} and the new value is the
 *       current one</li>
 *   <li>{@code "currentAiClass"} — the FQCN of the class currently being
 *       sent to the AI engine changed; the new value is the FQCN
 *       {@code String}, or an empty string when no class is in flight</li>
 *   <li>{@code "aiClassDone"} — one class has finished AI enrichment;
 *       the new value is the completed {@link AiClassResult}</li>
 *   <li>{@code "selectedEntry"} — the user selected a different method;
 *       old and new values are the before/after {@link MethodEntry}
 *       instances (either may be {@code null})</li>
 *   <li>{@code "cleared"} — all entries were removed because a new scan
 *       was started; old value is {@code false}, new value is {@code true}</li>
 * </ul>
 *
 */
public final class AnalysisModel {

    // ── Fields ────────────────────────────────────────────────────────────

    /** Maximum number of AI class results retained in the recent-results log. */
    private static final int MAX_AI_RESULTS = 50;

    private final PropertyChangeSupport pcs = new PropertyChangeSupport(this);

    /** Preserves insertion order so classes appear in discovery order. */
    private final Map<String, List<MethodEntry>> methodsByClass = new LinkedHashMap<>();

    private Status status = Status.IDLE;
    private String statusMessage = "Ready";
    private int progressCurrent;
    private int progressTotal;
    private String currentAiClass = "";
    private final List<AiClassResult> recentAiResults = new ArrayList<>();
    private MethodEntry selectedEntry;

    /**
     * High-level lifecycle state of the background analysis.
     *
     * <p>States transition in a single direction during one analysis run:
     * {@code IDLE} → {@code SCANNING} → {@code AI_RUNNING} → {@code DONE}
     * (or {@code ERROR} on failure, or back to {@code IDLE} on
     * cancellation).</p>
     */
    public enum Status {

        /**
         * No analysis is in progress.
         *
         * <p>This is the initial state.  It is also set when an in-progress
         * analysis is cancelled by the user.</p>
         */
        IDLE,

        /**
         * File-system traversal and test-method discovery are in progress.
         *
         * <p>Methods are published to the model as they are found so that the
         * results tree populates incrementally without waiting for AI.</p>
         */
        SCANNING,

        /**
         * Discovery is complete; the AI engine is enriching methods class by class.
         *
         * <p>Progress is tracked by {@link #getProgressCurrent()} and
         * {@link #getProgressTotal()}.  The FQCN of the class currently being
         * processed is available from {@link #getCurrentAiClass()}.</p>
         */
        AI_RUNNING,

        /**
         * All phases have completed normally.
         *
         * <p>The model is now fully populated.  A subsequent call to
         * {@link #clear()} resets the model and returns it to
         * {@link #IDLE}.</p>
         */
        DONE,

        /**
         * The analysis terminated abnormally.
         *
         * <p>A partial result set may be present in the model.  The
         * human-readable error description is available from
         * {@link #getStatusMessage()}.</p>
         */
        ERROR
    }

    /**
     * Immutable result of AI enrichment for a single class.
     *
     * @param fqcn        fully-qualified name of the class that was processed
     * @param methodCount number of test methods the class contains
     * @param durationMs  wall-clock time elapsed during the AI call, in
     *                    milliseconds; {@code 0} when no AI call was made
     *                    (for example, because the source was unavailable)
     * @param hadError    {@code true} if the AI call failed with an
     *                    {@link org.egothor.methodatlas.ai.AiSuggestionException}
     */
    public record AiClassResult(String fqcn, int methodCount, long durationMs, boolean hadError) {}

    // ── Observer wiring ───────────────────────────────────────────────────

    /**
     * Registers a listener that is notified whenever any model property changes.
     *
     * @param l listener to register; must not be {@code null}
     * @see #addPropertyChangeListener(String, PropertyChangeListener)
     * @see #removePropertyChangeListener(PropertyChangeListener)
     */
    public void addPropertyChangeListener(PropertyChangeListener l) {
        pcs.addPropertyChangeListener(l);
    }

    /**
     * Registers a listener that is notified only when the named property changes.
     *
     * @param prop property name as documented in the class-level Javadoc;
     *             must not be {@code null}
     * @param l    listener to register; must not be {@code null}
     * @see #addPropertyChangeListener(PropertyChangeListener)
     */
    public void addPropertyChangeListener(String prop, PropertyChangeListener l) {
        pcs.addPropertyChangeListener(prop, l);
    }

    /**
     * Removes a previously registered listener.
     *
     * <p>If {@code l} was registered for a specific property, this method
     * removes only the global registration.  Use
     * {@link java.beans.PropertyChangeSupport#removePropertyChangeListener(String, PropertyChangeListener)}
     * directly for named-property listeners.</p>
     *
     * @param l listener to remove; ignored if {@code null} or not registered
     */
    public void removePropertyChangeListener(PropertyChangeListener l) {
        pcs.removePropertyChangeListener(l);
    }

    // ── Mutation ──────────────────────────────────────────────────────────

    /**
     * Removes all method entries and resets all progress counters to their
     * initial values.
     *
     * <p>This method is called at the beginning of each new analysis run.
     * Fires the {@code "cleared"} property change event.</p>
     */
    public void clear() {
        methodsByClass.clear();
        progressCurrent = 0;
        progressTotal = 0;
        currentAiClass = "";
        recentAiResults.clear();
        pcs.firePropertyChange("cleared", false, true);
    }

    /**
     * Appends a newly discovered method entry to the model.
     *
     * <p>Multiple entries for the same class are allowed and are stored in
     * discovery order.  Fires the {@code "entries"} property change event
     * with the new entry as the event's new value.</p>
     *
     * @param entry the discovered method entry to append; must not be
     *              {@code null}
     */
    public void addEntry(MethodEntry entry) {
        String fqcn = entry.discovered().fqcn();
        methodsByClass.computeIfAbsent(fqcn, k -> new ArrayList<>()).add(entry);
        pcs.firePropertyChange("entries", null, entry);
    }

    /**
     * Updates the AI suggestion on an existing entry for the given class and
     * method name.
     *
     * <p>The entry is located by matching both {@code fqcn} and
     * {@code methodName}.  If no matching entry is found, this method does
     * nothing.  Fires the {@code "entries"} property change event with the
     * updated entry as the event's new value.</p>
     *
     * @param fqcn       fully-qualified name of the class that owns the method;
     *                   must not be {@code null}
     * @param methodName simple name of the method to update; must not be
     *                   {@code null}
     * @param suggestion AI-generated suggestion to apply; may be {@code null}
     *                   to clear an existing suggestion
     */
    public void updateSuggestion(String fqcn, String methodName,
            org.egothor.methodatlas.ai.AiMethodSuggestion suggestion) {
        List<MethodEntry> entries = methodsByClass.get(fqcn);
        if (entries == null) { return; }
        for (MethodEntry e : entries) {
            if (e.discovered().method().equals(methodName)) {
                e.setSuggestion(suggestion);
                pcs.firePropertyChange("entries", null, e);
                return;
            }
        }
    }

    /**
     * Sets the high-level lifecycle status of the background analysis.
     *
     * <p>Fires the {@code "status"} property change event with the previous
     * and new {@link Status} values.</p>
     *
     * @param status the new lifecycle status; must not be {@code null}
     */
    public void setStatus(Status status) {
        Status old = this.status;
        this.status = status;
        pcs.firePropertyChange("status", old, status);
    }

    /**
     * Sets the human-readable status message shown in the status bar.
     *
     * <p>Fires the {@code "statusMessage"} property change event.</p>
     *
     * @param message the message text to display; must not be {@code null}
     */
    public void setStatusMessage(String message) {
        String old = this.statusMessage;
        this.statusMessage = message;
        pcs.firePropertyChange("statusMessage", old, message);
    }

    /**
     * Updates the AI enrichment progress counters.
     *
     * <p>Fires the {@code "progress"} property change event with the previous
     * and new values of {@code current}.</p>
     *
     * @param current number of classes for which AI enrichment has been
     *                initiated in this run (1-based; {@code 0} before the
     *                first class starts)
     * @param total   total number of classes that will be sent to the AI
     *                engine in this run; {@code 0} if not yet known
     */
    public void setProgress(int current, int total) {
        int old = this.progressCurrent;
        this.progressCurrent = current;
        this.progressTotal = total;
        pcs.firePropertyChange("progress", old, current);
    }

    /**
     * Sets the fully-qualified name of the class currently being sent to
     * the AI engine.
     *
     * <p>Pass an empty string (or {@code null}, which is normalised to an
     * empty string) when no class is in flight.  Fires the
     * {@code "currentAiClass"} property change event.</p>
     *
     * @param fqcn fully-qualified class name, or {@code null} / empty string
     *             to clear the indicator
     */
    public void setCurrentAiClass(String fqcn) {
        String old = this.currentAiClass;
        this.currentAiClass = fqcn == null ? "" : fqcn;
        pcs.firePropertyChange("currentAiClass", old, this.currentAiClass);
    }

    /**
     * Records the result of AI enrichment for one class and appends it to
     * the recent-results log.
     *
     * <p>The log is capped at fifty entries; the oldest entry is removed
     * when the cap is exceeded.  Fires the {@code "aiClassDone"} property
     * change event with {@code null} as the old value and {@code result}
     * as the new value.</p>
     *
     * @param result result of the completed AI call; must not be {@code null}
     */
    public void addAiClassResult(AiClassResult result) {
        recentAiResults.add(result);
        if (recentAiResults.size() > MAX_AI_RESULTS) { recentAiResults.remove(0); }
        pcs.firePropertyChange("aiClassDone", null, result);
    }

    /**
     * Changes the currently selected method entry.
     *
     * <p>Fires the {@code "selectedEntry"} property change event with the
     * previous and new entries (either may be {@code null}).</p>
     *
     * @param entry the newly selected entry, or {@code null} to deselect
     */
    public void setSelectedEntry(MethodEntry entry) {
        MethodEntry old = this.selectedEntry;
        this.selectedEntry = entry;
        pcs.firePropertyChange("selectedEntry", old, entry);
    }

    // ── Read access ───────────────────────────────────────────────────────

    /**
     * Returns the current lifecycle status of the background analysis.
     *
     * @return current {@link Status}; never {@code null}
     */
    public Status getStatus() { return status; }

    /**
     * Returns the human-readable status message last set by the analysis
     * service or by direct callers.
     *
     * @return current status message; never {@code null}
     */
    public String getStatusMessage() { return statusMessage; }

    /**
     * Returns the number of classes for which AI enrichment has been
     * initiated in the current run.
     *
     * @return classes started so far (1-based); {@code 0} before the AI
     *         phase begins or after {@link #clear()}
     */
    public int getProgressCurrent() { return progressCurrent; }

    /**
     * Returns the total number of classes that will be sent to the AI engine
     * in the current run.
     *
     * @return total class count; {@code 0} before the AI phase begins or
     *         after {@link #clear()}
     */
    public int getProgressTotal() { return progressTotal; }

    /**
     * Returns the fully-qualified name of the class currently being sent to
     * the AI engine.
     *
     * @return FQCN of the in-flight class, or an empty string when no class
     *         is being processed; never {@code null}
     */
    public String getCurrentAiClass() { return currentAiClass; }

    /**
     * Returns an unmodifiable view of the AI class results recorded so far
     * in the current run, in completion order.
     *
     * <p>The list contains at most fifty entries; older entries are dropped
     * when the cap is exceeded.</p>
     *
     * @return unmodifiable list of {@link AiClassResult} objects; never
     *         {@code null}
     */
    public List<AiClassResult> getRecentAiResults() {
        return Collections.unmodifiableList(recentAiResults);
    }

    /**
     * Returns the currently selected method entry.
     *
     * @return selected entry, or {@code null} when no entry is selected
     */
    public MethodEntry getSelectedEntry() { return selectedEntry; }

    /**
     * Returns {@code true} when at least one method entry has a staged patch
     * that has not yet been written to disk.
     *
     * @return {@code true} if any entry is in {@link MethodEntry.TagStatus#PENDING_SAVE} state
     */
    public boolean hasStagedChanges() {
        return methodsByClass.values().stream()
                .flatMap(Collection::stream)
                .anyMatch(MethodEntry::hasPendingChanges);
    }

    /**
     * Returns all method entries that have a staged patch pending a save.
     *
     * @return unmodifiable list of entries with pending changes, in discovery order
     */
    public List<MethodEntry> getStagedEntries() {
        return methodsByClass.values().stream()
                .flatMap(Collection::stream)
                .filter(MethodEntry::hasPendingChanges)
                .toList();
    }

    /**
     * Notifies all listeners that the given entry has changed without
     * altering any other model state.
     *
     * <p>Used by the GUI to propagate staging and unstaging events so that
     * the results tree and tag editor panel refresh immediately.</p>
     *
     * @param entry the entry that changed; must not be {@code null}
     */
    public void notifyEntryChanged(MethodEntry entry) {
        pcs.firePropertyChange("entries", null, entry);
    }

    /**
     * Returns an unmodifiable snapshot of the class-to-methods map in
     * discovery order.
     *
     * <p>The returned map reflects the state of the model at the time of the
     * call; subsequent changes to the model are not visible through it.</p>
     *
     * @return unmodifiable map from FQCN to the list of method entries for
     *         that class; never {@code null}
     */
    public Map<String, List<MethodEntry>> getMethodsByClass() {
        return Collections.unmodifiableMap(methodsByClass);
    }

    /**
     * Returns the total number of test methods discovered so far across all
     * classes.
     *
     * @return total method count; {@code 0} before discovery starts or after
     *         {@link #clear()}
     */
    public int getTotalMethodCount() {
        return methodsByClass.values().stream().mapToInt(List::size).sum();
    }

    /**
     * Returns the number of distinct classes discovered so far.
     *
     * @return class count; {@code 0} before discovery starts or after
     *         {@link #clear()}
     */
    public int getClassCount() {
        return methodsByClass.size();
    }
}