ImportedRecordingWorkbenchPanel.java

package org.hammer.audio.experimental.acoustic.workbench;

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingWorker;
import javax.swing.filechooser.FileSystemView;
import org.hammer.audio.experimental.acoustic.dataset.DatasetAnalytics;
import org.hammer.audio.experimental.acoustic.dataset.DatasetManifest;
import org.hammer.audio.experimental.acoustic.dataset.DatasetRecording;
import org.hammer.audio.experimental.acoustic.dataset.HumBugDbImporter;
import org.hammer.audio.experimental.acoustic.feature.comparison.FeatureDifference;
import org.hammer.audio.experimental.acoustic.simulation.WingbeatSignalParameters;
import org.hammer.audio.experimental.acoustic.simulation.calibration.CalibrationResult;
import org.hammer.audio.experimental.acoustic.simulation.calibration.GeneratorCalibrationService;
import org.hammer.audio.experimental.acoustic.wingbeat.DatasetWingbeatEvaluationWorkflow;
import org.hammer.audio.experimental.acoustic.wingbeat.RuleBasedWingbeatClassifier;
import org.hammer.audio.experimental.acoustic.wingbeat.WingbeatDataset;
import org.hammer.audio.experimental.acoustic.wingbeat.WingbeatFeatureVector;

/**
 * Small Swing workbench for browsing imported dataset recordings and replaying analysis on them.
 */
@SuppressWarnings("PMD.CouplingBetweenObjects")
public final class ImportedRecordingWorkbenchPanel extends JPanel {

  private static final Logger LOGGER =
      Logger.getLogger(ImportedRecordingWorkbenchPanel.class.getName());
  private static final long serialVersionUID = 1L;

  private final JTextField datasetPathField;
  private final JComboBox<RecordingItem> recordingCombo;
  private final JTextArea manifestArea;
  private final JTextArea analyticsArea;
  private final JTextArea histogramArea;
  private final JTextArea recordingArea;
  private final JTextArea evaluationArea;
  private final JTextArea calibrationArea;
  private final JComboBox<String> calibrationScopeCombo;

  private final transient HumBugDbImporter importer;
  private final transient DatasetWingbeatEvaluationWorkflow workflow;
  private final transient RuleBasedWingbeatClassifier classifier;

  /** Both fields are read and written exclusively on the Swing event dispatch thread. */
  private transient DatasetManifest loadedManifest;

  private transient List<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis> loadedAnalyses =
      List.of();

  /** Suppresses the combo-box action listener during programmatic setup. */
  private transient boolean programmaticUpdate;

  /** Create the imported-recording workbench panel. */
  public ImportedRecordingWorkbenchPanel() {
    this(
        new HumBugDbImporter(),
        new DatasetWingbeatEvaluationWorkflow(),
        new RuleBasedWingbeatClassifier());
  }

  ImportedRecordingWorkbenchPanel(
      HumBugDbImporter importer,
      DatasetWingbeatEvaluationWorkflow workflow,
      RuleBasedWingbeatClassifier classifier) {
    super(new BorderLayout(6, 6));
    this.importer = Objects.requireNonNull(importer, "importer");
    this.workflow = Objects.requireNonNull(workflow, "workflow");
    this.classifier = Objects.requireNonNull(classifier, "classifier");
    setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));

    datasetPathField = new JTextField(42);
    recordingCombo = new JComboBox<>();
    recordingCombo.setEnabled(false);
    recordingCombo.addActionListener(e -> onComboSelectionChanged());

    manifestArea = newTextArea();
    analyticsArea = newTextArea();
    histogramArea = newTextArea();
    recordingArea = newTextArea();
    evaluationArea = newTextArea();
    calibrationArea = newTextArea();
    calibrationScopeCombo =
        new JComboBox<>(new String[] {"All imported recordings", "Selected recording only"});

    add(buildTopPanel(), BorderLayout.NORTH);
    add(buildCenterPanel(), BorderLayout.CENTER);
  }

  private JPanel buildTopPanel() {
    JPanel panel = new JPanel(new BorderLayout(4, 4));
    panel.setBorder(
        BorderFactory.createTitledBorder("Imported Recording Workbench (local-only HumBugDB)"));

    JPanel importRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
    importRow.add(new JLabel("Dataset path:"));
    datasetPathField.setPreferredSize(new Dimension(460, 24));
    importRow.add(datasetPathField);
    JButton browseButton = new JButton("Browse…");
    browseButton.addActionListener(e -> browseForDatasetRoot());
    importRow.add(browseButton);
    JButton importButton = new JButton("Import");
    importButton.addActionListener(e -> importDataset());
    importRow.add(importButton);

    JPanel selectRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
    selectRow.add(new JLabel("Recording:"));
    recordingCombo.setPreferredSize(new Dimension(460, 24));
    selectRow.add(recordingCombo);
    JButton replayButton = new JButton("Replay analysis");
    replayButton.addActionListener(e -> refreshSelectedRecording());
    selectRow.add(replayButton);
    selectRow.add(new JLabel("Calibration scope:"));
    calibrationScopeCombo.setPreferredSize(new Dimension(220, 24));
    selectRow.add(calibrationScopeCombo);
    JButton calibrationButton = new JButton("Run calibration");
    calibrationButton.addActionListener(e -> runCalibration());
    selectRow.add(calibrationButton);

    panel.add(importRow, BorderLayout.NORTH);
    panel.add(selectRow, BorderLayout.SOUTH);
    return panel;
  }

  private JSplitPane buildCenterPanel() {
    JSplitPane split =
        new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, buildLeftPanel(), buildRightPanel());
    split.setResizeWeight(0.35);
    split.setDividerLocation(340);
    return split;
  }

  private JSplitPane buildLeftPanel() {
    JSplitPane upper =
        new JSplitPane(
            JSplitPane.VERTICAL_SPLIT,
            new JScrollPane(manifestArea),
            new JScrollPane(analyticsArea));
    upper.setResizeWeight(0.5);
    upper.setDividerLocation(240);
    JSplitPane split =
        new JSplitPane(JSplitPane.VERTICAL_SPLIT, upper, new JScrollPane(histogramArea));
    split.setResizeWeight(0.67);
    split.setDividerLocation(480);
    return split;
  }

  private JSplitPane buildRightPanel() {
    JSplitPane upper =
        new JSplitPane(
            JSplitPane.VERTICAL_SPLIT,
            new JScrollPane(recordingArea),
            new JScrollPane(evaluationArea));
    upper.setResizeWeight(0.55);
    upper.setDividerLocation(300);
    JSplitPane split =
        new JSplitPane(JSplitPane.VERTICAL_SPLIT, upper, new JScrollPane(calibrationArea));
    split.setResizeWeight(0.7);
    split.setDividerLocation(520);
    return split;
  }

  private static JTextArea newTextArea() {
    JTextArea area = new JTextArea(18, 60);
    area.setEditable(false);
    area.setLineWrap(true);
    area.setWrapStyleWord(true);
    return area;
  }

  private void browseForDatasetRoot() {
    JFileChooser chooser = new JFileChooser(FileSystemView.getFileSystemView());
    chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
    chooser.setDialogTitle("Select local HumBugDB root");
    if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) {
      datasetPathField.setText(chooser.getSelectedFile().toPath().toAbsolutePath().toString());
    }
  }

  private void importDataset() {
    String value = datasetPathField.getText().trim();
    if (value.isBlank()) {
      manifestArea.setText("Enter a local HumBugDB path first.");
      recordingArea.setText("");
      evaluationArea.setText("");
      return;
    }
    Path root = Path.of(value);
    new SwingWorker<ImportResult, Void>() {
      @Override
      protected ImportResult doInBackground() throws IOException {
        DatasetManifest manifest = importer.importFrom(root);
        WingbeatDataset.Evaluation evaluation = null;
        String histogramsReport = "";
        List<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis> analyses = List.of();
        if (!manifest.recordings().isEmpty()) {
          evaluation = workflow.evaluate(manifest, classifier);
          analyses = workflow.analyzeAll(manifest, null);
          histogramsReport =
              DatasetWingbeatEvaluationWorkflow.toHistogramMarkdown(
                  DatasetWingbeatEvaluationWorkflow.computeHistograms(analyses));
        }
        String analyticsReport = DatasetAnalytics.compute(manifest).toMarkdownReport();
        return new ImportResult(manifest, evaluation, analyses, analyticsReport, histogramsReport);
      }

      @Override
      protected void done() {
        try {
          applyImportResult(get());
        } catch (ExecutionException ex) {
          LOGGER.log(Level.WARNING, "Dataset import failed", ex);
          manifestArea.setText("Import failed: " + ex.getCause().getMessage());
          recordingArea.setText("");
          evaluationArea.setText("");
          histogramArea.setText("");
          calibrationArea.setText("");
          recordingCombo.removeAllItems();
          recordingCombo.setEnabled(false);
        } catch (InterruptedException ex) {
          Thread.currentThread().interrupt();
          manifestArea.setText("Import was interrupted.");
        }
      }
    }.execute();
  }

  // PMD.UnusedAssignment: programmaticUpdate = true is read by onComboSelectionChanged() via the
  // action-listener mechanism; PMD cannot follow cross-method field reads within the same class.
  @SuppressWarnings("PMD.UnusedAssignment")
  private void applyImportResult(ImportResult result) {
    loadedManifest = result.manifest();
    loadedAnalyses = result.analyses();
    programmaticUpdate = true;
    recordingCombo.removeAllItems();
    for (DatasetRecording recording : loadedManifest.recordings()) {
      recordingCombo.addItem(new RecordingItem(recording));
    }
    recordingCombo.setEnabled(recordingCombo.getItemCount() > 0);
    programmaticUpdate = false;

    manifestArea.setText(renderManifest(loadedManifest));
    analyticsArea.setText(result.analyticsReport());
    histogramArea.setText(result.histogramsReport());
    if (result.evaluation() != null) {
      evaluationArea.setText(
          DatasetWingbeatEvaluationWorkflow.toMarkdownReport(result.evaluation()));
    } else {
      evaluationArea.setText("No recordings to evaluate.");
    }
    calibrationArea.setText("Select a calibration scope and run calibration.");
    if (recordingCombo.getItemCount() > 0) {
      recordingCombo.setSelectedIndex(0);
    } else {
      recordingArea.setText("No recordings imported.");
      calibrationArea.setText("Calibration unavailable: dataset contains no recordings.");
    }
  }

  /**
   * Load an already-imported manifest directly. Intended for headless tests; performs all I/O
   * synchronously on the calling thread.
   */
  // PMD.UnusedAssignment: see applyImportResult above.
  @SuppressWarnings("PMD.UnusedAssignment")
  void loadManifest(DatasetManifest importedManifest) throws IOException {
    loadedManifest = Objects.requireNonNull(importedManifest, "importedManifest");
    programmaticUpdate = true;
    try {
      recordingCombo.removeAllItems();
      for (DatasetRecording recording : loadedManifest.recordings()) {
        recordingCombo.addItem(new RecordingItem(recording));
      }
      recordingCombo.setEnabled(recordingCombo.getItemCount() > 0);
      manifestArea.setText(renderManifest(loadedManifest));
      analyticsArea.setText(DatasetAnalytics.compute(loadedManifest).toMarkdownReport());
      if (loadedManifest.recordings().isEmpty()) {
        loadedAnalyses = List.of();
        evaluationArea.setText("No recordings to evaluate.");
        histogramArea.setText("No recordings to analyze.");
        calibrationArea.setText("Calibration unavailable: dataset contains no recordings.");
      } else {
        WingbeatDataset.Evaluation evaluation = workflow.evaluate(loadedManifest, classifier);
        evaluationArea.setText(DatasetWingbeatEvaluationWorkflow.toMarkdownReport(evaluation));
        loadedAnalyses = workflow.analyzeAll(loadedManifest, null);
        histogramArea.setText(
            DatasetWingbeatEvaluationWorkflow.toHistogramMarkdown(
                DatasetWingbeatEvaluationWorkflow.computeHistograms(loadedAnalyses)));
        calibrationArea.setText(buildCalibrationReport(loadedManifest, loadedAnalyses, false));
      }
      if (recordingCombo.getItemCount() > 0) {
        recordingCombo.setSelectedIndex(0);
        RecordingItem firstItem = recordingCombo.getItemAt(0);
        DatasetWingbeatEvaluationWorkflow.RecordingAnalysis analysis =
            workflow.analyzeRecording(loadedManifest, firstItem.recording(), classifier);
        recordingArea.setText(renderRecordingAnalysis(analysis));
      } else {
        recordingArea.setText("No recordings imported.");
      }
    } finally {
      programmaticUpdate = false;
    }
  }

  /**
   * Fires when the combo-box selection changes due to user interaction (not programmatic setup).
   */
  private void onComboSelectionChanged() {
    if (!programmaticUpdate) {
      refreshSelectedRecording();
    }
  }

  private void refreshSelectedRecording() {
    RecordingItem item = (RecordingItem) recordingCombo.getSelectedItem();
    if (item == null || loadedManifest == null) {
      return;
    }
    final DatasetManifest currentManifest = loadedManifest;
    final DatasetRecording currentRecording = item.recording();
    new SwingWorker<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis, Void>() {
      @Override
      protected DatasetWingbeatEvaluationWorkflow.RecordingAnalysis doInBackground()
          throws IOException {
        return workflow.analyzeRecording(currentManifest, currentRecording, classifier);
      }

      @Override
      protected void done() {
        try {
          recordingArea.setText(renderRecordingAnalysis(get()));
        } catch (ExecutionException ex) {
          LOGGER.log(Level.WARNING, "Recording analysis failed", ex);
          recordingArea.setText("Recording analysis failed: " + ex.getCause().getMessage());
        } catch (InterruptedException ex) {
          Thread.currentThread().interrupt();
        }
      }
    }.execute();
  }

  private void runCalibration() {
    if (loadedManifest == null) {
      calibrationArea.setText("Import a dataset first.");
      return;
    }
    if (loadedManifest.recordings().isEmpty()) {
      calibrationArea.setText("Calibration unavailable: dataset contains no recordings.");
      return;
    }
    calibrationArea.setText("Running calibration …");
    final boolean selectedOnly = calibrationScopeCombo.getSelectedIndex() == 1;
    final DatasetManifest currentManifest = loadedManifest;
    final DatasetRecording selectedRecording = selectedRecording();
    final List<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis> currentAnalyses =
        loadedAnalyses;
    new SwingWorker<String, Void>() {
      @Override
      protected String doInBackground() throws IOException {
        return buildCalibrationReportForScope(
            currentManifest, selectedRecording, selectedOnly, currentAnalyses);
      }

      @Override
      protected void done() {
        try {
          calibrationArea.setText(get());
        } catch (ExecutionException ex) {
          LOGGER.log(Level.WARNING, "Calibration failed", ex);
          Throwable cause = ex.getCause() == null ? ex : ex.getCause();
          String message = cause.getMessage();
          String fallback = cause.getClass().getSimpleName();
          calibrationArea.setText(
              "Calibration failed: " + (message == null || message.isBlank() ? fallback : message));
        } catch (InterruptedException ex) {
          Thread.currentThread().interrupt();
          calibrationArea.setText("Calibration was interrupted.");
        }
      }
    }.execute();
  }

  /**
   * Run calibration synchronously for tests/headless usage.
   *
   * @param selectedOnly when true, calibrates only against the currently selected recording
   */
  void runCalibrationHeadless(boolean selectedOnly) throws IOException {
    DatasetManifest manifest = Objects.requireNonNull(loadedManifest, "loadedManifest");
    DatasetRecording selected = selectedRecording();
    calibrationArea.setText(
        buildCalibrationReportForScope(manifest, selected, selectedOnly, loadedAnalyses));
  }

  private String buildCalibrationReportForScope(
      DatasetManifest manifest,
      DatasetRecording selectedRecording,
      boolean selectedOnly,
      List<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis> preloadedAnalyses)
      throws IOException {
    List<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis> analyses =
        selectedOnly ? analyzeSelectedOnly(manifest, selectedRecording) : preloadedAnalyses;
    boolean shouldRecomputeAnalyses =
        !selectedOnly && analyses.isEmpty() && !manifest.recordings().isEmpty();
    if (shouldRecomputeAnalyses) {
      analyses = workflow.analyzeAll(manifest, null);
    }
    return buildCalibrationReport(manifest, analyses, selectedOnly);
  }

  private List<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis> analyzeSelectedOnly(
      DatasetManifest manifest, DatasetRecording selectedRecording) throws IOException {
    if (selectedRecording == null) {
      throw new IllegalArgumentException(
          "Please select a recording before running calibration on a subset.");
    }
    return List.of(workflow.analyzeRecording(manifest, selectedRecording, null));
  }

  private DatasetRecording selectedRecording() {
    RecordingItem item = (RecordingItem) recordingCombo.getSelectedItem();
    return item == null ? null : item.recording();
  }

  @SuppressWarnings({
    "PMD.ConsecutiveAppendsShouldReuse",
    "PMD.ConsecutiveLiteralAppends",
    "PMD.NcssCount",
    "PMD.NPathComplexity"
  })
  private static String buildCalibrationReport(
      DatasetManifest manifest,
      List<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis> analyses,
      boolean selectedOnly) {
    if (analyses.isEmpty()) {
      return "Calibration unavailable: no extracted features were produced.";
    }
    List<WingbeatFeatureVector> real =
        analyses.stream()
            .map(DatasetWingbeatEvaluationWorkflow.RecordingAnalysis::features)
            .toList();
    WingbeatSignalParameters baseline = WingbeatSignalParameters.mosquitoLike(500.0);
    CalibrationResult calibrationResult =
        new GeneratorCalibrationService().calibrate(baseline, real);

    int missingSpeciesCount = 0;
    int missingGenderCount = 0;
    int missingAnnotationCount = 0;
    for (DatasetWingbeatEvaluationWorkflow.RecordingAnalysis analysis : analyses) {
      DatasetRecording recording = analysis.recording();
      if (!recording.labels().containsKey("species")) {
        missingSpeciesCount++;
      }
      if (!recording.labels().containsKey("gender")) {
        missingGenderCount++;
      }
      if (recording.annotations().isEmpty()) {
        missingAnnotationCount++;
      }
    }
    StringBuilder sb = new StringBuilder(1024);
    sb.append("# Generator Calibration (Imported Dataset)\n\n");
    sb.append("## Dataset Provenance\n\n");
    sb.append("- Dataset ID: ").append(manifest.descriptor().id()).append('\n');
    sb.append("- Dataset name: ").append(manifest.descriptor().name()).append('\n');
    sb.append("- Dataset source: ").append(manifest.descriptor().source()).append('\n');
    sb.append("- Dataset root: ").append(manifest.descriptor().localRootPath()).append('\n');
    sb.append("- Scope: ")
        .append(selectedOnly ? "Selected recording only" : "All imported recordings")
        .append('\n');
    sb.append("- Imported recordings: ").append(manifest.recordings().size()).append('\n');
    sb.append("- Calibrated recordings: ").append(analyses.size()).append('\n');
    sb.append("- Extracted feature vectors: ").append(real.size()).append('\n');
    sb.append("- Feature extraction: ")
        .append(DatasetWingbeatEvaluationWorkflow.defaultFeatureExtractionProvenance())
        .append('\n');

    sb.append("\n## Dataset Warnings\n\n");
    boolean hasWarnings = false;
    if (analyses.size() < 3) {
      sb.append(
          "- Tiny dataset warning: fewer than 3 recordings; calibration may be unstable but remains"
              + " deterministic.\n");
      hasWarnings = true;
    }
    if (missingSpeciesCount > 0 || missingGenderCount > 0 || missingAnnotationCount > 0) {
      StringJoiner joiner = new StringJoiner("; ");
      if (missingSpeciesCount > 0) {
        joiner.add(String.format(Locale.ROOT, "%d missing species label(s)", missingSpeciesCount));
      }
      if (missingGenderCount > 0) {
        joiner.add(String.format(Locale.ROOT, "%d missing gender label(s)", missingGenderCount));
      }
      if (missingAnnotationCount > 0) {
        joiner.add(
            String.format(Locale.ROOT, "%d without annotation span(s)", missingAnnotationCount));
      }
      sb.append("- Partial annotation warning: ").append(joiner).append('\n');
      hasWarnings = true;
    }
    if (!hasWarnings) {
      sb.append("- No warnings.\n");
    }

    sb.append("\n## Baseline Parameters\n\n");
    appendParamRow(
        sb,
        "fundamentalFrequencyHz",
        calibrationResult.baselineParameters().fundamentalFrequencyHz());
    appendParamRow(sb, "harmonicCount", calibrationResult.baselineParameters().harmonicCount());
    appendParamRow(sb, "jitterHz", calibrationResult.baselineParameters().jitterHz());
    appendParamRow(sb, "modulationDepth", calibrationResult.baselineParameters().modulationDepth());
    appendParamRow(sb, "noiseAmplitude", calibrationResult.baselineParameters().noiseAmplitude());
    sb.append("\n## Calibrated Parameters\n\n");
    appendParamRow(
        sb,
        "fundamentalFrequencyHz",
        calibrationResult.calibratedParameters().fundamentalFrequencyHz());
    appendParamRow(sb, "harmonicCount", calibrationResult.calibratedParameters().harmonicCount());
    appendParamRow(sb, "jitterHz", calibrationResult.calibratedParameters().jitterHz());
    appendParamRow(
        sb, "modulationDepth", calibrationResult.calibratedParameters().modulationDepth());
    appendParamRow(sb, "noiseAmplitude", calibrationResult.calibratedParameters().noiseAmplitude());
    sb.append("\n## Feature Deviation Report\n\n");
    sb.append(
        String.format(
            Locale.ROOT, "%-30s %10s %10s %10s%n", "Feature", "Before", "After", "Improvement"));
    sb.append("-".repeat(65)).append('\n');
    List<FeatureDifference> beforeDiffs = calibrationResult.beforeCalibration().differences();
    List<FeatureDifference> afterDiffs = calibrationResult.afterCalibration().differences();
    int size = Math.min(beforeDiffs.size(), afterDiffs.size());
    for (int i = 0; i < size; i++) {
      double before = beforeDiffs.get(i).relativeDifference();
      double after = afterDiffs.get(i).relativeDifference();
      sb.append(
          String.format(
              Locale.ROOT,
              "%-30s %9.1f%% %9.1f%% %9.1f%%%n",
              beforeDiffs.get(i).featureName(),
              before * 100.0,
              after * 100.0,
              (before - after) * 100.0));
    }
    sb.append('\n');
    sb.append(
        String.format(
            Locale.ROOT, "Overall improvement: %.1f%%%n", calibrationResult.improvement() * 100.0));
    return sb.toString();
  }

  private static void appendParamRow(StringBuilder sb, String name, double value) {
    sb.append(String.format(Locale.ROOT, "  %-30s %.6f%n", name + ":", value));
  }

  private static void appendParamRow(StringBuilder sb, String name, int value) {
    sb.append(String.format(Locale.ROOT, "  %-30s %d%n", name + ":", value));
  }

  @SuppressWarnings({"PMD.ConsecutiveAppendsShouldReuse", "PMD.ConsecutiveLiteralAppends"})
  private static String renderManifest(DatasetManifest manifest) {
    StringBuilder sb = new StringBuilder(512);
    sb.append("# Imported Dataset\n\n");
    sb.append("- ID: ").append(manifest.descriptor().id()).append('\n');
    sb.append("- Name: ").append(manifest.descriptor().name()).append('\n');
    sb.append("- Root: ").append(manifest.descriptor().localRootPath()).append('\n');
    sb.append("- Recordings: ").append(manifest.recordings().size()).append("\n\n");
    sb.append("| Recording | Duration (s) | Sample rate (Hz) | Labels |\n");
    sb.append("|---|---:|---:|---|\n");
    for (DatasetRecording recording : manifest.recordings()) {
      sb.append("| ")
          .append(recording.recordingId())
          .append(" | ")
          .append(String.format(Locale.ROOT, "%.3f", recording.durationSeconds()))
          .append(" | ")
          .append(String.format(Locale.ROOT, "%.0f", recording.sampleRateHz()))
          .append(" | ")
          .append(recording.labels())
          .append(" |\n");
    }
    return sb.toString();
  }

  @SuppressWarnings({"PMD.ConsecutiveAppendsShouldReuse", "PMD.ConsecutiveLiteralAppends"})
  private static String renderRecordingAnalysis(
      DatasetWingbeatEvaluationWorkflow.RecordingAnalysis analysis) {
    StringBuilder sb = new StringBuilder(512);
    sb.append("# Recording inspection\n\n");
    sb.append("- ID: ").append(analysis.recording().recordingId()).append('\n');
    sb.append("- Audio: ").append(analysis.resolvedAudioPath()).append('\n');
    sb.append("- Ground truth label: ").append(analysis.groundTruthLabel()).append('\n');
    sb.append("- Raw labels: ").append(analysis.recording().labels()).append('\n');
    sb.append("- Metadata: ").append(analysis.recording().metadata()).append('\n');
    sb.append("- Duration: ")
        .append(String.format(Locale.ROOT, "%.3f s", analysis.recording().durationSeconds()))
        .append('\n');
    sb.append("- Sample rate: ")
        .append(String.format(Locale.ROOT, "%.0f Hz", analysis.recording().sampleRateHz()))
        .append('\n');
    sb.append("- Detected fundamental: ")
        .append(String.format(Locale.ROOT, "%.2f Hz", analysis.features().fundamentalFrequencyHz()))
        .append('\n');
    sb.append("- Spectral centroid: ")
        .append(String.format(Locale.ROOT, "%.2f Hz", analysis.features().spectralCentroidHz()))
        .append('\n');
    sb.append("- Bandwidth: ")
        .append(String.format(Locale.ROOT, "%.2f Hz", analysis.features().spectralBandwidthHz()))
        .append('\n');
    sb.append("- SNR: ")
        .append(String.format(Locale.ROOT, "%.3f", analysis.features().signalToNoiseRatio()))
        .append('\n');
    if (analysis.classificationResult() != null) {
      sb.append("- Predicted label: ").append(analysis.classificationResult().label()).append('\n');
      sb.append("- Prediction confidence: ")
          .append(String.format(Locale.ROOT, "%.3f", analysis.classificationResult().confidence()))
          .append('\n');
    }
    sb.append("- Annotations: ").append(analysis.recording().annotations()).append('\n');
    return sb.toString();
  }

  /**
   * Restore transient cached analyses state after deserialization.
   *
   * <p>Analyses are derived data and are intentionally recomputed from a manifest when needed.
   */
  private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    loadedAnalyses = List.of();
  }

  DatasetManifest manifest() {
    return loadedManifest;
  }

  String recordingSummaryText() {
    return recordingArea.getText();
  }

  String evaluationSummaryText() {
    return evaluationArea.getText();
  }

  String analyticsText() {
    return analyticsArea.getText();
  }

  String histogramText() {
    return histogramArea.getText();
  }

  String calibrationText() {
    return calibrationArea.getText();
  }

  private record RecordingItem(DatasetRecording recording) {
    @Override
    public String toString() {
      String species = recording.labels().get("species");
      return species == null || species.isBlank()
          ? recording.recordingId()
          : recording.recordingId() + " — " + species;
    }
  }

  private record ImportResult(
      DatasetManifest manifest,
      WingbeatDataset.Evaluation evaluation,
      List<DatasetWingbeatEvaluationWorkflow.RecordingAnalysis> analyses,
      String analyticsReport,
      String histogramsReport) {

    private ImportResult {
      Objects.requireNonNull(manifest, "manifest");
      Objects.requireNonNull(analyses, "analyses");
      Objects.requireNonNull(analyticsReport, "analyticsReport");
      Objects.requireNonNull(histogramsReport, "histogramsReport");
      // evaluation is intentionally nullable for empty manifests.
    }
  }
}