//! `pounce` — command-line driver for the POUNCE solver.
//!
//! Output is structured to mirror upstream `ipopt`'s console layout:
//! a banner, a problem-statistics block, the per-iteration table, and
//! a final residual / eval-count summary. The intent is that anyone
//! used to reading `ipopt` output can drop in `pounce` without
//! relearning where the numbers live.
//!
//! Exit status: 0 on `Solve_Succeeded`, non-zero otherwise. In AMPL
//! solver mode (`-AMPL`) the exit code instead follows the AMPL
//! contract — 0 for any solve that ran and produced a `.sol`, since
//! the termination is carried by the file's `solve_result_num`.

use pounce_algorithm::alg_builder::{LinearBackendFactory, LinearSolverChoice};
use pounce_algorithm::application::IpoptApplication;
use pounce_cli::builtin;
use pounce_cli::cli::{Args, ProblemSource};
use pounce_cli::counting_tnlp::CountingTnlp;
use pounce_cli::nl_reader;
use pounce_cli::nl_writer;
use pounce_cli::print;
use pounce_cli::sens;
use pounce_cli::solve_report::{
    status_to_solve_result_num, write_report_file, InputDescriptor, ReportBuilder, ReportDetail,
    SolutionSuffix,
};
use pounce_common::diagnostics::{
    DiagCategory, DiagnosticsConfig, DiagnosticsState, DumpFormat, IterSpec,
};
use pounce_linsol::sparse_sym_iface::SparseSymLinearSolverInterface;
use pounce_nlp::return_codes::ApplicationReturnStatus;
use pounce_nlp::tnlp::TNLP;
use pounce_restoration::resto_alg_builder::RestoAlgorithmBuilder;
use pounce_restoration::resto_inner_solver::{
    make_default_restoration_factory_provider, InnerBackendFactoryFactory,
};
use std::cell::RefCell;
use std::path::PathBuf;
use std::process::ExitCode;
use std::rc::Rc;

pub fn main() -> ExitCode {
    // Install the tracing subscriber first so even argument-parse
    // diagnostics and the iteration collector are active (pounce#71).
    // Honors RUST_LOG, NO_COLOR, and POUNCE_LOG_FORMAT.
    pounce_observability::init_subscriber();

    let args = match Args::parse_argv(std::env::args().collect()) {
        Ok(a) => a,
        Err(msg) => {
            eprintln!("pounce: {msg}");
            eprintln!("{}", Args::usage());
            return ExitCode::from(2);
        }
    };

    if args.help {
        println!("{}", Args::usage());
        return ExitCode::SUCCESS;
    }
    if args.version {
        println!("pounce {}", env!("CARGO_PKG_VERSION"));
        return ExitCode::SUCCESS;
    }
    if args.about {
        print_about();
        return ExitCode::SUCCESS;
    }

    let mut app = IpoptApplication::new();

    // Opt into iter-history capture when the user asked for a JSON
    // report at Full detail — saves the per-iter alloc when they
    // didn't.
    if args.json_output.is_some() && matches!(args.json_detail, ReportDetail::Full) {
        app.enable_iter_history();
    }

    if let Some(path) = &args.options_file {
        if let Err(e) = app.initialize_with_options_file(path) {
            eprintln!("pounce: failed to load options file: {e}");
            return ExitCode::from(2);
        }
    } else if let Err(e) = app.initialize() {
        eprintln!("pounce: initialize failed: {e}");
        return ExitCode::from(2);
    }

    // Apply CLI `key=value` overrides after initialization, mirroring
    // how upstream's ipopt CLI lets command-line options override the
    // ipopt.opt file. Routed through `read_from_str` so the type
    // coercion (string / number / integer) matches the options-file
    // parser exactly.
    for (k, v) in &args.set_options {
        let line = format!("{k} {v}\n");
        if let Err(e) = app.options_mut().read_from_str(&line, true) {
            eprintln!("pounce: failed to set {k}={v}: {e}");
            return ExitCode::from(2);
        }
    }

    // Wire the restoration phase. Without this, any line-search failure
    // surfaces as `RestorationFailure` instead of falling back into the
    // ℓ1-feasibility sub-IPM. Mirrors what upstream's `IpAlgBuilder`
    // does unconditionally for every solve.
    //
    // Capture the feral config off the now-fully-loaded options so the
    // restoration sub-IPM honors the same `feral_*` overrides (e.g.
    // `feral_cascade_break yes` from an `--options-file`) as the main
    // IPM. Snapshot, not borrow: the BFF outlives the option-mutation
    // window we cleanly own here.
    let feral_cfg = pounce_algorithm::application::feral_config_from_options(app.options());
    // Use the multi-pass provider so the ℓ₁ wrapper (`l1_exact_penalty_barrier`)
    // and the auto-fallback (`l1_fallback_on_restoration_failure`) don't
    // panic with "restoration factory invoked more than once" on their
    // second inner solve — see pounce#10 Phase 3 / pounce#24.
    let bff_mint = move || -> InnerBackendFactoryFactory {
        Box::new(move || default_backend_factory(feral_cfg))
    };
    // Hand the inner IPM a builder mirroring the outer options so its
    // `mu_strategy` (adaptive vs. monotone) inherits the user's choice —
    // matches upstream `IpAlgBuilder::BuildRestoIpoptAlgorithm`.
    let resto_provider = make_default_restoration_factory_provider(
        RestoAlgorithmBuilder::new(),
        app.algorithm_builder_from_options(),
        bff_mint,
    );
    app.set_restoration_factory_provider(resto_provider);

    // Branded logo + copyright banner, printed up-front — before the
    // problem is even read — so they head the output. The registered
    // default for `linear_solver` mirrors upstream IPOPT (`"ma57"`), but
    // pounce's actual backend is FERAL; only treat `"ma57"` as user
    // intent when explicitly set, else the banner would always claim
    // "ma57 requested". `sb yes` suppresses both (mirrors upstream
    // `IpoptApplication::Initialize`).
    let backend_tag = {
        let (v, explicit) = app
            .options()
            .get_string_value("linear_solver", "")
            .unwrap_or_else(|_| ("feral".to_string(), false));
        match (v.as_str(), explicit) {
            ("ma57", true) => {
                #[cfg(feature = "ma57")]
                {
                    "MA57 (HSL)"
                }
                #[cfg(not(feature = "ma57"))]
                {
                    "FERAL (ma57 requested but not compiled)"
                }
            }
            ("ma57", false) => "FERAL",
            _ => "FERAL",
        }
    };
    let suppress_banner = app
        .options()
        .get_bool_value("sb", "")
        .ok()
        .and_then(|(v, f)| f.then_some(v))
        .unwrap_or(false);
    if !suppress_banner {
        print::print_logo();
        print::print_banner(backend_tag);
    }

    // Snapshot the problem source as a string — needed downstream by
    // the diagnostics manifest.
    let problem_desc: String = match &args.problem {
        ProblemSource::Builtin(s) => format!("builtin:{s}"),
        ProblemSource::NlFile(p) => format!("nl:{}", p.display()),
    };

    // Resolve where (if anywhere) to write an AMPL `.sol` solution
    // file. AMPL solver convention: a `.nl` input gets a sibling
    // `<stub>.sol` unless suppressed. Builtins have no stub on disk,
    // so they only produce a `.sol` when `--sol-output` is explicit.
    let sol_path: Option<PathBuf> = if args.no_sol {
        None
    } else if let Some(p) = &args.sol_output {
        Some(p.clone())
    } else {
        match &args.problem {
            ProblemSource::NlFile(p) => {
                let mut s = p.clone();
                s.set_extension("sol");
                Some(s)
            }
            ProblemSource::Builtin(_) => None,
        }
    };

    // Load the problem. For `.nl` inputs, keep the parsed suffixes and
    // dimensions around: the sIPOPT-style suffixes (`sens_state_1` …)
    // drive the post-optimal sensitivity step below, and they must be
    // read off `NlProblem` before `NlTnlp` consumes it.
    let mut nl_suffixes: Option<nl_reader::NlSuffixes> = None;
    let mut nl_dims: Option<(usize, usize)> = None;
    // `nl_expr_provider` shadows `inner_tnlp` for the `.nl`-file path:
    // both point at the same `NlTnlp`, but the second handle is typed
    // as `dyn ExpressionProvider` so the presolve wrapper can use it
    // for FBBT (issue #62). For built-in problems we leave it `None`.
    let mut nl_expr_provider: Option<
        Rc<RefCell<dyn pounce_nlp::expression_provider::ExpressionProvider>>,
    > = None;
    let inner_tnlp: Rc<RefCell<dyn TNLP>> = match &args.problem {
        ProblemSource::Builtin(name) => match builtin::lookup(name) {
            Some(t) => t,
            None => {
                eprintln!("pounce: unknown builtin problem '{name}'");
                eprintln!("available: {}", builtin::list().join(", "));
                return ExitCode::from(2);
            }
        },
        ProblemSource::NlFile(path) => {
            println!("Reading {}...", path.display());
            let t0 = std::time::Instant::now();
            match nl_reader::read_nl_file(path) {
                Ok(prob) => {
                    nl_suffixes = Some(prob.suffixes.clone());
                    nl_dims = Some((prob.n, prob.m));
                    let elapsed = t0.elapsed().as_secs_f64();
                    let nl_rc = Rc::new(RefCell::new(nl_reader::NlTnlp::new(prob)));
                    nl_expr_provider = Some(Rc::clone(&nl_rc)
                        as Rc<RefCell<dyn pounce_nlp::expression_provider::ExpressionProvider>>);
                    let t: Rc<RefCell<dyn TNLP>> = nl_rc;
                    if let Some(info) = t.borrow_mut().get_nlp_info() {
                        println!(
                            "Parsed {} vars, {} cons, jac_nnz={}, h_nnz={} in {:.2}s",
                            info.n, info.m, info.nnz_jac_g, info.nnz_h_lag, elapsed
                        );
                    }
                    t
                }
                Err(e) => {
                    eprintln!("pounce: failed to read {}: {e}", path.display());
                    return ExitCode::from(2);
                }
            }
        }
    };

    // Does the `.nl` ask for a parametric sensitivity step? When it
    // does, the post-optimal step runs inside `on_converged` below and
    // its result is written back as the `sens_sol_state_1` suffix.
    let sens_active = nl_suffixes
        .as_ref()
        .map(sens::is_sensitivity_input)
        .unwrap_or(false);

    // Capture the converged primal / dual into `nominal_capture` so the
    // JSON report and `.sol` below can ship `solution.x` /
    // `solution.lambda`. The same callback runs the suffix-driven
    // post-processing: the parametric sensitivity step
    // (`sens_sol_state_1`) and the reduced-Hessian computation.
    let nominal_capture: Rc<
        RefCell<
            Option<(
                Vec<pounce_common::types::Number>,
                Vec<pounce_common::types::Number>,
            )>,
        >,
    > = Rc::new(RefCell::new(None));
    let sens_capture: Rc<RefCell<Option<Vec<pounce_common::types::Number>>>> =
        Rc::new(RefCell::new(None));
    let red_hessian_capture: Rc<RefCell<Option<sens::RedHessianResult>>> =
        Rc::new(RefCell::new(None));
    if args.json_output.is_some() || sol_path.is_some() || sens_active || args.compute_red_hessian {
        let cap = Rc::clone(&nominal_capture);
        let sens_cap = Rc::clone(&sens_capture);
        let rh_cap = Rc::clone(&red_hessian_capture);
        let suffixes_cb = nl_suffixes.clone();
        let dims_cb = nl_dims;
        let compute_rh = args.compute_red_hessian;
        let rh_eigen = args.rh_eigendecomp;
        let boundcheck_eps = args.sens_boundcheck.then_some(args.sens_bound_eps);
        app.set_on_converged(Box::new(move |data, cq, nlp, pd| {
            let curr = match data.borrow().curr.clone() {
                Some(c) => c,
                None => return,
            };
            // Lift to full length so a fixed / eliminated variable
            // still occupies its slot — AMPL's `.sol` reader matches
            // the x block against the originating `.nl`'s var count.
            let x = nlp.borrow().lift_x_to_full(&*curr.x);
            let n_c = curr.y_c.dim() as usize;
            let n_d = curr.y_d.dim() as usize;
            let mut lambda = Vec::with_capacity(n_c + n_d);
            if let Some(dv) = curr
                .y_c
                .as_any()
                .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
            {
                lambda.extend_from_slice(&dv.expanded_values());
            } else {
                lambda.extend(std::iter::repeat(0.0).take(n_c));
            }
            if let Some(dv) = curr
                .y_d
                .as_any()
                .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
            {
                lambda.extend_from_slice(&dv.expanded_values());
            } else {
                lambda.extend(std::iter::repeat(0.0).take(n_d));
            }
            *cap.borrow_mut() = Some((x.clone(), lambda));

            // Suffix-driven post-processing on the converged KKT
            // system: the parametric sensitivity step and (on request)
            // the reduced Hessian.
            if let Some(suffixes) = &suffixes_cb {
                let (n_full, m_full) = dims_cb.unwrap_or((x.len(), 0));
                if sens_active {
                    if let Some(xp) = sens::compute_sens_perturbed_x(
                        data,
                        cq,
                        nlp,
                        Rc::clone(&pd),
                        suffixes,
                        n_full,
                        m_full,
                        &x,
                        boundcheck_eps,
                    ) {
                        *sens_cap.borrow_mut() = Some(xp);
                    }
                }
                if compute_rh {
                    match sens::try_compute_red_hessian(
                        data,
                        cq,
                        nlp,
                        Rc::clone(&pd),
                        suffixes,
                        rh_eigen,
                    ) {
                        Some(r) => *rh_cap.borrow_mut() = Some(r),
                        None => eprintln!(
                            "pounce: --compute-red-hessian requested but the `red_hessian` \
                             suffix is missing or empty in the input .nl"
                        ),
                    }
                }
            }
        }));
    }

    // Optionally wrap with presolve before counting so eval-call
    // counts reflect what the solver actually issues.
    let mut presolve_opts = match pounce_presolve::PresolveOptions::from_options_list(app.options())
    {
        Ok(o) => o,
        Err(e) => {
            eprintln!("pounce: presolve setup failed: {e}");
            return ExitCode::from(2);
        }
    };
    // Sensitivity / reduced-Hessian post-processing reads the converged
    // KKT system and indexes it with suffixes defined against the
    // original `.nl`. Presolve tightens bounds and drops rows, which
    // would shift that indexing — so disable it when either is active.
    if (sens_active || args.compute_red_hessian) && presolve_opts.enabled {
        eprintln!(
            "pounce: disabling presolve — sensitivity / reduced-Hessian post-processing \
             operates on the original (un-presolved) KKT system"
        );
        presolve_opts.enabled = false;
    }
    let presolve_handle = if presolve_opts.enabled {
        let p = Rc::new(RefCell::new(match &nl_expr_provider {
            Some(ep) => pounce_presolve::PresolveTnlp::with_expression_provider(
                Rc::clone(&inner_tnlp),
                Rc::clone(ep),
                presolve_opts,
            ),
            None => pounce_presolve::PresolveTnlp::new(Rc::clone(&inner_tnlp), presolve_opts),
        }));
        // Force the lazy init now so we can print a one-line summary.
        let _ = p.borrow_mut().get_nlp_info();
        {
            let h = p.borrow();
            let tr = h.tighten_report();
            let dropped = h.n_dropped_rows();
            let licq = h
                .licq_verdict()
                .map(|v| format!("{v:?}"))
                .unwrap_or_else(|| "off".into());
            println!(
                "Presolve: tightened {} bounds ({} newly-finite), dropped {} redundant rows, LICQ={}",
                tr.n_tightened, tr.n_new_finite, dropped, licq
            );
            if let Some(fr) = h.fbbt_report() {
                println!(
                    "Presolve FBBT: {} sweeps, {} variable tightenings (Σ|Δ|={:.3e})",
                    fr.iterations, fr.bound_updates, fr.total_tightening
                );
                if let Some(witness) = fr.infeasibility_witness {
                    eprintln!("pounce: FBBT detected infeasibility (witness constraint {witness})");
                }
            }
        }
        Some(p)
    } else {
        None
    };
    let post_presolve: Rc<RefCell<dyn TNLP>> = match &presolve_handle {
        Some(p) => Rc::clone(p) as Rc<RefCell<dyn TNLP>>,
        None => Rc::clone(&inner_tnlp),
    };

    // Wrap so we can pull eval-call counts out for the final summary.
    let counting = Rc::new(RefCell::new(CountingTnlp::new(Rc::clone(&post_presolve))));
    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::clone(&counting) as Rc<RefCell<dyn TNLP>>;

    // Problem statistics. (The branded logo + copyright banner print
    // up-front, before the problem is read — see near the top of `run`.)
    if let Some(stats) = print::collect_stats(&tnlp) {
        print::print_problem_stats(&stats);
    }

    // Build diagnostics state from `--dump …` flags. None of these
    // flags is required, but `--dump-dir` / `--dump-format` on their
    // own (no `--dump <cat>`) yields an empty config and we skip
    // installation entirely — there's nothing to write.
    let diagnostics_handle = match build_diagnostics(
        &args.dump_specs,
        args.dump_dir.as_ref(),
        args.dump_format.as_deref(),
    ) {
        Ok(d) => d,
        Err(msg) => {
            eprintln!("pounce: {msg}");
            return ExitCode::from(2);
        }
    };
    if let Some(diag) = diagnostics_handle.as_ref() {
        println!(
            "Diagnostics: dumping to {} ({} categor{} configured)",
            diag.dump_dir().display(),
            diag.config.categories.len(),
            if diag.config.categories.len() == 1 {
                "y"
            } else {
                "ies"
            },
        );
        app.set_diagnostics(Rc::clone(diag));
    }

    // Snapshot NLP dimensions before the solve so we can use them in
    // both the console summary and the JSON report. Borrowing here is
    // safe because we hold no outstanding borrow on the counting
    // wrapper yet.
    let nlp_info_snapshot = tnlp.borrow_mut().get_nlp_info();

    let status = app.optimize_tnlp(Rc::clone(&tnlp));
    let solve_stats = app.statistics();
    let counters = counting.borrow();
    print::print_summary(status, &solve_stats, &counters);
    drop(counters); // release before JSON block (which re-borrows the wrapped TNLP).

    // Reduced Hessian: print to stderr (informational), mirroring
    // upstream sIPOPT's RedHessian / Eigenvalues prints in
    // `SensReducedHessianCalculator.cpp`.
    if let Some(rh) = red_hessian_capture.borrow().as_ref() {
        sens::print_red_hessian_to_stderr(rh);
    } else if args.compute_red_hessian {
        eprintln!(
            "pounce: --compute-red-hessian requested but the reduced Hessian \
             was not produced (see warnings above)."
        );
    }

    // Assemble the AMPL `.sol` suffix blocks. The parametric
    // sensitivity step contributes `sens_sol_state_1` (the perturbed
    // primal) when the `.nl` declared the sIPOPT suffixes.
    let mut sol_suffixes: Vec<nl_writer::SolSuffix> = Vec::new();
    if let Some(xp) = sens_capture.borrow().clone() {
        sol_suffixes.push(nl_writer::SolSuffix {
            name: "sens_sol_state_1".to_string(),
            target: nl_writer::SolSuffixTarget::Var,
            values: nl_writer::SolSuffixValues::Real(xp),
        });
    }

    // Emit the JSON solve report, when requested. Written AFTER the
    // console summary so a piped `pounce ... --json-output -` reader
    // could be wired up later without disturbing stdout (today we
    // write to a path; stdout-mode is a follow-up).
    if let Some(json_path) = &args.json_output {
        let input = match &args.problem {
            ProblemSource::Builtin(name) => InputDescriptor::Builtin { name: name.clone() },
            ProblemSource::NlFile(p) => InputDescriptor::NlFile {
                path: p.clone(),
                size_bytes: std::fs::metadata(p).ok().map(|m| m.len()),
            },
        };
        let mut builder = ReportBuilder::new(args.json_detail, input);
        if let Some(info) = nlp_info_snapshot {
            builder.problem.n_variables = info.n;
            builder.problem.n_constraints = info.m;
            builder.problem.n_objectives = 1; // pounce IPM uses obj 0; multi-obj is read but ignored
            builder.problem.nnz_jac_g = Some(info.nnz_jac_g);
            builder.problem.nnz_h_lag = Some(info.nnz_h_lag);
        }
        builder.solution.status = status;
        builder.solution.solve_result_num = status_to_solve_result_num(status);
        builder.solution.objective = solve_stats.final_objective;
        if let Some((x, lambda)) = nominal_capture.borrow().clone() {
            builder.solution.x = x;
            builder.solution.lambda = lambda;
        }
        builder.ingest_stats(&solve_stats);
        if let Some(linsol) = app.linear_solver_summary() {
            builder.set_linear_solver_summary(linsol);
        }

        // `Full` detail carries the suffix blocks: the sensitivity
        // result and, when computed, the reduced Hessian (packed as
        // problem-level suffixes — see `pounce-cli`'s sens module).
        if matches!(args.json_detail, ReportDetail::Full) {
            for s in &sol_suffixes {
                builder
                    .solution
                    .suffixes
                    .push(sens::sol_suffix_to_report(s));
            }
            if let Some(rh) = red_hessian_capture.borrow().as_ref() {
                builder.solution.suffixes.push(SolutionSuffix {
                    name: "_red_hessian".to_string(),
                    target: "problem".to_string(),
                    kind: "real".to_string(),
                    values: rh.hr.clone(),
                    int_values: Vec::new(),
                });
                builder.solution.suffixes.push(SolutionSuffix {
                    name: "_red_hessian_vars".to_string(),
                    target: "problem".to_string(),
                    kind: "int".to_string(),
                    values: Vec::new(),
                    int_values: rh.var_indices.iter().map(|&v| v as i32).collect(),
                });
                if let Some(w) = &rh.eigenvalues {
                    builder.solution.suffixes.push(SolutionSuffix {
                        name: "_red_hessian_eigenvalues".to_string(),
                        target: "problem".to_string(),
                        kind: "real".to_string(),
                        values: w.clone(),
                        int_values: Vec::new(),
                    });
                }
                if let Some(v) = &rh.eigenvectors {
                    builder.solution.suffixes.push(SolutionSuffix {
                        name: "_red_hessian_eigenvectors".to_string(),
                        target: "problem".to_string(),
                        kind: "real".to_string(),
                        values: v.clone(),
                        int_values: Vec::new(),
                    });
                }
            }
        }

        let report = builder.finish();
        if let Err(e) = write_report_file(json_path, &report) {
            eprintln!(
                "pounce: failed to write JSON report to {}: {e}",
                json_path.display()
            );
        } else {
            eprintln!("pounce: wrote {}", json_path.display());
        }
    }

    // Emit the AMPL `.sol` file. Written unconditionally once a target
    // path is resolved — even on a failed solve — so AMPL's reader
    // always sees a `solve_result_num`, matching `pounce_sens` and
    // upstream AMPL solver behaviour. When the solve never converged
    // the capture is empty; fall back to zero blocks sized from the
    // pre-solve NLP dimensions so the file still round-trips.
    if let Some(sol_path) = &sol_path {
        let (n, m) = nlp_info_snapshot
            .as_ref()
            .map(|i| (i.n as usize, i.m as usize))
            .unwrap_or((0, 0));
        let (x, lambda) = nominal_capture
            .borrow()
            .clone()
            .unwrap_or_else(|| (vec![0.0; n], vec![0.0; m]));
        let message = format!("POUNCE {}: {status:?}", env!("CARGO_PKG_VERSION"));
        let payload = nl_writer::SolutionFile {
            message: &message,
            x: &x,
            lambda: &lambda,
            solve_result_num: status_to_solve_result_num(status),
            suffixes: &sol_suffixes,
        };
        match nl_writer::write_sol_file(sol_path, &payload) {
            Ok(_) => eprintln!("pounce: wrote {}", sol_path.display()),
            Err(e) => eprintln!("pounce: failed to write {}: {e}", sol_path.display()),
        }
    }

    // After the solve, drop a manifest + timing summary at the dump
    // root so consumers (and humans) can tell which run produced
    // which artifacts without reading the iter_NNN/ tree.
    if let Some(diag) = diagnostics_handle.as_ref() {
        write_diagnostics_manifest(diag, &problem_desc, status);
        write_diagnostics_timing(diag, &app);
    }

    match status {
        ApplicationReturnStatus::SolveSucceeded
        | ApplicationReturnStatus::SolvedToAcceptableLevel => ExitCode::SUCCESS,
        // AMPL solver-protocol mode: the process exit code is not the
        // status channel. AMPL and Pyomo's ASL interface read the
        // termination from the `.sol` file's `solve_result_num`, and
        // conventionally an AMPL solver exits 0 whenever it ran and
        // produced a `.sol` — limit reached, infeasible, even a failed
        // solve. A non-zero exit makes Pyomo raise `ApplicationError`
        // and never parse the `.sol`. Genuine startup failures (bad
        // `.nl`, bad option) already returned non-zero above, before
        // the solve, so reaching here in `-AMPL` mode means a `.sol`
        // was written and carries the verdict.
        _ if args.ampl => ExitCode::SUCCESS,
        _ => ExitCode::from(1),
    }
}

/// Translate the CLI's `--dump …` flags into a live `DiagnosticsState`.
/// Returns `Ok(None)` when no `--dump <cat>` was given (the `--dump-dir`
/// / `--dump-format` flags alone don't activate dumping).
fn build_diagnostics(
    dump_specs: &[(String, String)],
    dump_dir: Option<&std::path::PathBuf>,
    dump_format: Option<&str>,
) -> Result<Option<Rc<DiagnosticsState>>, String> {
    if dump_specs.is_empty() {
        if dump_dir.is_some() || dump_format.is_some() {
            return Err(
                "--dump-dir / --dump-format require at least one --dump <cat>[:spec]".to_string(),
            );
        }
        return Ok(None);
    }

    let dump_dir = dump_dir.cloned().unwrap_or_else(|| {
        let secs = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        std::path::PathBuf::from(format!("pounce-dump-{secs}"))
    });

    let format = match dump_format {
        Some(f) => DumpFormat::parse(f)?,
        None => DumpFormat::Jsonl,
    };

    let mut config = DiagnosticsConfig::new(dump_dir);
    config.format = format;
    for (cat_str, spec_str) in dump_specs {
        let cat = DiagCategory::parse(cat_str)?;
        if cat == DiagCategory::Iterate {
            // `iterate:` accepts an extra `:summary` / `:full` variant
            // suffix after the iter filter. See parse_iterate_spec.
            let (filter, variant) = pounce_common::diagnostics::parse_iterate_spec(spec_str)?;
            config = config
                .with_category(cat, filter)
                .with_iterate_variant(variant);
        } else if cat == DiagCategory::Kkt {
            // `kkt:` accepts `+L` / `+L+Lvals` suffixes that pick up
            // the LDLᵀ factor's pattern (and optionally values). See
            // parse_kkt_spec.
            let (filter, variant) = pounce_common::diagnostics::parse_kkt_spec(spec_str)?;
            config = config.with_category(cat, filter).with_kkt_variant(variant);
        } else {
            let spec = IterSpec::parse(spec_str)?;
            config = config.with_category(cat, spec);
        }
    }

    let state = DiagnosticsState::new(config)
        .map_err(|e| format!("could not create dump directory: {e}"))?;
    Ok(Some(Rc::new(state)))
}

/// Drop a minimal JSON manifest summarising the run. Lets downstream
/// tools (and humans) join a dump directory back to its CLI args
/// without re-reading the per-iter files.
fn write_diagnostics_manifest(
    diag: &DiagnosticsState,
    problem_desc: &str,
    status: ApplicationReturnStatus,
) {
    let mut cats: Vec<String> = diag
        .config
        .categories
        .iter()
        .map(|(c, s)| format!("\"{}\":\"{:?}\"", c.as_str(), s))
        .collect();
    cats.sort();
    let manifest = format!(
        "{{\n  \"pounce_version\": \"{ver}\",\n  \"git\": \"{git}\",\n  \"problem\": \"{problem}\",\n  \"status\": \"{status:?}\",\n  \"format\": \"{fmt:?}\",\n  \"categories\": {{ {cats} }}\n}}\n",
        ver = env!("CARGO_PKG_VERSION"),
        git = env!("POUNCE_BUILD_GIT"),
        problem = problem_desc,
        fmt = diag.config.format,
        cats = cats.join(", "),
    );
    let _ = diag.write_top_level("manifest.json", &manifest);
}

/// Emit a sibling `timing.json` so dump consumers can correlate
/// per-iter files with the solve's wall-clock budget.
fn write_diagnostics_timing(diag: &DiagnosticsState, app: &IpoptApplication) {
    let t = app.timing_stats();
    let body = format!(
        "{{\n  \"overall_alg_secs\": {a:.6},\n  \"linear_system_factorization_secs\": {f:.6},\n  \"linear_system_back_solve_secs\": {b:.6}\n}}\n",
        a = t.overall_alg.total_wallclock_time(),
        f = t.linear_system_factorization.total_wallclock_time(),
        b = t.linear_system_back_solve.total_wallclock_time(),
    );
    let _ = diag.write_top_level("timing.json", &body);
}

/// `--about` output: version, build provenance, compiled-in features,
/// available linear-solver backends, and runtime paths. Intended for
/// bug reports — every field that distinguishes one build from another
/// should appear here.
fn print_about() {
    let pkg_ver = env!("CARGO_PKG_VERSION");
    let git = env!("POUNCE_BUILD_GIT");
    let when = env!("POUNCE_BUILD_TIME");
    let profile = env!("POUNCE_BUILD_PROFILE");
    let target = env!("POUNCE_BUILD_TARGET");
    let host = env!("POUNCE_BUILD_HOST");
    let rustc = env!("POUNCE_BUILD_RUSTC");

    println!("pounce {pkg_ver} (commit {git}, built {when})");
    println!();
    println!("Build:");
    println!("  profile:        {profile}");
    println!("  target:         {target}");
    if host != target {
        println!("  host:           {host}");
    }
    println!("  rustc:          {rustc}");
    println!();

    println!("Features:");
    #[cfg(feature = "ma57")]
    println!("  ma57:           enabled");
    #[cfg(not(feature = "ma57"))]
    println!("  ma57:           disabled (rebuild with --features ma57 to enable HSL MA57)");
    println!();

    println!("Linear solvers:");
    println!("  feral           FERAL pure-Rust sparse LDL^T  (always built-in)");
    #[cfg(feature = "ma57")]
    println!("  ma57            HSL MA57 via libcoinhsl       (compiled in)");
    #[cfg(not(feature = "ma57"))]
    println!("  ma57            HSL MA57 via libcoinhsl       (not compiled; resolves to FERAL at runtime)");
    println!();

    println!("Runtime paths:");
    match std::env::current_exe() {
        Ok(p) => println!("  executable:     {}", p.display()),
        Err(e) => println!("  executable:     <unknown: {e}>"),
    }
    match std::env::current_dir() {
        Ok(p) => println!("  cwd:            {}", p.display()),
        Err(e) => println!("  cwd:            <unknown: {e}>"),
    }
    println!();

    println!("Report bugs at {}/issues", env!("CARGO_PKG_REPOSITORY"));
}

/// Default backend factory used by the restoration sub-IPM. Mirrors
/// the `default_backend_factory` in `pounce-algorithm`: FERAL is the
/// shipping default, with MA57 available behind the `ma57` cargo
/// feature. The `feral_cfg` argument carries the `feral_*` extension
/// options (cascade-break / FMA / iterative-refinement) captured from
/// the application's options list, so per-problem `.opt` overrides
/// flow into the resto sub-IPM as well.
fn default_backend_factory(feral_cfg: pounce_feral::FeralConfig) -> LinearBackendFactory {
    Box::new(
        move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
            match choice {
                LinearSolverChoice::Feral => {
                    Box::new(pounce_feral::FeralSolverInterface::with_config(feral_cfg))
                }
                LinearSolverChoice::Ma57 => {
                    #[cfg(feature = "ma57")]
                    {
                        Box::new(pounce_hsl::Ma57SolverInterface::new())
                    }
                    #[cfg(not(feature = "ma57"))]
                    {
                        Box::new(pounce_feral::FeralSolverInterface::with_config(feral_cfg))
                    }
                }
            }
        },
    )
}
