/**
 **
 ** CuasQC.java - per-product quality metrics with pass bands (regression protection)
 **
 ** Copyright (C) 2026 Elphel, Inc.
 **
 ** -----------------------------------------------------------------------------**
 **
 **  CuasQC.java is free software: you can redistribute it and/or modify
 **  it under the terms of the GNU General Public License as published by
 **  the Free Software Foundation, either version 3 of the License, or
 **  (at your option) any later version.
 **
 **  This program is distributed in the hope that it will be useful,
 **  but WITHOUT ANY WARRANTY; without even the implied warranty of
 **  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 **  GNU General Public License for more details.
 **
 **  You should have received a copy of the GNU General Public License
 **  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 ** -----------------------------------------------------------------------------**
 **
 */
package com.elphel.imagej.cuas;

import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashMap;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

/**
 * Quality-metrics harness (Andrey's design 07/07/2026, after the v013-ROWCOL-01 junk):
 * once a pipeline step is "done and verified", detect regressions by per-product HEALTH
 * STATISTICS with pass bands - NOT bit comparison (a photometric recalibration shifts
 * values a little; these invariants survive it and transfer across image sequences).
 * Bands are sequence-independent (defined here in code, in physical/calibrated units for
 * this LWIR16 hardware class); per-sequence VALUES land in <center>-QC.json next to the
 * products (append-per-run, with timestamps), so any run answers "was it healthy" and
 * trends are visible. A FAIL prints loudly but never aborts the run.
 *
 * Increment 1 (07/07/2026): ROWCOL vectors + FPN. The ROWCOL bands would have caught both
 * of today's defects (baseline-mix rms 83, edge-column p95 ~1000 vs legacy 8.5/338).
 * By Claude on 07/07/2026, from Andrey's design.
 */
public class CuasQC {
	public static final int    QC_VERSION = 1;
	public static final String SUFFIX = "-QC.json";
	private static final Gson  GSON = new GsonBuilder().setPrettyPrinting().create();

	// --- pass bands (sequence-independent, LWIR16 class; reference measurements in comments) ---
	public static final double ROWCOL_MAX_RMS =        25.0;  // legacy method 8.5; broken baseline-mix 83; broken edges 53
	public static final double ROWCOL_MAX_SCENE_P95 = 500.0;  // p95 of per-scene max |vector|; legacy 338 (own x=2 edge artifact included)
	public static final double FPN_MAX_RMS =          150.0;  // measured 40.5 (2026-07-07 vintage, rot 164.9)
	public static final double FPN_MAX_LINE_ROW =      25.0;  // FPN line-structure rms, per-row means; measured 6.4
	public static final double FPN_MAX_LINE_COL =      25.0;  // per-col means; measured 12.3

	public static class Check {
		public String  name;
		public double  value;
		public double  max;
		public boolean pass;
		public Check(String name, double value, double max) {
			this.name = name; this.value = value; this.max = max; this.pass = (value <= max);
		}
	}
	public static class Entry {
		public String                        saved;      // timestamp of this QC evaluation
		public String                        product;    // "ROWCOL", "FPN", ...
		public boolean                       calculated; // true - just calculated this run; false - reused from file
		public boolean                       pass;
		public LinkedHashMap<String, Double> metrics;
		public ArrayList<Check>              checks;
	}
	public static class QcFile {
		public int              qc_version = QC_VERSION;
		public ArrayList<Entry> entries = new ArrayList<Entry>();
	}

	/** ROWCOL health: vector magnitudes must stay at the line-noise scale.
	 *  @param row_col {rows[scene][sensor][width], cols[scene][sensor][height]} */
	public static boolean checkRowCol(
			String          x3d_directory,
			String          center_name,
			double [][][][] row_col,
			boolean         calculated) {
		double sum2 = 0.0; long n = 0;
		final int num_scenes = row_col[0].length;
		final double [] scene_max = new double [num_scenes];
		for (int f = 0; f < row_col.length; f++) {
			for (int nscene = 0; nscene < num_scenes; nscene++) if (row_col[f][nscene] != null) {
				for (double [] vec : row_col[f][nscene]) if (vec != null) {
					for (double v : vec) if (!Double.isNaN(v)) {
						sum2 += v*v; n++;
						final double a = Math.abs(v);
						if (a > scene_max[nscene]) scene_max[nscene] = a;
					}
				}
			}
		}
		final double rms = Math.sqrt(sum2/Math.max(n,1));
		final LinkedHashMap<String,Double> metrics = new LinkedHashMap<String,Double>();
		metrics.put("rms",            rms);
		metrics.put("scene_max_med",  percentile(scene_max, 50));
		metrics.put("scene_max_p95",  percentile(scene_max, 95));
		metrics.put("scene_max_max",  percentile(scene_max, 100));
		final ArrayList<Check> checks = new ArrayList<Check>();
		checks.add(new Check("rms",           rms,                          ROWCOL_MAX_RMS));
		checks.add(new Check("scene_max_p95", metrics.get("scene_max_p95"), ROWCOL_MAX_SCENE_P95));
		return report(x3d_directory, center_name, "ROWCOL", calculated, metrics, checks);
	}

	/** FPN health: overall scale + row/col line-structure energy (per-sensor line means
	 *  after removing each sensor's global mean, rms pooled over sensors). */
	public static boolean checkFpn(
			String        x3d_directory,
			String        center_name,
			double [][][] fpn,
			int           width,
			boolean       calculated) {
		double sum2 = 0.0; long n = 0;
		double line_row2 = 0.0, line_col2 = 0.0; long nrow = 0, ncol = 0;
		for (int nsens = 0; nsens < fpn.length; nsens++) if ((fpn[nsens] != null) && (fpn[nsens][0] != null)) {
			final double [] px = fpn[nsens][0];
			final int height = px.length/width;
			double mean = 0.0; long nm = 0;
			for (double v : px) if (!Double.isNaN(v)) { mean += v; nm++; }
			mean /= Math.max(nm,1);
			final double [] rowm = new double [height];
			final double [] coln = new double [width];
			final double [] rown = new double [height];
			final double [] colm = new double [width];
			int idx = 0;
			for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) {
				final double v = px[idx++];
				if (Double.isNaN(v)) continue;
				final double d = v - mean;
				sum2 += d*d; n++;
				rowm[y] += d; rown[y]++;
				colm[x] += d; coln[x]++;
			}
			for (int y = 0; y < height; y++) if (rown[y] > 0) { final double d = rowm[y]/rown[y]; line_row2 += d*d; nrow++; }
			for (int x = 0; x < width;  x++) if (coln[x] > 0) { final double d = colm[x]/coln[x]; line_col2 += d*d; ncol++; }
		}
		final LinkedHashMap<String,Double> metrics = new LinkedHashMap<String,Double>();
		metrics.put("rms",      Math.sqrt(sum2/Math.max(n,1)));
		metrics.put("line_row", Math.sqrt(line_row2/Math.max(nrow,1)));
		metrics.put("line_col", Math.sqrt(line_col2/Math.max(ncol,1)));
		final ArrayList<Check> checks = new ArrayList<Check>();
		checks.add(new Check("rms",      metrics.get("rms"),      FPN_MAX_RMS));
		checks.add(new Check("line_row", metrics.get("line_row"), FPN_MAX_LINE_ROW));
		checks.add(new Check("line_col", metrics.get("line_col"), FPN_MAX_LINE_COL));
		return report(x3d_directory, center_name, "FPN", calculated, metrics, checks);
	}

	private static double percentile(double [] a, double pct) {
		final double [] s = a.clone();
		Arrays.sort(s);
		final int k = (int) Math.min(s.length-1, Math.round(0.01*pct*(s.length-1)));
		return s[k];
	}

	/** Print the QC block, append the entry to <center>-QC.json, return overall pass. */
	private static boolean report(
			String x3d_directory, String center_name, String product, boolean calculated,
			LinkedHashMap<String,Double> metrics, ArrayList<Check> checks) {
		final Entry entry = new Entry();
		entry.saved =      new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
		entry.product =    product;
		entry.calculated = calculated;
		entry.metrics =    metrics;
		entry.checks =     checks;
		entry.pass = true;
		for (Check c : checks) entry.pass &= c.pass;
		final StringBuilder sb = new StringBuilder();
		sb.append("QC: ").append(product).append(calculated ? " (calculated)" : " (reused)")
		  .append(entry.pass ? " PASS" : " *** FAIL ***");
		for (Check c : checks) if (!c.pass) {
			sb.append(String.format(" [%s=%.2f > %.2f]", c.name, c.value, c.max));
		}
		sb.append(" metrics: ");
		for (java.util.Map.Entry<String,Double> m : metrics.entrySet()) {
			sb.append(String.format("%s=%.2f ", m.getKey(), m.getValue()));
		}
		System.out.println(sb.toString());
		if (!entry.pass) {
			System.out.println("QC: *** "+product+" is OUT OF ITS HEALTH BANDS - the product is suspect, "+
					"inspect before trusting this run (bands in CuasQC.java) ***");
		}
		try {
			final String path = x3d_directory + java.io.File.separator + center_name + SUFFIX;
			QcFile qc = null;
			if (Files.exists(Paths.get(path))) {
				try (FileReader fr = new FileReader(path)) {
					qc = GSON.fromJson(fr, QcFile.class);
				} catch (Exception e) {
					System.out.println("QC: could not parse existing "+path+" ("+e.getMessage()+") - starting a new file");
				}
			}
			if ((qc == null) || (qc.entries == null)) qc = new QcFile();
			qc.entries.add(entry);
			try (FileWriter fw = new FileWriter(path)) {
				GSON.toJson(qc, fw);
			}
		} catch (IOException e) {
			System.out.println("QC: failed to save QC json: "+e.getMessage());
		}
		return entry.pass;
	}
}
