### DO NOT EDIT THIS FILE
### This file is auto-generated by the Dyad command-line compiler.
### If you edit this code it is likely to get overwritten.
### Instead, update the Dyad source code and regenerate this file


using CSV, DataFrames, Plots
using DyadInterface: TransientAnalysis, rebuild_sol, ODEAlg
using ModelingToolkit: toggle_namespacing, get_initial_conditions, @named

# Runs one Dyad test case end-to-end. Called once per (component, case)
# pair from the generated `<Component>_test.jl` files; the call site is
# pure data (a single keyword-arg call), while all of the actual test
# behaviour — solver invocation, assertion loops, snapshot read/write,
# legacy-layout migration, optional plot output — lives here.
#
# Arguments:
#   constructor      — the Julia type/function the codegen emits for the
#                      Dyad component (e.g. `MyLib.Continuous.Foo`).
#                      Called as `constructor(; name=:model, params...)`
#                      to build the MTK system under test.
#   testset_name     — human-readable label embedded in the `@testset`.
#
# Keyword arguments (all required, but the `Tuple[]` defaults let
# components with no initial/final/signal entries omit them):
#   case_name        — Dyad test-case key (e.g. "case1"). Combined with
#                      `component_stem` to form snapshot filenames.
#   component_stem   — short component name (no module prefix). Snapshot
#                      filenames look like
#                        <component_stem>_<case_name>_sig<i>.ref
#   module_path      — module hierarchy the component lives in, e.g.
#                      `["Continuous", "Tests"]`. Snapshots are written
#                      under `snapshots/<module_path...>/` (issue #1448 —
#                      previously everything went in a flat `snapshots/`
#                      directory, so same-named components in different
#                      submodules collided).
#   start, stop      — simulation time range.
#   abstol, reltol   — solver tolerances.
#   solver           — `ODEAlg.<Name>()` instance (e.g. `ODEAlg.Auto()`).
#   automatic_discontinuity_detection — passed straight through.
#   params           — NamedTuple of constructor kwargs (e.g. `(; k=2.0)`).
#   initial_conditions — `Tuple[(accessor, value), ...]`. Each accessor
#                      is a closure `m -> m.<dotted.path>` (see below).
#                      Applied to the un-namespaced model so we can poke
#                      sub-component state directly.
#   expected_initial — `Tuple[(accessor, label, value, atol, rtol), ...]`.
#                      Each entry asserts `sol[accessor(model)][1] ≈ value`.
#                      The `label` string is used to match the entry
#                      against a signal in the comparison-plot branch.
#   expected_final   — same shape as `expected_initial` but asserts
#                      against `sol[...][end]`.
#   signals          — `Tuple[(accessor, label, atol, rtol), ...]`. One
#                      regression-tested time series per entry; each gets
#                      its own `.ref` file under `snapshots/<module_path>/`.
#
# Why accessors are closures and not Symbols:
# A previous version of this helper passed `Symbol`s like `:T` or
# `:integrator.y`. Simple names worked; compound names did not — Julia
# parses `:integrator.y` as `getproperty(:integrator, :y)`, which throws
# `FieldError: type Symbol has no field y`. Wrapping in a closure
# (`m -> m.integrator.y`) defers the property access to call time and
# works uniformly for both simple and chained paths. The paired `label`
# string keeps the original textual signal name available for plot
# annotations and for matching expected-initial/final entries against
# signals in the comparison-plot branch.
function __dyad_run_test_case!(constructor, testset_name::AbstractString;
                              case_name::AbstractString,
                              component_stem::AbstractString,
                              module_path::Vector{String},
                              start::Real,
                              stop::Real,
                              abstol::Real,
                              reltol::Real,
                              solver,
                              automatic_discontinuity_detection::Bool = false,
                              params::NamedTuple = NamedTuple(),
                              initial_conditions::Vector{<:Tuple} = Tuple[],
                              expected_initial::Vector{<:Tuple} = Tuple[],
                              expected_final::Vector{<:Tuple} = Tuple[],
                              signals::Vector{<:Tuple} = Tuple[])
  @testset "Running test $testset_name" begin
    # Build the MTK system. Constructor kwargs come from the test-case
    # `params` block in the Dyad source.
    model = constructor(; name=:model, params...)

    # Initial-condition dance: `toggle_namespacing(model, false)` strips
    # the `model.` prefix so the accessors (`m -> m.integrator.y`)
    # resolve against sub-component state. After setting the IC dictionary
    # we flip namespacing back on so `TransientAnalysis` sees the system
    # in its normal namespaced form.
    model = toggle_namespacing(model, false)
    for (accessor, val) in initial_conditions
      get_initial_conditions(model)[accessor(model)] = val
    end
    model = toggle_namespacing(model, true)

    # Run the simulation. `rebuild_sol` materialises the SciML solution
    # object from the DyadInterface `TransientAnalysisSolution`.
    result = TransientAnalysis(; model = model, alg = solver,
                                start = start, stop = stop,
                                abstol = abstol, reltol = reltol,
                                automatic_discontinuity_detection = automatic_discontinuity_detection)
    sol = rebuild_sol(result)
    @test SciMLBase.successful_retcode(sol)

    # Scalar assertions on solution endpoints. The `_label` slot is
    # unused here — it only matters in the comparison-plot branch below,
    # where we filter expected_initial/final entries by signal label.
    for (accessor, _label, val, atol, rtol) in expected_initial
      @test sol[accessor(model)][1] ≈ val atol=atol rtol=rtol
    end
    for (accessor, _label, val, atol, rtol) in expected_final
      @test sol[accessor(model)][end] ≈ val atol=atol rtol=rtol
    end

    # Time grid for snapshot writes. 100 evenly-spaced samples between
    # the first and last solver time point — enough resolution to catch
    # regressions without bloating the ref files.
    ref_times = [sol(t, idxs=:t) for t in LinRange(sol[:t][1], sol[:t][end], 100)]

    # Snapshot paths mirror the Dyad module hierarchy (#1448). For a
    # root-level component `module_path` is empty and these collapse to
    # `snapshots/` and `comparisons/` respectively — i.e. the old flat
    # layout — so root components don't need any special-case handling.
    nested_dir = joinpath("snapshots", module_path...)
    comparisons_dir = joinpath("comparisons", module_path...)

    # Mode 1: DYAD_UPDATE_REFS=1 → overwrite ref files with current run.
    # Used to refresh expected values after an intentional model change.
    if get(ENV, "DYAD_UPDATE_REFS", "") !== ""
      mkpath(nested_dir)
      for (i, (accessor, _label, _atol, _rtol)) in enumerate(signals)
        stem = string(component_stem, "_", case_name, "_sig", i-1)
        new_path = joinpath(nested_dir, stem * ".ref")
        flat_path = joinpath("snapshots", stem * ".ref")
        # (#1448) For non-root components, also delete the stale flat-
        # layout ref so we don't keep two copies. `new_path == flat_path`
        # only when `module_path` is empty, in which case skip the rm —
        # we'd otherwise delete the file we just wrote.
        if new_path != flat_path && isfile(flat_path)
          rm(flat_path)
        end
        CSV.write(new_path, DataFrame(t=ref_times, expected=[sol(t, idxs=accessor(model)) for t in ref_times]))
      end
    end

    # Mode 2: normal test run. For each signal we either compare against
    # an existing ref, fall back to the legacy flat-layout ref (#1448
    # migration path), or — if neither exists — write a fresh one so
    # first-time runs don't fail just because nobody seeded the snapshot.
    for (i, (accessor, label, atol, rtol)) in enumerate(signals)
      stem = string(component_stem, "_", case_name, "_sig", i-1)
      new_path = joinpath(nested_dir, stem * ".ref")
      flat_path = joinpath("snapshots", stem * ".ref")
      sig = accessor(model)
      if isfile(new_path)
        # Happy path — ref exists at the expected nested location.
        ref = CSV.read(new_path, DataFrame)
        for j in 1:length(ref.expected)
          @test ref.expected[j] ≈ sol(ref.t[j], idxs=sig) atol=atol rtol=rtol
        end
        # DYAD_COMPARISONS=1 → also dump a PNG overlay of actual vs.
        # expected, with initial/final ICs annotated as scatter points.
        # Purely diagnostic; never affects test pass/fail.
        if get(ENV, "DYAD_COMPARISONS", "") !== ""
          df = DataFrame(t=sol[:t], actual=sol[sig])
          dfr = CSV.read(new_path, DataFrame)
          plot(sol, idxs=[sig], width=2, label="Actual value of $label")
          scatter!(dfr.t, dfr.expected, mc=:red, ms=3, label="Expected value of $label")
          # Find any expected_initial/final entries that name THIS signal
          # (by label string, since the closures aren't comparable) and
          # plot them as endpoint markers.
          for (_accessor, elabel, val, _atol, _rtol) in expected_initial
            elabel == label || continue
            plot!([df.t[1]], [val], seriestype=:scatter, label="Initial Condition for $label")
          end
          for (_accessor, elabel, val, _atol, _rtol) in expected_final
            elabel == label || continue
            plot!([df.t[end]], [val], seriestype=:scatter, label="Final Condition for $label")
          end
          mkpath(comparisons_dir)
          savefig(joinpath(comparisons_dir, stem * ".png"))
        end
      elseif new_path != flat_path && isfile(flat_path)
        # (#1448) Migration fallback. The nested ref hasn't been
        # generated yet but a legacy flat one exists from before the
        # layout change — compare against it so coverage isn't silently
        # lost. The warning tells the developer to re-run with
        # DYAD_UPDATE_REFS=1 to migrate the file to its proper home.
        @warn "Comparing against legacy flat snapshot $flat_path; run with DYAD_UPDATE_REFS=1 to migrate."
        ref = CSV.read(flat_path, DataFrame)
        for j in 1:length(ref.expected)
          @test ref.expected[j] ≈ sol(ref.t[j], idxs=sig) atol=atol rtol=rtol
        end
      else
        # No ref anywhere — seed one from the current run. Subsequent
        # runs will hit the `isfile(new_path)` branch above and actually
        # compare. This makes the first run for a new test case
        # self-bootstrapping rather than a failure.
        mkpath(nested_dir)
        CSV.write(new_path, DataFrame(t=ref_times, expected=[sol(t, idxs=sig) for t in ref_times]))
      end
    end
  end
end
