#pragma once
#include "inference/backend_config.hpp"
#include <string>

inline const std::string kDefaultDetectorModel = std::string(SAE_MODELS_DIR) + "/scrfd_500m_bnkps.onnx";
inline const std::string kDefaultArcfaceModel  = std::string(SAE_MODELS_DIR) + "/LVFace-B_Glint360K.onnx";
inline const std::string kDefaultSceneModel    = std::string(SAE_MODELS_DIR) + "/transnetv2.onnx";

enum class Verbosity {
    minimal,   // actor names + merged time windows only
    standard,  // per-frame detail: bbox, similarity, unknowns logged
    xray,      // Jellyfin-Xray format: {"second": ["Actor", ...], ...}
};

// How a track's accepted frames become a reported presence window.
enum class PresenceMode {
    // A claim IS its track's [first_seen, last_seen] (AR-012/AR-013). The
    // default and the only mode whose semantics the register validated.
    track_extent,
    // Flood-fill: snap each claim to the shot it sits in, so an actor seen once
    // anywhere in a scene is reported for the whole scene [prev_boundary,
    // next_boundary]. Trades precision for recall against X-Ray's per-scene cast
    // granularity. Snaps to TransNetV2 shot boundaries (is_scene_boundary) when a
    // scene detector populated them, else to the always-on histogram cuts
    // (is_cut). With no boundaries at all it degrades to track_extent per claim.
    flood,
};
// debug verbosity = compile with -DSAE_DEBUG → scene_analyze_debug binary

struct Config {
    // ── Input ─────────────────────────────────────────────────────────────────
    std::string movie_path;
    std::string gallery_path;
    // TRACES: IR-002 | SR-003
    // "global" (matched against the whole library) or "limited" (this title's
    // credited cast only). The strongest single quality signal when two
    // manifests compete for the same cut: identical gallery_size can mean very
    // different recall depending on which was used.
    std::string gallery_scope{"global"};    // gallery.json produced by build_gallery

    // ── Output ───────────────────────────────────────────────────────────────
    std::string output_path;     // annotations.json
    Verbosity   verbosity{Verbosity::minimal};

    // When set, tee the embedder output to an HDF5 dump (schema:
    // scripts/optimizer/SCHEMA.md) for offline threshold-sweep replay via sae_kpn.
    std::string dump_embeddings_path;

    /// TRACES: VR-015 | PR-004
    // When set, write a per-node timing and bottleneck report here (src/
    // benchmark.hpp) and print it at shutdown. Costs one background thread
    // reading relaxed atomics on a timer, so it is safe to leave on, but a
    // measurement run should still be isolated (nothing else on the GPU).
    std::string benchmark_path;
    int         benchmark_interval_ms{100};   // channel-occupancy sampling period

    // ── Sampling ─────────────────────────────────────────────────────────────
    float  sample_fps{1.0f};     // frames to analyse per second of movie
    float  max_decode_fps{0.f};  // wall-clock cap on source decode rate (0 = uncapped)
    double start_sec{0.0};       // seek to this timestamp before sampling
    double end_sec{-1.0};        // stop at this timestamp (-1 = end of file)

    // ── Detection (SCRFD-500MF via cv::dnn::Net) ──────────────────────────────
    std::string detector_model;
    std::string detector_engine;       // optional path to pre-built TRT engine; bypasses ORT
    // TRACES: AR-003 | SR-002
    // 0 = no cap, the default. A fixed cap discards the SMALLEST faces first,
    // which are exactly the background cast X-Ray still credits with scene
    // membership. Per-frame cost is contained by backpressure (AR-004) rather
    // than by throwing work away. Set >0 only to bound a pathological source.
    int         max_faces{0};
    float       min_face_px{40.f};    // discard detections narrower or shorter than this
    float       detector_conf{0.5f};
    float       detector_nms{0.4f};

    /// TRACES: GR-004 | SR-001
    // Gallery ↔ embedder binding. A gallery built with a different model than the
    // one loaded here is a hard error, always. This flag additionally promotes
    // "cannot prove they match" (unstamped legacy gallery, or a name-only match
    // because the ONNX could not be hashed) from a loud warning to a hard error.
    // Also settable via SAE_REQUIRE_GALLERY_STAMP=1. Measurement runs want it on.
    bool require_gallery_stamp{false};   // --require-gallery-stamp

    // ── Recognition (ArcFace ONNX) ────────────────────────────────────────────
    std::string arcface_model;
    std::string arcface_engine;        // optional path to a pre-built TRT engine; bypasses ORT
    int         embed_batch_size{4};    // max faces per ORT Run() call — bounds per-call latency
    float       match_prior{0.433f};      // base-rate prior; 10-knob DE optimum (was 0.5)
    // Tuned by Differential Evolution against Amazon X-Ray per-second presence
    // over ALL 9 films (opencv5 build, LVFace-B_Glint360K, full gallery,
    // expansion on), a 10-parameter sweep — see docs/model-bakeoff.md. The
    // per-second misID-weighted macro-F1 optimum is 64.0% (P 79.0%, R 61.1%).
    //
    // This is a permissive operating point: the sweep discovered that with
    // flood-fill presence recovering recall, a LOW threshold pays off. It
    // supersedes the earlier 0.754, which came from a 4-film subset under the
    // now-withdrawn anneal/extinction windows and was never re-derived after a
    // scoring-bug fix. The full-9-film sweep at 0.485 beats it.
    //
    // Caveat, still true: the optimum generalises unevenly. It is strong on 7 of
    // 9 films (F1 62–80%) and weak on two — The Many Saints of Newark (an
    // ensemble of look-alikes; nearly all the run's misIDs land here) and
    // Scarface (sparse cuts, so flood-fill over-extends: R 95% / P 26%). Both
    // were the low outliers in every prior run too. Shipped because it wins on
    // average and on the misID-weighted objective; not a settled, film-agnostic
    // constant.
    float       prob_threshold{0.485f};   // posterior P(match | sim, prior) threshold
    // TRACES: AR-024 | SR-002
    // match_threshold (0.45), match_ratio (0.80) and match_ratio_ceil (0.65) are
    // RETIRED, joining track_max_embed_dist, cut_revive_sim, expand_novelty_sim
    // and expand_track_spread_max. All were raw cosine distances, and they were
    // the accept rule whenever the calibration fit failed — so the one situation
    // in which the pipeline knew its probabilities were untrustworthy was the
    // one in which it stopped using them. An unfitted sigmoid is now the
    // fallback everywhere, which is at least the same wrong number in every
    // stage. See identity_matcher_node.hpp.

    // ── Presence derivation ──────────────────────────────────────────────────
    // How accepted frames become a reported window. flood requires scene_detect.
    // Default flood: the 10-knob DE optimum uses it — snapping presence to the
    // shot recovers enough recall against X-Ray's scene-level cast to win the
    // misID-weighted F1, at a precision cost that is a net gain on 7 of 9 films.
    // Falls back to track_extent per claim when no boundaries exist. See
    // docs/model-bakeoff.md and PresenceMode above.
    PresenceMode presence_mode{PresenceMode::flood};

    // Path to the learned XGBoost scene-boundary model. When set (build has
    // SAE_SCENE_XGB), the camera-position node stamps a per-frame RGB histogram
    // and the sink runs the detector post-EOF to supply flood-fill boundaries —
    // the measured best flood boundary source (presence F1 ~76% vs ~64% for the
    // always-on histogram cut). Empty → flood falls back to is_cut.
    std::string  scene_xgb_model;

    // ── Cut detection ────────────────────────────────────────────────────────
    float cut_threshold{0.70f};         // grayscale histogram correlation below this → hard cut

    // ── Scene detection (TransNetV2, opt-in) ─────────────────────────────────
    // When enabled, the source decodes densely (native FPS) and a decimator
    // splits the stream: full-res 1-FPS frames to the face pipeline, and a
    // downscaled dense stream to the TransNetV2 scene detector. Shot boundaries
    // it finds are surfaced as Frame::is_scene_boundary. This is separate from
    // the always-on histogram cut, which flags intra-scene camera-angle changes.
    bool        scene_detect{false};        // master switch (--scene-detect)
    std::string scene_model;                // TransNetV2 .onnx (default set in main)
    std::string scene_engine;               // optional pre-built TRT .engine; bypasses ORT
    float       scene_threshold{0.60f};     // sigmoid boundary prob above this → boundary
                                            //   (this export's non-boundary baseline sits
                                            //    at ~0.50; real boundaries spike to ~0.7+)
    int         scene_stride{50};           // frames advanced between windows (≤ kWindow)

    // Dense-decode knobs (only active with scene_detect). Dense decode of every
    // native-rate frame is the pipeline's cost driver, which is what made the
    // temporal shortcut below tempting.
    /// TRACES: AR-011 | SR-002
    //   scene_decode_fps: rate the source decodes at in dense mode.
    //     **0 = native, and native is the only correct setting.** kWindow is 100
    //     frames: at native 25 fps that window spans ~4 s, which is what
    //     TransNetV2 was trained on; at the 12 fps this used to default to it
    //     spans ~8.3 s, so the model saw half-speed motion over twice its
    //     temporal context. Boundary *timestamps* stay right either way — which
    //     is exactly why the degradation was invisible, and why the compressed
    //     separation it produced (~0.50 baseline against ~0.7+ peaks) was read
    //     as a property of the export rather than of the input. Lowering this
    //     buys decode time by running the model off-distribution; reach for
    //     dense_scale or scene_stride instead, which do not.
    //   dense_scale: downscale factor applied to decoded frames in dense mode
    //     (0<f≤1; e.g. 0.5 = half size). Cheaper sws_scale + smaller frames
    //     through the fanout. A spatial reduction, and TransNetV2 downsamples to
    //     48×27 regardless, so unlike the above it is a documented, understood
    //     degradation. NOTE: also shrinks what the face detector sees — keep
    //     ≥0.5 on 1080p sources so SCRFD still resolves small faces. 1 = off.
    float       scene_decode_fps{0.f};      // dense decode rate (0 = native)
    float       dense_scale{1.0f};          // dense-mode frame downscale (1 = off)

    // ── Face tracking (frame-to-frame) ───────────────────────────────────────
    /// TRACES: AR-007, AR-008, AR-024 | SR-002
    // track_alpha is the *base* weight, used on ordinary frames. It is
    // frame-dependent (AR-007): on is_cut / is_scene_boundary, and for any track
    // that is no longer on screen, it drops to 0 (embedding only), because
    // position carries no information across a viewpoint change or a gap.
    float track_alpha{0.435f};          // base cost weight: 0=embedding only, 1=spatial only (10-knob DE optimum)
    float track_min_iou{0.1f};          // IoU below which spatial link alone is rejected
    // Minimum P(same person) for an association to be admissible on appearance
    // alone. This replaces track_max_embed_dist (a raw cosine distance, AR-024).
    // 0.5 is not a tuned constant: it is the decision boundary. Below it the pair
    // is more likely two people than one, and no amount of IoU makes that a link
    // worth asserting on identity grounds.
    float track_assoc_min_prob{0.5f};
    // How long a track that has gone off screen stays available for association
    // before the registry reaps it and emits its presence claim (AR-013).
    // Replaces track_max_frames_missing: a frame count silently changed meaning
    // with sample_fps, and the same number had to be guessed twice (once for an
    // ordinary miss, once for a cut). Seconds mean one thing at any sample rate.
    double track_extinction_sec{31.0};   // 10-knob DE optimum (was 5.0)

    // ── Ownership and evidence accumulation (AR-025) ──────────────────────────
    // TRACES: AR-025, AR-017 | SR-002
    // These four decided how presence is claimed and were unreachable: they
    // lived as in-class initialisers on TrackRegistry::Config and
    // EvidenceDiscounter::Config, and main constructed the discounter with the
    // one-argument constructor, so nothing short of a recompile could move
    // them. rho_max's own comment defers to "the sweep (VR-007)" for where it
    // belongs — a sweep that could not reach it.
    //
    // ownership_logodds is arguably the most consequential constant in the
    // pipeline after prob_threshold: below it a track produces no presence
    // claim at all, so it decides whether an actor is reported rather than how
    // confidently. 1.72 is a posterior of ~0.85 — the 10-knob DE optimum (was
    // an unswept 2.0 ≈ 0.88); slightly more permissive, consistent with the
    // low-threshold operating point the sweep converged on.
    float  ownership_logodds{1.72f};

    // How much a single observation may move a track's belief. n_eff =
    // n / (1 + (n-1)·rho), so rho_max caps what a repeated view can ever be
    // worth: 0.5 caps it at two independent observations however long the shot
    // runs. It is deliberately below 1 — a held pose still yields a fresh
    // detection, alignment and noise realisation, so a little independent
    // evidence survives. Setting it to 1 freezes belief after the first frame,
    // which is the bug this replaced.
    float  evidence_rho_max{0.204f};   // 10-knob DE optimum (was 0.5): weights a
                                       // held pose closer to a single observation
    // P(same view) below this and the observation counts as a genuinely new
    // look, so it joins the per-track view set.
    float  evidence_admit_below{0.784f};  // 10-knob DE optimum (was 0.6)
    // Distinct views remembered per track, which bounds the novelty comparison.
    int    evidence_max_views{8};

    // ── Scene tracking ────────────────────────────────────────────────────────
    // TRACES: AR-012, AR-013 | SR-002
    // extinction_sec (57.4) and anneal_sec (35.5) are GONE, along with
    // SceneTrackerFunc, which is what read the first of them. docs/SPEC.md
    // specified this removal and ended it "grep for both names and expect no
    // survivors"; there were about forty, and the register meanwhile recorded
    // both as Withdrawn and "deleted rather than retained at zero" on the
    // grounds that a field naming a mechanism the pipeline no longer has is
    // actively misleading.
    //
    // Both existed to bridge gaps between isolated accepted frames. A track
    // that survives its own gaps leaves them nothing to do: AR-012 makes a
    // window the extent of a track an actor owns, and AR-013 ends it at the
    // last sighting. The keep-alive answered the same question again and
    // answered it worse, by re-opening exactly the trailing cool-down AR-013
    // refuses.
    //
    // track_extinction_sec above is NOT the same knob under a new name. It
    // bounds how long a lost track stays available for re-association, which is
    // a tracking question; it never extends a presence claim.

    // ── Per-film gallery expansion ────────────────────────────────────────────
    // Within one uncut track every face is the same physical person — a free
    // same-identity label the baked gallery lacks. When a track is confidently
    // owned by an actor, its gallery-far (pose-varied) embeddings are validated
    // new reference views; they are promoted into a per-film, in-memory annex so
    // later frames/tracks of that actor at similar poses recognise. See
    // gallery/track_gallery.hpp.
    // Default ON: the rep4 matrix (docs/model-bakeoff.md, "Two effects in
    // isolation") found expansion helps recall on the full (unrestricted)
    // gallery for the winning model/mode — the opposite of the earlier
    // assumption that it only helps restricted galleries. The same section is
    // explicit that on the full gallery it buys +2.1pp F1 and +3.9pp recall
    // "at a real cost" in misIDs, where in restricted mode it is a clean win.
    bool   expand_gallery{true};         // master switch
    int    expand_buffer_size{20};       // per-track diversity buffer capacity
    // TRACES: AR-018, AR-024 | SR-005
    // Banded admission for the per-subject store, in PROBABILITY space. An
    // embedding joins only if P(same person) against something already stored
    // lands inside [lo, hi]: above hi it is redundant, below lo it is evidence
    // the track is not one person. The same lo is re-applied to the whole store
    // at promotion time — see track_gallery.hpp. This is the only threshold the
    // expansion path has: it replaces the raw-cosine expand_novelty_sim (0.55)
    // and expand_track_spread_max (0.60), which are retired (AR-024).
    // 10-knob DE optimum (was 0.90/0.95). The sweep widened the band — a lower lo
    // admits more pose-varied views into the annex — which the optimum preferred.
    float  expand_band_lo{0.804f};
    float  expand_band_hi{0.952f};
    int    expand_min_anchor_frames{3};  // require ≥N accepted frames naming the actor before
                                         //   the track is confirmed and its buffer promoted
    std::string expand_debug_dir;        // if set, dump promoted mugshots + embeddings here

    // ── Inference backend tuning ────────────────────────────────────────────
    // Consumed by the compiled-in inference backend (ORT or TRT).
    // INT8 is unsafe for ArcFace without a calibration table.
    BackendConfig trt{};   // fp16=true, int8=false, cache_dir="./trt_cache"

    // ── Debug output (only used when SAE_DEBUG is defined) ───────────────────
#ifdef SAE_DEBUG
    std::string debug_dir{"debug_frames"};
    float       crop_context{1.5f};   // bbox expansion factor for context crop
#endif
};
