#pragma once
/// TRACES: GR-003 | SR-001
///
/// The gallery build report — what the gallery *is*, written next to it.
///
/// A gallery is a silent artefact: it loads, it scores, it never complains. The
/// two ways it fails are both invisible from the outside.
///
///  1. **An actor with zero usable images can never be recognised.** They are
///     dropped at build time (`gallery_builder.cpp` skips a directory whose
///     images all fail detection or alignment), so afterwards nothing in the
///     file records that they were ever meant to be there. Every scene they
///     appear in is a guaranteed miss, and recall is capped at a number nobody
///     computed. This is the single most useful line in the report.
///  2. **A gallery can be quietly bad and look fine.** The Platt sigmoid
///     (AR-023) is fitted from two distributions — intra-class (same actor,
///     different reference) and inter-class (different actors) similarity — and
///     *every* threshold in the pipeline is expressed in the probability space
///     that fit defines (AR-024): identity acceptance, track association,
///     expansion admission, cluster merging. If those two distributions overlap
///     heavily the fit is weak, and every downstream decision silently inherits
///     that weakness while still reporting confident-looking probabilities. The
///     fit already computes the distributions and throws them away; emitting
///     them is what makes the quality of the whole probability space auditable
///     instead of assumed.
///
/// The report is therefore a build artefact, not a debug aid: it is the only
/// place the recall ceiling and the calibration's conditioning are written down.
///
/// **On the histograms being in cosine space.** They bin raw similarity, and
/// that is not an AR-024 violation: no decision is taken here. These two
/// distributions are the *input* the calibration is fitted from — they cannot be
/// expressed in the probability space the calibration defines, because that
/// space is their output. GR-003 asks for exactly this ("the intra/inter
/// distributions behind it"), for the same reason GR-008 characterises an
/// actor's reference spread in the metric space: shape is a property of the
/// metric, decisions are a property of the probability.
#include "gallery/gallery_calibration.hpp"
#include "gallery/embedder_stamp.hpp"
#include "types.hpp"

#include <nlohmann/json.hpp>

#include <algorithm>
#include <cmath>
#include <cstdint>
#include <fstream>
#include <stdexcept>
#include <string>
#include <vector>

/// TRACES: GR-003 | SR-001
///
/// Per-actor image accounting from the build pass, including the actors that
/// produced nothing and were therefore dropped from the gallery.
///
/// Filled by `build_gallery()`. It has to be collected there and cannot be
/// recovered later: by the time a gallery exists, an actor with no usable image
/// is indistinguishable from an actor who was never requested.
struct GalleryBuildAudit {
    struct ActorImages {
        std::string imdb_id;
        std::string name;
        int images_seen   = 0;  // candidate image files in the actor's directory
        int images_used   = 0;  // ...that yielded an embedding
        int unreadable    = 0;  // cv::imread failed
        int no_face       = 0;  // detector found nothing
        int align_failed  = 0;  // 5-point warp failed
    };
    std::vector<ActorImages> actors;  // every directory seen, in build order
};

/// TRACES: GR-003 | SR-001
struct GalleryReport {
    // One row per actor the build considered. Actors with references == 0 are
    // the zero-usable-image case: present in the source tree, absent from the
    // gallery, unrecognisable for the life of the file.
    struct Actor {
        std::string imdb_id;
        std::string name;
        int  images_seen         = -1;  // -1 = unknown (report built without a build audit)
        int  references          = 0;   // embeddings stored in the gallery
        int  distinct_references = 0;   // ...after near-duplicate removal
        int  duplicates_removed  = 0;
        bool eligible_for_positive_pairs = false;
    };

    // The two distributions the sigmoid is fitted from, as the fit itself saw
    // them: counts per similarity bin over [sim_min, sim_max].
    struct Distributions {
        int    bins    = 0;
        float  sim_min = -1.f;
        float  sim_max =  1.f;
        std::vector<double> intra;  // same actor, different reference image
        std::vector<double> inter;  // different actors
        double intra_pairs = 0.0;
        double inter_pairs = 0.0;
        double intra_mean  = 0.0;
        double inter_mean  = 0.0;
        // Normalised histogram intersection, Σ_b min(p_intra[b], p_inter[b]).
        // 0 = perfectly separated, 1 = indistinguishable. This is the number
        // that says whether the calibration — and so every threshold expressed
        // in its probability space — rests on anything.
        double overlap     = 0.0;
    };

    // GR-003 / AR-023 open question, reported but NOT applied. The spec asks for
    // a gallery-derived prior of intra/(intra+inter); the shipped default is
    // 0.5. Persisting the distributions makes the real value computable, so the
    // decision can be taken on evidence rather than left implicit. Behaviour is
    // unchanged: `applied` is always false here.
    struct Prior {
        double      derived           = 0.0;  // intra_pairs / (intra_pairs + inter_pairs)
        double      derived_log_odds  = 0.0;  // log(p/(1-p)), the term AR-023 would add
        float       configured_default = 0.5f;
        bool        applied           = false;
        std::string note;
    };

    std::string   schema{"sae.gallery_report/1"};
    std::string   gallery_path;
    EmbedderStamp embedder;

    // ── Summary ──────────────────────────────────────────────────────────────
    int     actors_total                   = 0;  // considered (gallery + zero-usable)
    int     actors_in_gallery              = 0;
    int     actors_zero_usable             = 0;
    int     actors_below_positive_threshold = 0;
    int64_t embeddings_total               = 0;
    int64_t distinct_embeddings_total      = 0;
    int64_t duplicates_removed_total       = 0;
    double  mean_embeddings_per_actor      = 0.0;  // over actors in the gallery
    int     min_embeddings_for_positive_pairs = 0;
    float   dedup_similarity_threshold     = 0.f;

    // ── Calibration ──────────────────────────────────────────────────────────
    float    calib_a     = 10.f;
    float    calib_b     = -5.f;
    bool     calib_valid = false;
    uint64_t calib_hash  = 0;
    double   calib_train_accuracy_pct = 0.0;
    float    calib_boundary_p50       = 0.f;  // similarity at which P(match) = 0.5

    Distributions distributions;
    Prior         prior;

    std::vector<Actor>       actors;
    // Names duplicated out of `actors` so the two failure modes are greppable
    // without a JSON query. These are the lines a human reads first.
    std::vector<std::string> zero_usable;
    std::vector<std::string> below_positive_threshold;
};

/// TRACES: GR-003 | SR-001
///
/// Assembles the report from the three things that know a piece of the answer:
/// the gallery itself (who is in it, with how many references), the calibration
/// stats (dedup, eligibility, the two distributions), and the build audit (who
/// was considered and produced nothing). The audit is optional — a report built
/// from a stored gallery simply cannot know about the actors that never made it.
///
/// `stats` is indexed by actor index, so the flat arrays handed to
/// `calibrate_gallery()` must have used the gallery's own actor ordering.
inline GalleryReport build_gallery_report(const ActorGallery&            gallery,
                                          const GalleryCalibration&      cal,
                                          const GalleryCalibrationStats& stats,
                                          const GalleryBuildAudit*       audit = nullptr,
                                          const std::string&             gallery_path = "",
                                          float                          configured_prior = 0.5f)
{
    GalleryReport r;
    r.gallery_path = gallery_path;
    r.embedder     = gallery.embedder;

    r.calib_a     = cal.a;
    r.calib_b     = cal.b;
    r.calib_valid = cal.valid;
    r.calib_hash  = gallery.calib_hash;
    r.calib_train_accuracy_pct = stats.train_accuracy_pct;
    r.calib_boundary_p50       = cal.boundary_at(0.5f);

    r.min_embeddings_for_positive_pairs = stats.min_embeddings_for_positive;
    r.dedup_similarity_threshold        = stats.dedup_sim_threshold;

    auto audit_for = [&](const ActorGallery::Actor& a) -> const GalleryBuildAudit::ActorImages* {
        if (!audit) return nullptr;
        for (const auto& e : audit->actors) {
            if (!a.imdb_id.empty() && e.imdb_id == a.imdb_id) return &e;
            if (a.imdb_id.empty() && e.name == a.name)        return &e;
        }
        return nullptr;
    };

    for (size_t i = 0; i < gallery.actors.size(); ++i) {
        const auto& ga = gallery.actors[i];
        GalleryReport::Actor row;
        row.imdb_id    = ga.imdb_id;
        row.name       = ga.name;
        row.references = static_cast<int>(ga.embeddings.size());
        if (const auto* au = audit_for(ga)) row.images_seen = au->images_seen;

        if (i < stats.distinct_per_actor.size()) {
            row.distinct_references = stats.distinct_per_actor[i];
            row.duplicates_removed  = stats.duplicates_removed_per_actor[i];
            row.eligible_for_positive_pairs = stats.eligible[i] != 0;
        } else {
            // No calibration stats for this actor (the fit never saw them).
            // Report the raw count rather than a fabricated distinct count.
            row.distinct_references = row.references;
        }

        r.embeddings_total          += row.references;
        r.distinct_embeddings_total += row.distinct_references;
        r.duplicates_removed_total  += row.duplicates_removed;
        if (!row.eligible_for_positive_pairs) {
            ++r.actors_below_positive_threshold;
            r.below_positive_threshold.push_back(row.name);
        }
        r.actors.push_back(std::move(row));
    }
    r.actors_in_gallery = static_cast<int>(gallery.actors.size());

    // Actors the build considered and could not use at all. They are not in the
    // gallery, so this is the only record that they exist.
    if (audit) {
        for (const auto& e : audit->actors) {
            if (e.images_used > 0) continue;
            GalleryReport::Actor row;
            row.imdb_id     = e.imdb_id;
            row.name        = e.name;
            row.images_seen = e.images_seen;
            row.references  = 0;
            r.zero_usable.push_back(e.name);
            r.actors.push_back(std::move(row));
        }
    }
    r.actors_zero_usable = static_cast<int>(r.zero_usable.size());
    r.actors_total       = r.actors_in_gallery + r.actors_zero_usable;
    r.mean_embeddings_per_actor =
        r.actors_in_gallery > 0
            ? static_cast<double>(r.embeddings_total) / r.actors_in_gallery
            : 0.0;

    // ── The two distributions, straight out of the fit ───────────────────────
    auto& d  = r.distributions;
    d.bins    = stats.hist_bins;
    d.sim_min = -1.f;
    d.sim_max =  1.f;
    d.intra   = stats.intra_hist;
    d.inter   = stats.inter_hist;
    d.intra_pairs = stats.n_intra_pairs;
    d.inter_pairs = stats.n_inter_pairs;

    if (d.bins > 0) {
        const double bin_w = (d.sim_max - d.sim_min) / d.bins;
        double si = 0.0, se = 0.0;
        for (int b = 0; b < d.bins; ++b) {
            const double centre = d.sim_min + (b + 0.5) * bin_w;
            si += d.intra[b] * centre;
            se += d.inter[b] * centre;
        }
        if (d.intra_pairs > 0.0) d.intra_mean = si / d.intra_pairs;
        if (d.inter_pairs > 0.0) d.inter_mean = se / d.inter_pairs;
        if (d.intra_pairs > 0.0 && d.inter_pairs > 0.0) {
            double ov = 0.0;
            for (int b = 0; b < d.bins; ++b)
                ov += std::min(d.intra[b] / d.intra_pairs, d.inter[b] / d.inter_pairs);
            d.overlap = ov;
        }
    }

    // ── The prior AR-023 leaves open — computed, reported, not applied ────────
    r.prior.configured_default = configured_prior;
    r.prior.applied            = false;
    const double pair_total = d.intra_pairs + d.inter_pairs;
    if (pair_total > 0.0) {
        r.prior.derived = d.intra_pairs / pair_total;
        const double p  = std::clamp(r.prior.derived, 1e-12, 1.0 - 1e-12);
        r.prior.derived_log_odds = std::log(p / (1.0 - p));
    }
    r.prior.note =
        "AR-023 specifies a gallery-derived prior of intra/(intra+inter); the shipped "
        "match_prior default is 0.5 (calibrated sigmoid used directly). The derived value "
        "is the base rate of same-actor pairs among ALL enumerated gallery pairs, so it "
        "falls as the cast grows (roughly (k-1)/((k-1)+(A-1)k) for A actors with k "
        "references each) — it is a property of gallery size as much as of the embedder. "
        "Reported here as evidence; NOT applied. Behaviour is unchanged until the choice "
        "is recorded in the spec.";

    return r;
}

// ── JSON ─────────────────────────────────────────────────────────────────────

/// TRACES: GR-003 | SR-001
inline nlohmann::json gallery_report_to_json(const GalleryReport& r) {
    nlohmann::json j;
    j["schema"]       = r.schema;
    j["gallery_path"] = r.gallery_path;
    j["embedder"] = {{"model_name",   r.embedder.model_name},
                     {"model_sha256", r.embedder.model_sha256},
                     {"embed_dim",    r.embedder.embed_dim}};

    j["summary"] = {
        {"actors_total",                    r.actors_total},
        {"actors_in_gallery",               r.actors_in_gallery},
        {"actors_zero_usable",              r.actors_zero_usable},
        {"actors_below_positive_threshold", r.actors_below_positive_threshold},
        {"embeddings_total",                r.embeddings_total},
        {"distinct_embeddings_total",       r.distinct_embeddings_total},
        {"duplicates_removed_total",        r.duplicates_removed_total},
        {"mean_embeddings_per_actor",       r.mean_embeddings_per_actor},
        {"min_embeddings_for_positive_pairs", r.min_embeddings_for_positive_pairs},
        {"dedup_similarity_threshold",      r.dedup_similarity_threshold}};

    j["calibration"] = {
        {"a",                  r.calib_a},
        {"b",                  r.calib_b},
        {"valid",              r.calib_valid},
        {"hash",               r.calib_hash},
        {"train_accuracy_pct", r.calib_train_accuracy_pct},
        {"boundary_p50",       r.calib_boundary_p50}};

    const auto& d = r.distributions;
    j["distributions"] = {
        {"bins",        d.bins},
        {"sim_min",     d.sim_min},
        {"sim_max",     d.sim_max},
        {"intra",       d.intra},
        {"inter",       d.inter},
        {"intra_pairs", d.intra_pairs},
        {"inter_pairs", d.inter_pairs},
        {"intra_mean",  d.intra_mean},
        {"inter_mean",  d.inter_mean},
        {"overlap",     d.overlap}};

    j["prior"] = {
        {"derived",            r.prior.derived},
        {"derived_log_odds",   r.prior.derived_log_odds},
        {"configured_default", r.prior.configured_default},
        {"applied",            r.prior.applied},
        {"note",               r.prior.note}};

    j["zero_usable"]              = r.zero_usable;
    j["below_positive_threshold"] = r.below_positive_threshold;

    j["actors"] = nlohmann::json::array();
    for (const auto& a : r.actors) {
        j["actors"].push_back({
            {"imdb_id",             a.imdb_id},
            {"name",                a.name},
            {"images_seen",         a.images_seen},
            {"references",          a.references},
            {"distinct_references", a.distinct_references},
            {"duplicates_removed",  a.duplicates_removed},
            {"eligible_for_positive_pairs", a.eligible_for_positive_pairs}});
    }
    return j;
}

/// TRACES: GR-003 | SR-001
inline GalleryReport gallery_report_from_json(const nlohmann::json& j) {
    GalleryReport r;
    r.schema       = j.value("schema", std::string{});
    r.gallery_path = j.value("gallery_path", std::string{});
    if (j.contains("embedder")) {
        const auto& je = j.at("embedder");
        r.embedder.model_name   = je.value("model_name", "");
        r.embedder.model_sha256 = je.value("model_sha256", "");
        r.embedder.embed_dim    = je.value("embed_dim", 512);
    }
    if (j.contains("summary")) {
        const auto& s = j.at("summary");
        r.actors_total                    = s.value("actors_total", 0);
        r.actors_in_gallery               = s.value("actors_in_gallery", 0);
        r.actors_zero_usable              = s.value("actors_zero_usable", 0);
        r.actors_below_positive_threshold = s.value("actors_below_positive_threshold", 0);
        r.embeddings_total                = s.value("embeddings_total", int64_t{0});
        r.distinct_embeddings_total       = s.value("distinct_embeddings_total", int64_t{0});
        r.duplicates_removed_total        = s.value("duplicates_removed_total", int64_t{0});
        r.mean_embeddings_per_actor       = s.value("mean_embeddings_per_actor", 0.0);
        r.min_embeddings_for_positive_pairs = s.value("min_embeddings_for_positive_pairs", 0);
        r.dedup_similarity_threshold      = s.value("dedup_similarity_threshold", 0.f);
    }
    if (j.contains("calibration")) {
        const auto& c = j.at("calibration");
        r.calib_a     = c.value("a", 10.f);
        r.calib_b     = c.value("b", -5.f);
        r.calib_valid = c.value("valid", false);
        r.calib_hash  = c.value("hash", uint64_t{0});
        r.calib_train_accuracy_pct = c.value("train_accuracy_pct", 0.0);
        r.calib_boundary_p50       = c.value("boundary_p50", 0.f);
    }
    if (j.contains("distributions")) {
        const auto& d = j.at("distributions");
        r.distributions.bins        = d.value("bins", 0);
        r.distributions.sim_min     = d.value("sim_min", -1.f);
        r.distributions.sim_max     = d.value("sim_max", 1.f);
        r.distributions.intra       = d.value("intra", std::vector<double>{});
        r.distributions.inter       = d.value("inter", std::vector<double>{});
        r.distributions.intra_pairs = d.value("intra_pairs", 0.0);
        r.distributions.inter_pairs = d.value("inter_pairs", 0.0);
        r.distributions.intra_mean  = d.value("intra_mean", 0.0);
        r.distributions.inter_mean  = d.value("inter_mean", 0.0);
        r.distributions.overlap     = d.value("overlap", 0.0);
    }
    if (j.contains("prior")) {
        const auto& p = j.at("prior");
        r.prior.derived            = p.value("derived", 0.0);
        r.prior.derived_log_odds   = p.value("derived_log_odds", 0.0);
        r.prior.configured_default = p.value("configured_default", 0.5f);
        r.prior.applied            = p.value("applied", false);
        r.prior.note               = p.value("note", "");
    }
    r.zero_usable              = j.value("zero_usable", std::vector<std::string>{});
    r.below_positive_threshold = j.value("below_positive_threshold", std::vector<std::string>{});
    if (j.contains("actors")) {
        for (const auto& ja : j.at("actors")) {
            GalleryReport::Actor a;
            a.imdb_id             = ja.value("imdb_id", "");
            a.name                = ja.value("name", "");
            a.images_seen         = ja.value("images_seen", -1);
            a.references          = ja.value("references", 0);
            a.distinct_references = ja.value("distinct_references", 0);
            a.duplicates_removed  = ja.value("duplicates_removed", 0);
            a.eligible_for_positive_pairs = ja.value("eligible_for_positive_pairs", false);
            r.actors.push_back(std::move(a));
        }
    }
    return r;
}

// "<dir>/cast.h5" → "<dir>/cast.report.json". A known gallery extension is
// replaced rather than appended to, so the report sits beside the gallery under
// the same stem.
inline std::string gallery_report_path(const std::string& gallery_path) {
    auto slash = gallery_path.find_last_of("/\\");
    auto dot   = gallery_path.find_last_of('.');
    std::string stem =
        (dot != std::string::npos && (slash == std::string::npos || dot > slash))
            ? gallery_path.substr(0, dot)
            : gallery_path;
    return stem + ".report.json";
}

/// TRACES: GR-003 | SR-001
inline void save_gallery_report(const std::string& path, const GalleryReport& r) {
    std::ofstream out(path);
    if (!out.is_open())
        throw std::runtime_error("save_gallery_report: cannot write " + path);
    out << gallery_report_to_json(r).dump(2) << "\n";
}

/// TRACES: GR-003 | SR-001
inline GalleryReport load_gallery_report(const std::string& path) {
    std::ifstream in(path);
    if (!in.is_open())
        throw std::runtime_error("load_gallery_report: cannot open " + path);
    nlohmann::json j;
    in >> j;
    return gallery_report_from_json(j);
}

/// TRACES: GR-003 | SR-001
///
/// The report's headline, on stderr, at build time. The file is the audit trail;
/// this is what stops a bad gallery from being shipped without anyone noticing.
inline void log_gallery_report(const GalleryReport& r) {
    std::cerr << "[gallery-report] " << r.actors_in_gallery << " actors / "
              << r.embeddings_total << " embeddings"
              << "  (mean " << r.mean_embeddings_per_actor << " per actor)\n";
    if (r.actors_zero_usable > 0) {
        std::cerr << "[gallery-report] WARNING: " << r.actors_zero_usable
                  << " actor(s) have NO usable image — they can never be recognised:\n";
        for (const auto& n : r.zero_usable) std::cerr << "    - " << n << "\n";
    }
    if (r.actors_below_positive_threshold > 0) {
        std::cerr << "[gallery-report] " << r.actors_below_positive_threshold
                  << " actor(s) below " << r.min_embeddings_for_positive_pairs
                  << " distinct references — they contribute no positive pairs and "
                     "weaken the calibration\n";
    }
    if (r.duplicates_removed_total > 0)
        std::cerr << "[gallery-report] " << r.duplicates_removed_total
                  << " near-duplicate reference(s) removed\n";
    std::cerr << "[gallery-report] calibration valid=" << r.calib_valid
              << "  a=" << r.calib_a << "  b=" << r.calib_b
              << "  intra/inter overlap=" << r.distributions.overlap
              << "  (intra mean=" << r.distributions.intra_mean
              << ", inter mean=" << r.distributions.inter_mean << ")\n";
    std::cerr << "[gallery-report] gallery-derived prior would be "
              << r.prior.derived << " (log-odds " << r.prior.derived_log_odds
              << "); shipped default " << r.prior.configured_default
              << " is in force — reported, not applied\n";
}
