text:
module DAT
import ..Cubes
using ..YAXTools
using Distributed:
    nworkers,
    pmap,
    @everywhere,
    workers,
    remotecall_fetch,
    myid,
    nprocs,
    remotecall,
    @spawn,
    AbstractWorkerPool,
    default_worker_pool
import ..Cubes: cubechunks, iscompressed, chunkoffset, CubeAxis, YAXArray, caxes, YAXSlice
import ..Cubes.Axes:
    AxisDescriptor, axname, ByInference, axsym, getOutAxis, getAxis, findAxis, match_axis
import ..Datasets: Dataset, createdataset
import ...YAXArrays
import ...YAXArrays.workdir
import YAXArrayBase
import ProgressMeter: Progress, next!, progress_pmap, progress_map
using YAXArrayBase
using DiskArrays: grid_offset, approx_chunksize, max_chunksize, RegularChunks,
  IrregularChunks, GridChunks, eachchunk, ChunkType
using OffsetArrays: OffsetArray
using Dates

export mapCube,
    getAxis,
    InDims,
    OutDims,
    Dataset,
    CubeTable,
    cubefittable,
    fittable,
    savecube,
    loadcube,
    rmcube,
    MovingWindow

global const debugDAT = [false]

const WindowDescriptor = Tuple{Int,Int}

#TODO use a logging package
macro debug_print(e)
    debugDAT[1] && return (:(println($e)))
    :()
end

include("registration.jl")

"""
Internal representation of an input cube for DAT operations
"""
mutable struct InputCube{N}
    cube::Any                  #The input data cube
    desc::InDims               #The input description given by the user/registration
    axesSmall::Vector          #List of axes that were actually selected through the description
    icolon::Vector{Int}
    colonperm::Union{Vector{Int},Nothing}
    loopinds::Vector{Int}        #Indices of loop axes that this cube does not contain, i.e. broadcasts
    cachesize::Vector{Int}     #Number of elements to keep in cache along each axis TODO: delete
    window::Vector{WindowDescriptor}
    iwindow::Vector{Int}
    windowloopinds::Vector{Int}
    iall::Vector{Int}
end

function InputCube(c, desc::InDims)
    internalaxes = findAxis.(desc.axisdesc, Ref(c))
    any(isequal(nothing), internalaxes) &&
        error("One of the input axes not found in input cubes")
    fullaxes = internalaxes[findall(i -> !isa(i, MovingWindow), desc.axisdesc)]
    axlist = caxes(c)
    axesSmall = map(i -> axlist[i], fullaxes)
    colonperm =
        issorted(internalaxes) ? nothing :
        collect(Base.invperm(sortperm(collect(internalaxes))))
    _window = findall(i -> isa(i, MovingWindow), desc.axisdesc)
    iwindow = collect(internalaxes[_window])
    window =
        WindowDescriptor[(desc.axisdesc[i].pre, desc.axisdesc[i].after) for i in _window]
    InputCube{ndims(c)}(
        c,
        desc,
        collect(CubeAxis, axesSmall),
        collect(fullaxes),
        colonperm,
        Int[],
        Int[],
        window,
        iwindow,
        Int[],
        collect(internalaxes),
    )
end
geticolon(ic::InputCube) = ic.icolon
getwindowoob(ic::InputCube) = ic.desc.window_oob_value
createworkarrays(T, s, ntr) = [Array{T}(undef, s...) for i = 1:ntr]
getwindow(ic::InputCube) = zip(ic.windowloopinds, ic.window)
function getworksize(ic::InputCube{N}) where {N}
    r = map(ic.iall) do i
        i1 = findfirst(isequal(i), ic.icolon)
        i1 === nothing || return size(ic.cube, i)
        i2 = findfirst(isequal(i), ic.iwindow)
        return sum(ic.window[i2]) + 1
    end
    (r...,)
end

"""
Internal representation of an output cube for DAT operations
"""
mutable struct OutputCube
    cube::Any #The actual outcube cube, once it is generated
    cube_unpermuted::Any #The unpermuted output cube
    desc::OutDims                 #The description of the output axes as given by users or registration
    axesSmall::Array{CubeAxis}    #The list of output axes determined through the description
    allAxes::Vector{CubeAxis}     #List of all the axes of the cube
    loopinds::Vector{Int}         #Index of the loop axes that are broadcasted for this output cube
    innerchunks::Any
    outtype::Any
end
getwindow(::OutputCube) = []

const InOutCube = Union{InputCube,OutputCube}

getsmallax(c::InOutCube) = c.axesSmall
geticolon(c::OutputCube) = 1:length(c.axesSmall)
getAxis(desc, c::InOutCube) = getAxis(desc, c.cube)

has_window(c::InOutCube) = !isempty(getwindow(c))
function getworksize(oc::OutputCube)
    (length.(oc.axesSmall)...,)
end

function getworkarray(c::InOutCube, ntr)
    wa = createworkarrays(eltype(c.cube), getworksize(c), ntr)
    map(wa) do w
        if !has_window(c)
            wrapWorkArray(c.desc.artype, w, c.axesSmall)
        else
            axes = map(c.iall) do i
                i1 = findfirst(isequal(i), c.icolon)
                i1 === nothing || return caxes(c.cube)[c.icolon[i1]]
                i2 = findfirst(isequal(i), c.iwindow)
                RangeAxis(axname(caxes(c.cube)[c.iwindow[i2]]), UnitRange(-c.window[i2][1], c.window[i2][2]))
            end
            wrapWorkArray(c.desc.artype, w, axes)
        end
    end
end

function interpretoutchunksizes(desc, axesSmall, incubes)
    if desc.chunksize == :max
        map(ax -> axname(ax) => RegularChunks(length(ax),0,length(ax)), axesSmall)
    elseif desc.chunksize == :input
        map(axesSmall) do ax
            for cc in incubes
                i = findAxis(axname(ax), cc)
                if i !== nothing
                    return axname(ax) => eachchunk(cc.data).chunks[i]
                end
            end
            return axname(ax) => length(ax)
        end
    else
        desc.chunksize
    end
end

getOutAxis(desc::Tuple, inAxes, incubes, pargs, f) =
    map(i -> getOutAxis(i, inAxes, incubes, pargs, f), desc)
function OutputCube(desc::OutDims, inAxes, incubes, pargs, f)
    axesSmall = getOutAxis(desc.axisdesc, inAxes, incubes, pargs, f)
    outtype = getOuttype(desc.outtype, incubes)
    innerchunks = interpretoutchunksizes(desc, axesSmall, incubes)
    OutputCube(
        nothing,
        nothing,
        desc,
        collect(CubeAxis, axesSmall),
        CubeAxis[],
        Int[],
        innerchunks,
        outtype,
    )
end

"""
Configuration object of a DAT process. This holds all necessary information to perform the calculations.
It contains the following fields:

- `incubes::NTuple{NIN,InputCube}` The input data cubes
- `outcube::NTuple{NOUT,OutputCube}` The output data cubes
allInAxes     :: Vector
LoopAxes      :: Vector
ispar         :: Bool
loopcachesize :: Vector{Int}
max_cache
fu
inplace      :: Bool
include_loopvars:: Bool
ntr
addargs
kwargs
"""
mutable struct DATConfig{NIN,NOUT}
    "The input data cubes"
    incubes::NTuple{NIN,InputCube}

    outcubes::NTuple{NOUT,OutputCube}
    allInAxes::Vector
    LoopAxes::Vector
    ispar::Bool
    loopcachesize::Vector{Int}
    allow_irregular_chunks::Bool
    max_cache::Any
    fu::Any
    inplace::Bool
    include_loopvars::Bool
    ntr::Any
    addargs::Any
    kwargs::Any
end
function DATConfig(
    cdata,
    indims,
    outdims,
    inplace,
    max_cache,
    fu,
    ispar,
    include_loopvars,
    allow_irregular,
    nthreads,
    addargs,
    kwargs,
)

    isa(indims, InDims) && (indims = (indims,))
    isa(outdims, OutDims) && (outdims = (outdims,))
    length(cdata) == length(indims) || error(
        "Number of input cubes ($(length(cdata))) differs from registration ($(length(indims)))",
    )
    incubes = ([InputCube(o[1], o[2]) for o in zip(cdata, indims)]...,)
    allInAxes = vcat([ic.axesSmall for ic in incubes]...)
    outcubes = ((
        map(1:length(outdims), outdims) do i, desc
            OutputCube(desc, allInAxes, cdata, addargs, fu)
        end
    )...,)

    DATConfig(
        incubes,
        outcubes,
        allInAxes,
        CubeAxis[],                                 # LoopAxes
        ispar,
        Int[],
        allow_irregular,
        max_cache,                                  # max_cache
        fu,                                         # fu                                      # loopcachesize
        inplace,                                    # inplace
        include_loopvars,
        nthreads,
        addargs,                                    # addargs
        kwargs,
    )
end

getOuttype(outtype::Int, cdata) = eltype(cdata[outtype])
function getOuttype(outtype::DataType, cdata)
    outtype
end

mapCube(fu::Function, cdata, addargs...; kwargs...) =
    mapCube(fu, (cdata,), addargs...; kwargs...)

function mapCube(
    f,
    in_ds::Dataset,
    addargs...;
    indims = InDims(),
    outdims = OutDims(),
    inplace = true,
    kwargs...,
)
    allars = values(in_ds.cubes)
    allaxes = collect(values(in_ds.axes))
    arnames = keys(in_ds.cubes)
    sarnames = (arnames...,)
    any(ad -> findAxis(ad, allaxes) === nothing, indims.axisdesc) &&
        error("One of the Dimensions does not exist in Dataset")

    idar = collect(indims.axisdesc)
    allindims = map(allars) do c
        idshort = filter(idar) do ad
            findAxis(ad, c) !== nothing
        end
        InDims((idshort...,), indims.artype, indims.procfilter)
    end
    isa(outdims, OutDims) || error("Only one output cube currently supported for datasets")
    isempty(addargs) || error(
        "Additional arguments currently not supported for datasets, use kwargs instead",
    )
    if inplace
        fnew = let arnames = collect(arnames), f = f
            function dsfun(xout, xin...; kwargs...)
                incubes = NamedTuple{sarnames,typeof(xin)}(xin)
                f(xout, incubes; kwargs...)
            end
        end
    else
        fnew = let arnames = collect(arnames), f = f
            function dsfun(xin...; kwargs...)
                incubes = NamedTuple{sarnames,typeof(xin)}(xin)
                f(incubes; kwargs...)
            end
        end
    end
    allcubes = collect(values(in_ds.cubes))
    mapCube(
        fnew,
        (allcubes...,);
        indims = allindims,
        outdims = outdims,
        inplace = inplace,
        kwargs...,
    )
end

import Base.mapslices
function mapslices(f, d::Union{YAXArray,Dataset}, addargs...; dims, kwargs...)
    isa(dims, String) && (dims = (dims,))
    mapCube(
        f,
        d,
        addargs...;
        indims = InDims(dims...),
        outdims = OutDims(ByInference()),
        inplace = false,
        kwargs...,
    )
end

"""
    mapCube(fun, cube, addargs...;kwargs)

Map a given function `fun` over slices of the data cube `cube`.

### Keyword arguments

* `max_cache=1e7` maximum size of blocks that are read into memory, defaults to approx 10Mb
* `indims::InDims` List of input cube descriptors of type [`InDims`](@ref) for each input data cube
* `outdims::OutDims` List of output cube descriptors of type [`OutDims`](@ref) for each output cube
* `inplace` does the function write to an output array inplace or return a single value> defaults to `true`
* `ispar` boolean to determine if parallelisation should be applied, defaults to `true` if workers are available.
* `showprog` boolean indicating if a ProgressMeter shall be shown
* `include_loopvars` boolean to indicate if the varoables looped over should be added as function arguments
* `loopchunksize` determines the chunk sizes of variables which are looped over, a dict
* `kwargs` additional keyword arguments passed to the inner function

The first argument is always the function to be applied, the second is the input cube or
a tuple input cubes if needed.
"""
function mapCube(
    fu::Function,
    cdata::Tuple,
    addargs...;
    max_cache = YAXArrays.YAXDefaults.max_cache[],
    indims = InDims(),
    outdims = OutDims(),
    inplace = true,
    ispar = nprocs() > 1,
    debug = false,
    include_loopvars = false,
    showprog = true,
    irregular_loopranges = false,
    nthreads = ispar ? Dict(i => remotecall_fetch(Threads.nthreads, i) for i in workers()) :
               [Threads.nthreads()],
    loopchunksize = Dict(),
    kwargs...,
)

    #Translate slices
    if any(i -> isa(i, YAXSlice), cdata)
        inew = map(cdata) do d
            isa(d, YAXSlice) ? InDims(axname.(d.sliceaxes[2])...) : InDims()
        end
        cnew = map(i -> isa(i, YAXSlice) ? i.c : i, cdata)
        return mapCube(
            fu,
            cnew,
            addargs...;
            indims = inew,
            outdims = outdims,
            inplace = inplace,
            ispar = ispar,
            debug = debug,
            include_loopvars = include_loopvars,
            irregular_loopranges = irregular_loopranges,
            showprog = showprog,
            nthreads = nthreads,
            kwargs...,
        )
    end
    @debug_print "Generating DATConfig"
    dc = DATConfig(
        cdata,
        indims,
        outdims,
        inplace,
        max_cache,
        fu,
        ispar,
        include_loopvars,
        irregular_loopranges,
        nthreads,
        addargs,
        kwargs,
    )
    @debug_print "Analysing Axes"
    analyzeAxes(dc)
    @debug_print "Permuting loop axes"
    permuteloopaxes(dc)
    @debug_print "Calculating Cache Sizes"
    getCacheSizes(dc, loopchunksize)
    @debug_print "Generating Output Cube"
    generateOutCubes(dc)
    @debug_print "Running main Loop"
    debug && return (dc)
    runLoop(dc, showprog)
    @debug_print "Finalizing Output Cube"

    if length(dc.outcubes) == 1
        return dc.outcubes[1].cube_unpermuted
    else
        return (map(i -> i.cube_unpermuted, dc.outcubes)...,)
    end

end

function makeinplace(f)
    (args...; kwargs...) -> begin
        first(args) .= f(Base.tail(args)...; kwargs...)
        nothing
    end
end

to_chunksize(c::RegularChunks, cs, _ = true) = RegularChunks(cs, c.offset, c.s)
function to_chunksize(c::IrregularChunks, cs, allow_irregular=true)
    fac = cs ÷ approx_chunksize(c)
    ll = length.(c)
    newchunks = sum.(Iterators.partition(ll,fac))
    if length(newchunks)==1
        RegularChunks(cs, 0, newchunks[1])
    elseif length(newchunks) == 2 || all(==(cs),newchunks[2:end-1])
        RegularChunks(cs, cs-newchunks[1], sum(newchunks))
    else
        if allow_irregular
            IrregularChunks(chunksizes = newchunks)
        else
            RegularChunks(cs, 0, last(last(c)))
        end
    end
end

function getloopchunks(dc::DATConfig)
    lc = dc.loopcachesize
    co = map(lc,dc.LoopAxes) do cs, ax
        allchunks = map(dc.incubes) do ic
            ii = findAxis(ax, caxes(ic.cube))
            ii === nothing ? nothing : eachchunk(ic.cube.data).chunks[ii]
        end
        allchunks = unique(filter(!isnothing, allchunks))
        if length(allchunks) == 1
            return to_chunksize(allchunks[1],cs,dc.allow_irregular_chunks)
        end
        allchunks_offset = filter(i->mod(cs,approx_chunksize(i))==0, allchunks)
        allchunks = isempty(allchunks_offset) ? allchunks : allchunks_offset
        if length(allchunks) == 1
            return to_chunksize(allchunks[1],cs,dc.allow_irregular_chunks)
        end
        if !dc.allow_irregular_chunks
            allchunks = filter(i->isa(i,RegularChunks),allchunks)
        end
        if length(allchunks) == 1
            return to_chunksize(allchunks[1],cs)
        end
        allchunks = to_chunksize.(allchunks,cs)
        allchunks = unique(allchunks)
        if length(allchunks)>1
            @warn "Multiple chunk offset resolutions possible: $allchunks for dim $(axname(ax))"
        end
        first(allchunks)
    end
    (co...,)
end

function permuteloopaxes(dc)
    foreach(dc.incubes) do ic
        if !issorted(ic.loopinds)
            p = sortperm(ic.loopinds)
            ic.cube = permutedims(ic.cube,[1:length(ic.axesSmall);p .+ length(ic.axesSmall)])
            ic.loopinds = ic.loopinds[p]
        end
    end
end

updatears(clist, r, f, caches) =
    foreach(clist, caches) do ic, ca
        if !has_window(ic)
            updatear(f, r, ic.cube, geticolon(ic), ic.loopinds, ca)
        else
            updatear_window(
                r,
                ic.cube,
                geticolon(ic),
                ic.loopinds,
                ca,
                zip(ic.iwindow, ic.window),
                getwindowoob(ic),
            )
        end
    end
getindsall(indscol, loopinds, rfunc, colfunc = _ -> Colon()) =
    getindsall((), 1, (sort(indscol)...,), (loopinds...,), rfunc, colfunc)
function getindsall(indsall, inow, indscol, loopinds, rfunc, colfunc)
    if !isempty(indscol) && first(indscol) == inow
        getindsall(
            (indsall..., colfunc(inow)),
            inow + 1,
            Base.tail(indscol),
            loopinds,
            rfunc,
            colfunc,
        )
    else
        getindsall(
            (indsall..., rfunc(first(loopinds))),
            inow + 1,
            indscol,
            Base.tail(loopinds),
            rfunc,
            colfunc,
        )
    end
end
getindsall(indsall, inow, ::Tuple{}, ::Tuple{}, r, c) = indsall

function updatear_window(r, cube, indscol, loopinds, cache, windows, windowoob)
    indsall = getindsall(indscol, loopinds, i -> r[i])
    for (iw, pa) in windows
        iold = indsall[iw]
        indsall = Base.setindex(indsall, first(iold)-pa[1]:last(iold)+pa[2], iw)
    end
    data = getdata(cube)
    l2 = map((i, s) -> isa(i, Colon) ? s : length(i), indsall, size(cache))
    oo = map(indsall, axes(cache), axes(data)) do i, c, d
        if isa(i, Colon)
            return Base.OneTo(length(c)), Base.OneTo(length(c))
        else
            precut, aftercut = max(0, first(d) - first(i)), max(0, last(i) - last(d))
            icube = first(i)+precut:last(i)-aftercut
            icache = first(c)+precut:first(c)+precut+length(icube)-1
            return icache, icube
        end
    end
    hinds = first.(oo)
    indsall2 = last.(oo)
    fill!(cache, windowoob)
    cache[hinds...] = data[indsall2...]
end

function updatear(f, r, cube, indscol, loopinds, cache)
    indsall = getindsall(indscol, loopinds, i -> r[i])
    l2 = map((i, s) -> isa(i, Colon) ? s : length(i), indsall, size(cache))
    if size(cache) != l2
        hinds = map((i, s) -> isa(i, Colon) ? (1:s) : 1:length(i), indsall, size(cache))
        if f == :read
            cache[hinds...] = getdata(cube)[indsall...]
        else
            getdata(cube)[indsall...] = cache[hinds...]
        end
    else
        if f == :read
            d = getdata(cube)[indsall...]
            cache[:] = d
        else
            _writedata(getdata(cube), cache, indsall)
        end
    end
end
_writedata(d,cache,indsall) = d[indsall...] = cache
_writedata(d::Array{<:Any,0},cache::Array{<:Any,0},::Tuple{}) = d[] = cache[]

updateinars(dc, r, incaches) = updatears(dc.incubes, r, :read, incaches)
writeoutars(dc, r, outcaches) = updatears(dc.outcubes, r, :write, outcaches)

function pmap_with_data(f, p::AbstractWorkerPool, c...; initfunc, progress=nothing, kwargs...)
    d = Dict(ip=>remotecall(initfunc, ip) for ip in workers(p))
    allrefs = @spawn d
    function fnew(args...,)
        refdict = fetch(allrefs)
        myargs = fetch(refdict[myid()])
        f(args..., myargs)
    end
    if progress !==nothing
        progress_pmap(fnew,p,c...;progress=progress,kwargs...)
    else
        pmap(fnew,p,c...;kwargs...)
    end
end
pmap_with_data(f,c...;initfunc,kwargs...) = pmap_with_data(f,default_worker_pool(),c...;initfunc,kwargs...)

function moduleloadedeverywhere()
    try
        isloaded = map(workers()) do w
            #We try calling a function defined inside this module, thi will error when YAXArrays is not loaded on the remote workers
            remotecall(() -> true, w)
        end
        fetch.(isloaded)
    catch e
        return false
    end
    return true
end

function runLoop(dc::DATConfig, showprog)
    allRanges = GridChunks(getloopchunks(dc)...)
    if dc.ispar
        #Test if YAXArrays is loaded on all workers:
        moduleloadedeverywhere() || error(
            "YAXArrays is not loaded on all workers. Please run `@everywhere using YAXArrays` to fix.",
        )
        dcref = @spawn dc
        prepfunc = ()->getallargs(fetch(dcref))
        prog = showprog ? Progress(length(allRanges)) : nothing
        pmap_with_data(allRanges, initfunc=prepfunc, progress=prog) do r, prep
            incaches, outcaches, args = prep
            updateinars(dc, r, incaches)
            innerLoop(r, args...)
            writeoutars(dc, r, outcaches)
        end
    else
        incaches, outcaches, args = getallargs(dc)
        mapfun = showprog ? progress_map : map
        mapfun(allRanges) do r
            updateinars(dc, r, incaches)
            innerLoop(r, args...)
            writeoutars(dc, r, outcaches)
        end
    end
    dc.outcubes
end

abstract type AxValCreator end
struct NoLoopAxes <: AxValCreator end
struct AllLoopAxes{S,V} <: AxValCreator
    loopsyms::S
    loopaxvals::V
end
AllLoopAxes(a) = AllLoopAxes(map(axsym, a), map(i -> i.values, a))
getlaxvals(::NoLoopAxes, cI, offscur) = ()
getlaxvals(a::AllLoopAxes, cI, offscur) = (
    NamedTuple{a.loopsyms}(
        map((ax, i, of) -> (i + of, ax[i+of]), a.loopaxvals, cI.I, offscur),
    ),
)

function getallargs(dc::DATConfig)
    incache, outcache = getCubeCache(dc)
    filters = map(ic -> ic.desc.procfilter, dc.incubes)
    inworkar, outworkar = generateworkarrays(dc)
    axvals = if dc.include_loopvars
        lax = (dc.LoopAxes...,)
        AllLoopAxes(lax)
    else
        NoLoopAxes()
    end
    adda = dc.addargs
    kwa = dc.kwargs
    fu = if !dc.inplace
        makeinplace(dc.fu)
    else
        dc.fu
    end
    inarsbc = map(dc.incubes, incache) do ic, cache
        allax = getindsall(geticolon(ic), 1:length(dc.LoopAxes), i -> i in ic.loopinds ? true : false)
        if has_window(ic)
            for (iw, pa) in zip(ic.iwindow, ic.window)
                allax = Base.setindex(allax, pa, iw)
            end
        end
        if ic.colonperm === nothing
            pa = PickAxisArray(cache, allax)
        else
            pa = PickAxisArray(cache, allax, ic.colonperm)
        end
    end
    outarsbc = map(dc.outcubes, outcache) do oc, cache
        allax = getindsall(1:length(oc.axesSmall), oc.loopinds, i -> true)
        pa = PickAxisArray(cache, allax)
        pa
    end
    incache,
    outcache,
    (fu, inarsbc, outarsbc, filters, inworkar, outworkar, axvals, adda, kwa)
end

function getbackend(oc, ispar, max_cache)
    elementtype = Union{oc.outtype,Missing}
    outsize =
        sizeof(elementtype) * (length(oc.allAxes) > 0 ? prod(map(length, oc.allAxes)) : 1)
    rt = oc.desc.backend
    if rt == :auto
        if ispar[] || outsize > max_cache
            rt = :zarr
        else
            rt = :array
        end
    end
    b = YAXArrayBase.backendlist[Symbol(rt)]
    if !allow_parallel_write(b)
        ispar[] = false
    end
    elementtype, b
end

function generateOutCube(
    ::Type{T},
    elementtype,
    oc::OutputCube,
    loopcachesize,
    co;
    kwargs...,
) where {T}
    cs_inner = (map(length, oc.axesSmall)...,)
    cs = (cs_inner..., loopcachesize...)
    co = (map(_->0, oc.axesSmall)...,co...)
    for (i, cc) in enumerate(oc.innerchunks)
        if cc !== nothing && i <= length(oc.axesSmall)
            cs = Base.setindex(cs, approx_chunksize(cc), i)
            co = Base.setindex(co,grid_offset(cc), i)
        end
    end
    cube1, cube2 = createdataset(
        T,
        oc.allAxes;
        T = elementtype,
        chunksize = cs,
        chunkoffset = co,
        kwargs...,
    )
    oc.cube = cube1
    oc.cube_unpermuted = cube2
end
function generateOutCube(
    ::Type{T},
    elementtype,
    oc::OutputCube,
    loopcachesize,
    co;
    kwargs...,
) where {T<:Array}
    newsize = map(length, oc.allAxes)
    outar = Array{elementtype}(undef, newsize...)
    fill!(outar,_zero(elementtype))
    oc.cube = YAXArray(oc.allAxes, outar)
    oc.cube_unpermuted = oc.cube
end
_zero(T) = zero(T)
_zero(T::Type{<:AbstractString}) = convert(T, "")

function generateOutCubes(dc::DATConfig)
    rr = getloopchunks(dc)
    cs = approx_chunksize.(rr)
    offs = grid_offset.(rr)
    foreach(dc.outcubes) do c
        generateOutCube(c, Ref(dc.ispar), dc.max_cache, cs, offs)
    end
end
function generateOutCube(oc::OutputCube, ispar::Ref{Bool}, max_cache, loopcachesize, co)
    elementtype, cubetype = getbackend(oc, ispar, max_cache)
    generateOutCube(cubetype, elementtype, oc, loopcachesize, co; oc.desc.backendargs...)
end

function getCubeCache(dc::DATConfig)
    allranges = getloopchunks(dc)
    outcaches = map(i -> allocatecachebuf(i, max_chunksize.(allranges)), dc.outcubes)
    incaches = map(i -> allocatecachebuf(i, max_chunksize.(allranges)), dc.incubes)
    incaches, outcaches
end

function allocatecachebuf(ic::Union{InputCube,OutputCube}, loopcachesize)
    s = size(ic.cube)
    indsall = getindsall(geticolon(ic), ic.loopinds, i -> loopcachesize[i], i -> s[i])
    if has_window(ic)
        indsall = Base.OneTo.(indsall)
        for (iw, (pre, after)) in zip(ic.iwindow, ic.window)
            old = indsall[iw]
            new = (first(old)-pre):(last(old)+after)
            indsall = Base.setindex(indsall, new, iw)
        end
        #@show indsall
        OffsetArray(Array{eltype(ic.cube)}(undef, length.(indsall)...), indsall...)
    else
        Array{eltype(ic.cube)}(undef,indsall...)
    end
end

function init_DATworkers()
    freshworkermodule()
end

function analyzeAxes(dc::DATConfig{NIN,NOUT}) where {NIN,NOUT}

    loopaxsyms = Symbol[]
    for cube in dc.incubes
        for (iax,a) in enumerate(caxes(cube.cube))
            if !in(a, cube.axesSmall)
                s = axsym(a)
                is = findfirst(isequal(s), loopaxsyms)
                if is === nothing
                    push!(dc.LoopAxes, a)
                    push!(loopaxsyms, s)
                    is = length(loopaxsyms)
                else
                    a == dc.LoopAxes[is] || error("Axes $a and $(dc.LoopAxes[is]) have the same name but are note identical")
                end
                push!(cube.loopinds,is)
                if iax in cube.iwindow
                    push!(cube.windowloopinds, is)
                end
            end
        end
    end
    #Add output broadcast axes
    for outcube in dc.outcubes
        LoopAxesAdd = CubeAxis[]
        for (il, loopax) in enumerate(dc.LoopAxes)
            push!(outcube.loopinds, il)
            push!(LoopAxesAdd, loopax)
        end
        outcube.allAxes = CubeAxis[outcube.axesSmall; LoopAxesAdd]
        dold = outcube.innerchunks
        newchunks = ntuple(_->nothing, length(outcube.allAxes))
        for (k, v) in dold
            ii = findAxis(k, outcube.allAxes)
            if ii !== nothing
                Base.setindex(newchunks, v, ii)
            end
        end
        outcube.innerchunks = newchunks
    end
    #And resolve names in chunk size dicts
    return dc
end

mysizeof(x) = sizeof(x)
mysizeof(x::Type{String}) = 1

"""
Function that compares two cache miss specifiers by their importance
"""
function cmpcachmisses(x1, x2)
    #First give preference to compressed misses
    if xor(x1.iscompressed, x2.iscompressed)
        return x1.iscompressed
        #Now compare the size of the miss multiplied with the inner size
    else
        return approx_chunksize(x1.cs) * x1.innerleap > approx_chunksize(x2.cs) * x2.innerleap
    end
end

function getCacheSizes(dc::DATConfig, loopchunksizes)

    inAxlengths = Vector{Int}[Int.(length.(cube.axesSmall)) for cube in dc.incubes]
    inblocksizes = map(
        (x, T) ->
            isempty(x) ? mysizeof(eltype(T.cube)) : prod(x) * mysizeof(eltype(T.cube)),
        inAxlengths,
        dc.incubes,
    )
    inblocksize = sum(inblocksizes)
    outblocksizes = map(
        C ->
            length(C.axesSmall) > 0 ?
            sizeof(C.outtype) * prod(map(Int ∘ length, C.axesSmall)) : 1,
        dc.outcubes,
    )
    outblocksize = length(outblocksizes) > 0 ? sum(outblocksizes) : 1
    #Now add cache miss information for each input cube to every loop axis
    cmisses = NamedTuple{
        (:iloopax, :cs, :iscompressed, :innerleap, :preventpar),
        Tuple{Int64,ChunkType,Bool,Int64,Bool},
    }[]
    userchunks = Dict{Int,Int}()
    for (k, v) in loopchunksizes
        ii = findAxis(k, dc.LoopAxes)
        if ii !== nothing
            v isa ChunkType || error("Loop chunks must be provided as ChunkType object")
            userchunks[ii] = v
        end
    end
    foreach(dc.LoopAxes, 1:length(dc.LoopAxes)) do lax, ilax
        haskey(userchunks, ilax) && return nothing
        for ic in dc.incubes
            #@show lax
            #@show ic.cube.axes
            ii = findAxis(lax, ic.cube)
            if !isa(ii, Nothing)
                inax = isempty(ic.axesSmall) ? 1 : prod(map(length, ic.axesSmall))
                push!(
                    cmisses,
                    (
                        iloopax = ilax,
                        cs = eachchunk(ic.cube.data).chunks[ii],
                        iscompressed = iscompressed(ic.cube),
                        innerleap = inax,
                        preventpar = false,
                    ),
                )
            end
        end
        for oc in dc.outcubes
            cs = oc.innerchunks
            ii = findAxis(lax, oc.allAxes)
            if !isa(ii, Nothing) && !isa(cs[ii], Nothing)
                innerleap = isempty(oc.axesSmall) ? 1 : prod(map(length, oc.axesSmall))
                push!(
                    cmisses,
                    (
                        iloopax = ilax,
                        cs = cs[ii],
                        iscompressed = iscompressed(oc.cube),
                        innerleap = innerleap,
                        preventpar = true,
                    ),
                )
            end
        end
    end
    cmisses = sort(unique(cmisses), lt = cmpcachmisses)
    loopcachesize, nopar = getLoopCacheSize(
        inblocksize + outblocksize,
        map(length, dc.LoopAxes),
        dc.max_cache,
        cmisses,
        userchunks,
    )
    # for cube in dc.incubes
    #     cube.cachesize = map(i->RegularChunks(length(i), 0, length(i)), cube.axesSmall)
    #     for (cs, loopAx) in zip(loopcachesize, dc.LoopAxes)
    #         in(axsym(loopAx), axsym.(caxes(cube.cube))) && push!(cube.cachesize, cs)
    #     end
    # end
    nopar && (dc.ispar = false)
    dc.loopcachesize = loopcachesize
    return dc
end

"Calculate optimal Cache size to DAT operation"
function getLoopCacheSize(preblocksize, loopaxlengths, max_cache, cmisses, userchunks)
    totcachesize = max_cache
    loopcachesize = ones(Int, length(loopaxlengths))
    #Go through user definitions
    incfac = totcachesize / preblocksize / prod(loopcachesize)
    incfac < 1 && error(
        "The requested slices do not fit into the specified cache. Please consider increasing max_cache",
    )
    # Go through list of cache misses first and decide
    imiss = 1
    while imiss <= length(cmisses)
        il = cmisses[imiss].iloopax
        s = min(approx_chunksize(cmisses[imiss].cs), loopaxlengths[il]) / loopcachesize[il]
        #Check if cache size is already set for this axis
        if loopcachesize[il] == 1
            if s < incfac
                loopcachesize[il] = min(approx_chunksize(cmisses[imiss].cs), loopaxlengths[il])
                incfac = totcachesize / preblocksize / prod(loopcachesize)
            else
                ii = floor(Int, incfac)

                while ii > 1 && rem(s, ii) != 0
                    ii = ii - 1
                end
                loopcachesize[il] = ii
                incfac = incfac/ii
                break
            end
        end
        imiss += 1
    end
    #Now second run to read multiple blocks at once
    if incfac >= 2
        for i = 1:length(loopcachesize)
            haskey(userchunks, i) && continue
            imul = min(floor(Int, incfac), loopaxlengths[i] ÷ loopcachesize[i])
            loopcachesize[i] = loopcachesize[i] * imul
            incfac = incfac / imul
            incfac < 2 && break
        end
    end
    if imiss < length(cmisses) + 1
        @warn "There are still cache misses"
        cmisses[imiss].iscompressed &&
            @warn "There are compressed caches misses, you may want to use a different cube chunking"
    else
        #TODO continue increasing cache sizes on by one...
    end
    nopar = any(i -> i.preventpar, cmisses[imiss:end])
    return loopcachesize, nopar
end

function distributeLoopRanges(block_size::NTuple{N,Int}, loopR::NTuple{N,Int}, co) where {N}
    allranges = map(block_size, loopR, co) do bs, lr, cocur
        collect(filter(!isempty, [max(1, i):min(i + bs - 1, lr) for i = (1-cocur):bs:lr]))
    end
    Iterators.product(allranges...)
end

function generateworkarrays(dc::DATConfig)
    inwork = map(i -> getworkarray(i, dc.ntr[myid()]), dc.incubes)
    outwork = map(i -> getworkarray(i, dc.ntr[myid()]), dc.outcubes)
    inwork, outwork
end

function innercode(
    f,
    cI,
    xinBC,
    xoutBC,
    filters,
    inwork,
    outwork,
    axvalcreator,
    offscur,
    addargs,
    kwargs,
)
    ithr = Threads.threadid()
    #Pick the correct array according to thread
    myinwork = map(i -> i[ithr], inwork)
    myoutwork = map(i -> i[ithr], outwork)
    #Copy data into work arrays
    foreach(myinwork, xinBC) do iw, x
        YAXArrayBase.getdata(iw) .= view(x, cI.I...)
    end
    #Apply filters
    mvs = map(docheck, filters, myinwork)
    if any(mvs)
        # Set all outputs to missing
        foreach(ow -> fill!(ow, missing), myoutwork)
    else
        #Compute loop axis values if necessary
        laxval = getlaxvals(axvalcreator, cI, offscur)
        #Finally call the function
        f(myoutwork..., myinwork..., laxval..., addargs...; kwargs...)
    end
    #Copy data into output array
    foreach((iw, x) -> view(x, cI.I...) .= YAXArrayBase.getdata(iw), myoutwork, xoutBC)
end

using DataStructures: OrderedDict
using Base.Cartesian
@noinline function innerLoop(
    loopRanges,
    f,
    xinBC,
    xoutBC,
    filters,
    inwork,
    outwork,
    axvalcreator,
    addargs,
    kwargs,
)
    offscur = map(i -> (first(i) - 1), loopRanges)
    if length(inwork[1]) == 1
        for cI in CartesianIndices(map(i -> 1:length(i), loopRanges))
            innercode(
                f,
                cI,
                xinBC,
                xoutBC,
                filters,
                inwork,
                outwork,
                axvalcreator,
                offscur,
                addargs,
                kwargs,
            )
        end
    else
        Threads.@threads for cI in CartesianIndices(map(i -> 1:length(i), loopRanges))
            innercode(
                f,
                cI,
                xinBC,
                xoutBC,
                filters,
                inwork,
                outwork,
                axvalcreator,
                offscur,
                addargs,
                kwargs,
            )
        end
    end
end

"Calculate an axis permutation that brings the wanted dimensions to the front"
function getFrontPerm(dc, dims)
    ax = caxes(dc)
    N = length(ax)
    perm = Int[i for i = 1:length(ax)]
    iold = Int[]
    for i = 1:length(dims)
        push!(iold, findAxis(dims[i], ax))
    end
    iold2 = sort(iold, rev = true)
    for i = 1:length(iold)
        splice!(perm, iold2[i])
    end
    perm = Int[iold; perm]
    return ntuple(i -> perm[i], N)
end

include("dciterators.jl")
include("tablestats.jl")
include("CubeIO.jl")
end

max_stars_repo_name: meggart/YAXArrays

max_forks_repo_name: meggart/YAXArrays


text:
using LightGraphs
using LightGraphs.SimpleGraphs
using LightGraphs.LinAlg
using Base.Test

# g1 = smallgraph(:petersen)
# g2 = smallgraph(:tutte)
# g3 = PathGraph(5)
# g4 = PathDiGraph(5)
# g5 = DiGraph(4)
# add_edge!(g5,1,2); add_edge!(g5,2,3); add_edge!(g5,1,3); add_edge!(g5,3,4)
# g6 = smallgraph(:house)
#
# h1 = Graph(5)
# h2 = Graph(3)
# h3 = Graph()
# h4 = DiGraph(7)
# h5 = DiGraph()
#
# # self loops
# s2 = DiGraph(3)
# add_edge!(s2,1,2); add_edge!(s2,2,3); add_edge!(s2,3,3)
# s1 = Graph(s2)
#
# r1 = Graph(10,20)
# r2 = DiGraph(5,10)
#
# e0 = Edge(2, 3)
# e1 = Edge(1, 2)
# re1 = Edge(2, 1)
#
# # polygons
# triangle = random_regular_graph(3,2)
# quadrangle = random_regular_graph(4,2)
# pentagon = random_regular_graph(5,2)

testdir = dirname(@__FILE__)

testgraphs(g) = [g, Graph{UInt8}(g), Graph{Int16}(g)]
testdigraphs(g) = [g, DiGraph{UInt8}(g), DiGraph{Int16}(g)]

# some operations will create a large graph from two smaller graphs. We
# might error out on very small eltypes.
testlargegraphs(g) = [g, Graph{UInt16}(g), Graph{Int32}(g)]
testlargedigraphs(g) = [g, DiGraph{UInt16}(g), DiGraph{Int32}(g)]

tests = [
    "interface",
    "core",
    "graphtypes/simplegraphs/simplegraphs",
    "graphtypes/simplegraphs/simpleedge",
    "graphtypes/simplegraphs/simpleedgeiter",
    "operators",
    # "graphdigraph",
    "distance",
    "digraph-cyclicity-hadwick-james",
    "edit_distance",
    "linalg/spectral",
    "linalg/graphmatrices",
    "connectivity",
    "transitivity",
    "persistence/persistence",
    "generators/randgraphs",
    "generators/staticgraphs",
    "generators/smallgraphs",
    "generators/euclideangraphs",
    "shortestpaths/astar",
    "shortestpaths/bellman-ford",
    "shortestpaths/dijkstra",
    "shortestpaths/floyd-warshall",
    "traversals/bfs",
    "traversals/parallel_bfs",
    "traversals/dfs",
    "traversals/maxadjvisit",
    "traversals/graphvisit",
    "traversals/randomwalks",
    "community/cliques",
    "community/core-periphery",
    "community/label_propagation",
    "community/modularity",
    "community/clustering",
    "centrality/betweenness",
    "centrality/closeness",
    "centrality/degree",
    "centrality/katz",
    "centrality/pagerank",
    "centrality/eigenvector",
    "flow/edmonds_karp",
    "flow/dinic",
    "flow/boykov_kolmogorov",
    "flow/push_relabel",
    "flow/maximum_flow",
    "flow/multiroute_flow",
    "utils",
    "spanningtrees/kruskal",
    "spanningtrees/prim",
    "biconnectivity/articulation",
    "biconnectivity/biconnect"
]

@testset begin
    for t in tests
        tp = joinpath(testdir,"$(t).jl")
        include(tp)
    end
end

ext: jl

max_stars_repo_name: AsileBcd/JohnsonCycles

max_issues_repo_name: AsileBcd/JohnsonCycles

max_forks_repo_name: AsileBcd/JohnsonCycles


text:
module PowerModelsInterface

import PowerSystems
import PowerModels
import Dates

# PMI exports
export get_pm_data
export apply_time_series
export apply_time_period!

# PowerModels exports
import PowerModels: run_opf
export run_opf
import PowerModels: run_mn_opf
export run_mn_opf
import PowerModels: run_pf
export run_pf

import PowerModels: run_ac_pf
export run_ac_pf
import PowerModels: run_dc_pf
export run_dc_pf
import PowerModels: compute_ac_pf
export compute_ac_pf
import PowerModels: compute_dc_pf
export compute_dc_pf

import PowerModels: run_model
export run_model
import PowerModels: instantiate_model
export instantiate_model

# exact non-convex models
import PowerModels: ACPPowerModel
export ACPPowerModel
import PowerModels: ACRPowerModel
export ACRPowerModel
import PowerModels: ACTPowerModel
export ACTPowerModel
import PowerModels: IVRPowerModel
export IVRPowerModel

# linear approximations
import PowerModels: DCPPowerModel
export DCPPowerModel
import PowerModels: DCMPPowerModel
export DCMPPowerModel
import PowerModels: BFAPowerModel
export BFAPowerModel
import PowerModels: NFAPowerModel
export NFAPowerModel

# quadratic approximations
import PowerModels: DCPLLPowerModel
export DCPLLPowerModel
import PowerModels: LPACCPowerModel
export LPACCPowerModel

# quadratic relaxations
import PowerModels: SOCWRPowerModel
export SOCWRPowerModel
import PowerModels: SOCWRConicPowerModel
export SOCWRConicPowerModel
import PowerModels: SOCBFPowerModel
export SOCBFPowerModel
import PowerModels: SOCBFConicPowerModel
export SOCBFConicPowerModel
import PowerModels: QCRMPowerModel
export QCRMPowerModel
import PowerModels: QCLSPowerModel
export QCLSPowerModel

# sdp relaxations
import PowerModels: SDPWRMPowerModel
export SDPWRMPowerModel
import PowerModels: SparseSDPWRMPowerModel
export SparseSDPWRMPowerModel

const PSY = PowerSystems
const IS = PSY.IS
const PM = PowerModels

include("definitions.jl")
include("pm_data_translator.jl")
include("devices/bus.jl")
include("devices/branch.jl")
include("devices/load.jl")
include("devices/gen.jl")
include("devices/storage.jl")
include("devices/areas.jl")
include("pm_wrapper.jl")

end # module

max_stars_repo_name: NREL-SIIP/PowerModelsInterface

max_issues_repo_name: NREL-SIIP/PowerModelsInterface

max_forks_repo_name: NREL-SIIP/PowerModelsInterface


text:
"""
Analytical reports generated during training, for debugging and
hyperparameters tuning.
"""
module Report

# Some imports for docstrings cross-references
import ..MCTS, ..Network, ..MemAnalysisParams

"""
    Report.Loss

Decomposition of the loss in a sum of terms (all have type `Float32`).

- `L` is the total loss: `L == Lp + Lv + Lreg + Linv`
- `Lp` is the policy cross-entropy loss term
- `Lv` is the average value mean square error
- `Lreg` is the L2 regularization loss term
- `Linv` is the loss term penalizing the average weight put by the
  network on invalid actions
"""
struct Loss
  L :: Float32
  Lp :: Float32
  Lv :: Float32
  Lreg :: Float32
  Linv :: Float32
end

"""
    Report.LearningStatus

Statistics about the performance of the neural network on a subset of the
memory buffer.

- `loss`: detailed loss on the samples, as an object of type
    [`Report.Loss`](@ref)
- `Hp`: average entropy of the ``π`` component of samples (MCTS policy);
    this quantity is independent of the network and therefore constant
    during a learning iteration
- `Hpnet`: average entropy of the network's prescribed policy on the samples
"""
struct LearningStatus
  loss :: Loss
  Hp :: Float32 # property of the memory, constant during a learning iteration
  Hpnet :: Float32
end

"""
    Report.Checkpoint

Report generated after a checkpoint evaluation.

- `batch_id`: number of batches after which the checkpoint was computed
- `status_after`: learning status at the checkpoint, as an object of type
   [`Report.LearningStatus`](@ref)
- `reward`: average reward collected by the contender network
- `redundancy`: ratio of duplicate positions encountered during the evaluation,
   not counting the initial position. If this number is too high, you may
   want to increase the move selection temperature.
- `nn_replaced`: true if the current best neural network was updated after
   the checkpoint
"""
struct Checkpoint
  batch_id :: Int
  status_after :: LearningStatus
  reward :: Float64
  redundancy :: Float64
  nn_replaced :: Bool
end

"""
    Report.Learning

Report generated at the end of the learning phase of an iteration.

- `time_convert`, `time_loss`, `time_train` and `time_eval` are the
    amounts of time (in seconds) spent at converting the samples,
    computing losses, performing gradient updates and evaluating checkpoints
    respectively
- `initial_status`: status before the learning phase, as an object of type
    [`Report.LearningStatus`](@ref)
- `losses`: loss value on each minibatch
- `checkpoints`: vector of [`Report.Checkpoint`](@ref) reports
- `nn_replaced`: true if the best neural network was replaced
"""
struct Learning
  time_convert :: Float64
  time_loss :: Float64
  time_train :: Float64
  time_eval :: Float64
  initial_status :: LearningStatus
  losses :: Vector{Float32}
  checkpoints :: Vector{Checkpoint}
  nn_replaced :: Bool
end

"""
    Report.Samples

Statistics about a set of samples, as collected during memory analysis.

- `num_samples`: total number of samples
- `num_boards`: number of distinct board positions
- `Wtot`: total weight of the samples
- `status`: [`Report.LearningStatus`](@ref) statistics of the current network
    on the samples
"""
struct Samples
  num_samples :: Int
  num_boards :: Int
  Wtot :: Float32
  status :: LearningStatus
end

"""
    Report.StageSamples

Statistics for the samples corresponding to a particular game stage,
as collected during memory analysis.

The samples whose statistics are collected in the
[`samples_stats`](@ref Report.Samples) field correspond to historical positions
where the number of remaining moves until the end of the game was in the range
defined by the `min_remaining_length` and `max_remaining_length` fields.
"""
struct StageSamples
  min_remaining_length :: Int
  max_remaining_length :: Int
  samples_stats :: Samples
end

"""
    Report.Memory

Report generated by the memory analysis phase of an iteration. It features
samples statistics for
  - the whole memory buffer (`all_samples :: Report.Samples`)
  - the samples collected during the last self-play iteration
     (`latest_batch :: Report.Samples`)
  - the subsets of the memory buffer corresponding to different game stages:
     (`per_game_stage :: Vector{Report.StageSamples}`)

See [`MemAnalysisParams`](@ref).
"""
struct Memory
  latest_batch :: Samples
  all_samples :: Samples
  per_game_stage :: Vector{StageSamples}
end

"""
    Report.SelfPlay

Report generated after the self-play phase of an iteration.

- `inference_time_ratio`: see [`MCTS.inference_time_ratio`](@ref)
- `samples_gen_speed`: average number of samples generated per second
- `average_exploration_depth`: see [`MCTS.average_exploration_depth`](@ref)
- `mcts_memory_footprint`: estimation of the maximal memory footprint of the
    MCTS tree during self-play, as computed by
    [`MCTS.approximate_memory_footprint`](@ref)
- `memory_size`: number of samples in the memory buffer at the end of the
    self-play phase
- `memory_num_distinct_boards`: number of distinct board positions in the
    memory buffer at the end of the self-play phase
"""
struct SelfPlay
  inference_time_ratio :: Float64
  samples_gen_speed :: Float64
  average_exploration_depth :: Float64
  mcts_memory_footprint :: Int
  memory_size :: Int
  memory_num_distinct_boards :: Int
end

"""
    Report.Perfs

Performances report for a subroutine.
- `time`: total time spent, in seconds
- `allocated`: amount of memory allocated, in bytes
- `gc_time`: total amount of time spent in the garbage collector
"""
struct Perfs
  time :: Float64
  allocated :: Int64
  gc_time :: Float64
end

"""
    Report.Iteration

Report generated after each training iteration.
- `perfs_self_play`, `perfs_memory_analysis` and `perfs_learning` are
    performance reports for the different phases of the iteration, as
    objects of type [`Report.Perfs`](@ref)
- `self_play`, `memory`, `learning` have types [`Report.SelfPlay`](@ref),
    [`Report.SelfPlay`](@ref) and [`Report.Learning`](@ref) respectively
"""
struct Iteration
  perfs_self_play :: Perfs
  perfs_memory_analysis :: Perfs
  perfs_learning :: Perfs
  self_play :: SelfPlay
  memory :: Union{Memory, Nothing}
  learning :: Learning
end

"""
    Report.Initial

Report summarizing the configuration of an agent before training starts.
- `num_network_parameters`: see [`Network.num_parameters`](@ref)
- `num_network_regularized_parameters`:
    see [`Network.num_regularized_parameters`](@ref)
- `mcts_footprint_per_node`: see [`MCTS.memory_footprint_per_node`](@ref)
"""
struct Initial
  num_network_parameters :: Int
  num_network_regularized_parameters :: Int
  mcts_footprint_per_node :: Int
end

#####
##### Profiling utilities
#####

macro timed(e)
  quote
    local v, t, mem, gct = Base.@timed $(esc(e))
    v, Perfs(t, mem, gct)
  end
end

#####
##### Printing and plotting
#####

using Formatting
using ..Log

const NUM_COL = Log.ColType(7, x -> fmt(".4f", x))
const BIGINT_COL = Log.ColType(10, n -> format(ceil(Int, n), commas=true))

const LEARNING_STATUS_TABLE = Log.Table([
  ("Loss",   NUM_COL,     s -> s.loss.L),
  ("Lv",     NUM_COL,     s -> s.loss.Lv),
  ("Lp",     NUM_COL,     s -> s.loss.Lp),
  ("Lreg",   NUM_COL,     s -> s.loss.Lreg),
  ("Linv",   NUM_COL,     s -> s.loss.Linv),
  ("Hp",     NUM_COL,     s -> s.Hp),
  ("Hpnet",  NUM_COL,     s -> s.Hpnet)])

const SAMPLES_STATS_TABLE = Log.Table([
  ("Loss",   NUM_COL,     s -> s.status.loss.L),
  ("Lv",     NUM_COL,     s -> s.status.loss.Lv),
  ("Lp",     NUM_COL,     s -> s.status.loss.Lp),
  ("Lreg",   NUM_COL,     s -> s.status.loss.Lreg),
  ("Linv",   NUM_COL,     s -> s.status.loss.Linv),
  ("Hpnet",  NUM_COL,     s -> s.status.Hpnet),
  ("Hp",     NUM_COL,     s -> s.status.Hp),
  ("Wtot",   BIGINT_COL,  s -> s.Wtot),
  ("Nb",     BIGINT_COL,  s -> s.num_boards),
  ("Ns",     BIGINT_COL,  s -> s.num_samples)])

function print(logger::Logger, status::Report.LearningStatus; kw...)
  Log.table_row(logger, LEARNING_STATUS_TABLE, status; kw...)
end

function print(logger::Logger, stats::Report.Memory)
  content, styles, comments = [], [], []
  # All samples
  push!(content, stats.all_samples)
  push!(styles, Log.BOLD)
  push!(comments, ["all samples"])
  # Latest batch
  push!(content, stats.latest_batch)
  push!(styles, Log.BOLD)
  push!(comments, ["latest batch"])
  # Per game stage
  for stage in stats.per_game_stage
    minrem = stage.min_remaining_length
    maxrem = stage.max_remaining_length
    push!(content, stage.samples_stats)
    push!(styles, Log.NO_STYLE)
    push!(comments, ["$minrem to $maxrem turns left"])
  end
  Log.table(
    logger, SAMPLES_STATS_TABLE, content, styles=styles, comments=comments)
end

function print(logger::Logger, report::SelfPlay)
  t = round(Int, 100 * report.inference_time_ratio)
  Log.print(logger, "Time spent on inference: $(t)%")
  sspeed = format(round(Int, report.samples_gen_speed), commas=true)
  Log.print(logger, "Generating $(sspeed) samples per second on average")
  avgdepth = fmt(".1f", report.average_exploration_depth)
  Log.print(logger, "Average exploration depth: $avgdepth")
  memf = format(report.mcts_memory_footprint, autoscale=:metric, precision=2)
  Log.print(logger, "MCTS memory footprint: $(memf)B")
  mems = format(report.memory_size, commas=true)
  memd = format(report.memory_num_distinct_boards, commas=true)
  Log.print(logger, "Experience buffer size: $(mems) ($(memd) distinct boards)")
end

function print(logger::Logger, report::Initial)
  nnparams = format(report.num_network_parameters, commas=true)
  Log.print(logger, "Number of network parameters: $nnparams")
  nnregparams = format(report.num_network_regularized_parameters, commas=true)
  Log.print(logger, "Number of regularized network parameters: $nnregparams")
  mfpn = report.mcts_footprint_per_node
  Log.print(logger, "Memory footprint per MCTS node: $(mfpn) bytes")
end

end


text:
using Test
import Pluto: Configuration, Notebook, ServerSession, ClientSession, update_run!, Cell, WorkspaceManager
import Pluto.Configuration: Options, EvaluationOptions
import Distributed

@testset "Reactivity" begin
    🍭 = ServerSession()
    🍭.options.evaluation.workspace_use_distributed = false

    fakeclient = ClientSession(:fake, nothing)
    🍭.connected_clients[fakeclient.id] = fakeclient

    @testset "Basic $(parallel ? "distributed" : "single-process")" for parallel in [false, true]
        🍭.options.evaluation.workspace_use_distributed = parallel

        notebook = Notebook([
            Cell("x = 1"),
            Cell("y = x"),
            Cell("f(x) = x + y"),
            Cell("f(4)"),

            Cell("""begin
                g(a) = x
                g(a,b) = y
            end"""),
            Cell("g(6) + g(6,6)"),

            Cell("import Distributed"),
            Cell("Distributed.myid()"),
        ])
        fakeclient.connected_notebook = notebook

        @test !haskey(WorkspaceManager.workspaces, notebook.notebook_id)

        update_run!(🍭, notebook, notebook.cells[1:2])
        @test notebook.cells[1].output.body == notebook.cells[2].output.body
        @test notebook.cells[1].output.rootassignee == :x
        @test notebook.cells[1].runtime !== nothing
        setcode(notebook.cells[1], "x = 12")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[1].output.body == notebook.cells[2].output.body
        @test notebook.cells[2].runtime !== nothing

        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].errored == false
        @test notebook.cells[3].output.rootassignee === nothing

        update_run!(🍭, notebook, notebook.cells[4])
        @test notebook.cells[4].output.body == "16"
        @test notebook.cells[4].errored == false
        @test notebook.cells[4].output.rootassignee === nothing

        setcode(notebook.cells[1], "x = 912")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[4].output.body == "916"

        setcode(notebook.cells[3], "f(x) = x")
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[4].output.body == "4"

        setcode(notebook.cells[1], "x = 1")
        setcode(notebook.cells[2], "y = 2")
        update_run!(🍭, notebook, notebook.cells[1:2])
        update_run!(🍭, notebook, notebook.cells[5:6])
        @test notebook.cells[5].errored == false
        @test notebook.cells[6].output.body == "3"

        setcode(notebook.cells[2], "y = 1")
        update_run!(🍭, notebook, notebook.cells[2])
        @test notebook.cells[6].output.body == "2"

        setcode(notebook.cells[1], "x = 2")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[6].output.body == "3"

        update_run!(🍭, notebook, notebook.cells[7:8])
        @test if parallel
            notebook.cells[8].output.body != string(Distributed.myid())
        else
            notebook.cells[8].output.body == string(Distributed.myid())
        end

        WorkspaceManager.unmake_workspace((🍭, notebook))

    end

    🍭.options.evaluation.workspace_use_distributed = false

    @testset "Mutliple assignments" begin
        notebook = Notebook([
            Cell("x = 1"),
            Cell("x = 2"),
            Cell("f(x) = 3"),
            Cell("f(x) = 4"),
            Cell("g(x) = 5"),
            Cell("g = 6"),
        ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1])
        update_run!(🍭, notebook, notebook.cells[2])
        @test occursinerror("Multiple", notebook.cells[1])
        @test occursinerror("Multiple", notebook.cells[2])

        setcode(notebook.cells[1], "")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[1].errored == false
        @test notebook.cells[2].errored == false

    # https://github.com/fonsp/Pluto.jl/issues/26
        setcode(notebook.cells[1], "x = 1")
        update_run!(🍭, notebook, notebook.cells[1])
        setcode(notebook.cells[2], "x")
        update_run!(🍭, notebook, notebook.cells[2])
        @test notebook.cells[1].errored == false
        @test notebook.cells[2].errored == false

        update_run!(🍭, notebook, notebook.cells[3])
        update_run!(🍭, notebook, notebook.cells[4])
        @test occursinerror("Multiple", notebook.cells[3])
        @test occursinerror("Multiple", notebook.cells[4])

        setcode(notebook.cells[3], "")
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].errored == false
        @test notebook.cells[4].errored == false

        update_run!(🍭, notebook, notebook.cells[5])
        update_run!(🍭, notebook, notebook.cells[6])
        @test occursinerror("Multiple", notebook.cells[5])
        @test occursinerror("Multiple", notebook.cells[6])

        setcode(notebook.cells[5], "")
        update_run!(🍭, notebook, notebook.cells[5])
        @test notebook.cells[5].errored == false
        @test notebook.cells[6].errored == false

        WorkspaceManager.unmake_workspace((🍭, notebook))
    end

    @testset "Mutliple assignments topology" begin
        notebook = Notebook([
            Cell("x = 1"),
            Cell("z = 4 + y"),
            Cell("y = x + 2"),
            Cell("y = x + 3"),
        ])
        notebook.topology = Pluto.updated_topology(notebook.topology, notebook, notebook.cells)

        let topo_order = Pluto.topological_order(notebook, notebook.topology, notebook.cells[[1]])
            @test indexin(topo_order.runnable, notebook.cells) == [1,2]
            @test topo_order.errable |> keys == notebook.cells[[3,4]] |> Set
        end
        let topo_order = Pluto.topological_order(notebook, notebook.topology, notebook.cells[[1]], allow_multiple_defs=true)
            @test indexin(topo_order.runnable, notebook.cells) == [1,3,4,2] # x first, y second and third, z last
            # this also tests whether multiple defs run in page order
            @test topo_order.errable == Dict()
        end
    end

    @testset "Pkg topology workarounds" begin
        notebook = Notebook([
            Cell("1 + 1"),
            Cell("json([1,2])"),
            Cell("using JSON"),
            Cell("""Pkg.add("JSON")"""),
            Cell("Pkg.activate(mktempdir())"),
            Cell("import Pkg"),
            Cell("using Revise"),
            Cell("1 + 1"),
        ])
        notebook.topology = Pluto.updated_topology(notebook.topology, notebook, notebook.cells)

        topo_order = Pluto.topological_order(notebook, notebook.topology, notebook.cells)
        @test indexin(topo_order.runnable, notebook.cells) == [6, 5, 4, 7, 3, 1, 2, 8]
        # 6, 5, 4, 3 should run first (this is implemented using `cell_precedence_heuristic`), in that order
        # 1, 2, 7 remain, and should run in notebook order.

        # if the cells were placed in reverse order...
        reverse!(notebook.cell_order)
        topo_order = Pluto.topological_order(notebook, notebook.topology, notebook.cells)
        @test indexin(topo_order.runnable, reverse(notebook.cells)) == [6, 5, 4, 7, 3, 8, 2, 1]
        # 6, 5, 4, 3 should run first (this is implemented using `cell_precedence_heuristic`), in that order
        # 1, 2, 7 remain, and should run in notebook order, which is 7, 2, 1.

        reverse!(notebook.cell_order)
    end

    @testset "Pkg topology workarounds -- hard" begin
        notebook = Notebook([
            Cell("json([1,2])"),
            Cell("using JSON"),
            Cell("Pkg.add(package_name)"),
            Cell(""" package_name = "JSON" """),
            Cell("Pkg.activate(envdir)"),
            Cell("envdir = mktempdir()"),
            Cell("import Pkg"),
            Cell("using JSON3, Revise"),
        ])

        notebook.topology = Pluto.updated_topology(notebook.topology, notebook, notebook.cells)

        topo_order = Pluto.topological_order(notebook, notebook.topology, notebook.cells)

        comesbefore(A, first, second) = findfirst(isequal(first),A) < findfirst(isequal(second), A)

        run_order = indexin(topo_order.runnable, notebook.cells)

        # like in the previous test
        @test comesbefore(run_order, 7, 5)
        @test_broken comesbefore(run_order, 5, 3)
        @test_broken comesbefore(run_order, 3, 2)
        @test comesbefore(run_order, 2, 1)
        @test comesbefore(run_order, 8, 2)
        @test comesbefore(run_order, 8, 1)

        # the variable dependencies
        @test comesbefore(run_order, 6, 5)
        @test comesbefore(run_order, 4, 3)
    end

    @testset "Mixed usings and reactivity" begin
        notebook = Notebook([
            Cell("a; using Dates"),
            Cell("isleapyear(2)"),
            Cell("a = 3; using LinearAlgebra"),
        ])

        notebook.topology = Pluto.updated_topology(notebook.topology, notebook, notebook.cells)
        topo_order = Pluto.topological_order(notebook, notebook.topology, notebook.cells)
        run_order = indexin(topo_order.runnable, notebook.cells)

        @test run_order == [3, 1, 2]
    end

    @testset "Multiple methods across cells" begin
        notebook = Notebook([
            Cell("a(x) = 1"),
            Cell("a(x,y) = 2"),
            Cell("a(3)"),
            Cell("a(4,4)"),

            Cell("b = 5"),
            Cell("b(x) = 6"),
            Cell("b + 7"),
            Cell("b(8)"),

            Cell("Base.tan(x::String) = 9"),
            Cell("Base.tan(x::Missing) = 10"),
            Cell("Base.tan(\"eleven\")"),
            Cell("Base.tan(missing)"),
            Cell("tan(missing)"),

            Cell("d(x::Integer) = 14"),
            Cell("d(x::String) = 15"),
            Cell("d(16)"),
            Cell("d(\"seventeen\")"),
            Cell("d"),

            Cell("struct asdf; x; y; end"),
            Cell(""),
            Cell("asdf(21, 21)"),
            Cell("asdf(22)"),

            Cell("@enum e1 e2 e3"),
            Cell("@enum e4 e5=24"),
            Cell("Base.@enum e6 e7=25 e8"),
            Cell("Base.@enum e9 e10=26 e11"),
            Cell("""@enum e12 begin
                    e13=27
                    e14
                end"""),
        ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1:4])
        @test notebook.cells[1].errored == false
        @test notebook.cells[2].errored == false
        @test notebook.cells[3].output.body == "1"
        @test notebook.cells[4].output.body == "2"

        setcode(notebook.cells[1], "a(x,x) = 999")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[1].errored == true
        @test notebook.cells[2].errored == true
        @test notebook.cells[3].errored == true
        @test notebook.cells[4].errored == true

        setcode(notebook.cells[1], "a(x) = 1")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[1].errored == false
        @test notebook.cells[2].errored == false
        @test notebook.cells[3].output.body == "1"
        @test notebook.cells[4].output.body == "2"

        setcode(notebook.cells[1], "")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[1].errored == false
        @test notebook.cells[2].errored == false
        @test notebook.cells[3].errored == true
        @test notebook.cells[4].output.body == "2"

        update_run!(🍭, notebook, notebook.cells[5:8])
        @test notebook.cells[5].errored == true
        @test notebook.cells[6].errored == true
        @test notebook.cells[7].errored == true
        @test notebook.cells[8].errored == true

        setcode(notebook.cells[5], "")
        update_run!(🍭, notebook, notebook.cells[5])
        @test notebook.cells[5].errored == false
        @test notebook.cells[6].errored == false
        @test notebook.cells[7].errored == true
        @test notebook.cells[8].output.body == "6"

        setcode(notebook.cells[5], "b = 5")
        setcode(notebook.cells[6], "")
        update_run!(🍭, notebook, notebook.cells[5:6])
        @test notebook.cells[5].errored == false
        @test notebook.cells[6].errored == false
        @test notebook.cells[7].output.body == "12"
        @test notebook.cells[8].errored == true

        update_run!(🍭, notebook, notebook.cells[11:13])
        @test notebook.cells[12].output.body == "missing"

        update_run!(🍭, notebook, notebook.cells[9:10])
        @test notebook.cells[9].errored == false
        @test notebook.cells[10].errored == false
        @test notebook.cells[11].output.body == "9"
        @test notebook.cells[12].output.body == "10"
        @test_broken notebook.cells[13].output.body == "10"
        update_run!(🍭, notebook, notebook.cells[13])
        @test notebook.cells[13].output.body == "10"

        setcode(notebook.cells[9], "")
        update_run!(🍭, notebook, notebook.cells[9])
        @test notebook.cells[11].errored == true
        @test notebook.cells[12].output.body == "10"

        setcode(notebook.cells[10], "")
        update_run!(🍭, notebook, notebook.cells[10])
        @test notebook.cells[11].errored == true
        @test notebook.cells[12].output.body == "missing"

        # Cell("d(x::Integer) = 14"),
        # Cell("d(x::String) = 15"),
        # Cell("d(16)"),
        # Cell("d(\"seventeen\")"),
        # Cell("d"),

        update_run!(🍭, notebook, notebook.cells[16:18])
        @test notebook.cells[16].errored == true
        @test notebook.cells[17].errored == true
        @test notebook.cells[18].errored == true

        update_run!(🍭, notebook, notebook.cells[14])
        @test notebook.cells[16].errored == false
        @test notebook.cells[17].errored == true
        @test notebook.cells[18].errored == false

        update_run!(🍭, notebook, notebook.cells[15])
        @test notebook.cells[16].errored == false
        @test notebook.cells[17].errored == false
        @test notebook.cells[18].errored == false

        setcode(notebook.cells[14], "")
        update_run!(🍭, notebook, notebook.cells[14])
        @test notebook.cells[16].errored == true
        @test notebook.cells[17].errored == false
        @test notebook.cells[18].errored == false

        setcode(notebook.cells[15], "")
        update_run!(🍭, notebook, notebook.cells[15])
        @test notebook.cells[16].errored == true
        @test notebook.cells[17].errored == true
        @test notebook.cells[18].errored == true
        @test occursinerror("UndefVarError", notebook.cells[18])

        # Cell("struct e; x; y; end"),
        # Cell(""),
        # Cell("e(21, 21)"),
        # Cell("e(22)"),

        update_run!(🍭, notebook, notebook.cells[19:22])
        @test notebook.cells[19].errored == false
        @test notebook.cells[21].errored == false
        @test notebook.cells[22].errored == true

        setcode(notebook.cells[20], "asdf(x) = asdf(x,x)")
        update_run!(🍭, notebook, notebook.cells[20])
        @test occursinerror("Multiple definitions", notebook.cells[19])
        @test occursinerror("Multiple definitions", notebook.cells[20])
        @test occursinerror("asdf", notebook.cells[20])
        @test occursinerror("asdf", notebook.cells[20])
        @test notebook.cells[21].errored == true
        @test notebook.cells[22].errored == true

        setcode(notebook.cells[20], "")
        update_run!(🍭, notebook, notebook.cells[20])
        @test notebook.cells[19].errored == false
        @test notebook.cells[20].errored == false
        @test notebook.cells[21].errored == false
        @test notebook.cells[22].errored == true

        setcode(notebook.cells[19], "begin struct asdf; x; y; end; asdf(x) = asdf(x,x); end")
        setcode(notebook.cells[20], "")
        update_run!(🍭, notebook, notebook.cells[19:20])
        @test notebook.cells[19].errored == false
        @test notebook.cells[20].errored == false
        @test notebook.cells[21].errored == false
        @test notebook.cells[22].errored == false

        update_run!(🍭, notebook, notebook.cells[23:27])
        @test notebook.cells[23].errored == false
        @test notebook.cells[24].errored == false
        @test notebook.cells[25].errored == false
        @test notebook.cells[26].errored == false
        @test notebook.cells[27].errored == false
        update_run!(🍭, notebook, notebook.cells[23:27])
        @test notebook.cells[23].errored == false
        @test notebook.cells[24].errored == false
        @test notebook.cells[25].errored == false
        @test notebook.cells[26].errored == false
        @test notebook.cells[27].errored == false

        setcode.(notebook.cells[23:27], [""])
        update_run!(🍭, notebook, notebook.cells[23:27])

        setcode(notebook.cells[23], "@assert !any(isdefined.([@__MODULE__], [Symbol(:e,i) for i in 1:14]))")
        update_run!(🍭, notebook, notebook.cells[23])
        @test notebook.cells[23].errored == false

        WorkspaceManager.unmake_workspace((🍭, notebook))

        # for some unsupported edge cases, see:
        # https://github.com/fonsp/Pluto.jl/issues/177#issuecomment-645039993
    end

    @testset "Cyclic" begin
        notebook = Notebook([
            Cell("xxx = yyy"),
            Cell("yyy = xxx"),
            Cell("zzz = yyy"),

            Cell("aaa() = bbb"),
            Cell("bbb = aaa()"),
        ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1:3])
        @test occursinerror("Cyclic reference", notebook.cells[1])
        @test occursinerror("xxx", notebook.cells[1])
        @test occursinerror("yyy", notebook.cells[1])
        @test occursinerror("Cyclic reference", notebook.cells[2])
        @test occursinerror("xxx", notebook.cells[2])
        @test occursinerror("yyy", notebook.cells[2])
        @test occursinerror("UndefVarError", notebook.cells[3])

        setcode(notebook.cells[1], "xxx = 1")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[1].output.body == "1"
        @test notebook.cells[2].output.body == "1"
        @test notebook.cells[3].output.body == "1"

        setcode(notebook.cells[1], "xxx = zzz")
        update_run!(🍭, notebook, notebook.cells[1])
        @test occursinerror("Cyclic reference", notebook.cells[1])
        @test occursinerror("Cyclic reference", notebook.cells[2])
        @test occursinerror("Cyclic reference", notebook.cells[3])
        @test occursinerror("xxx", notebook.cells[1])
        @test occursinerror("yyy", notebook.cells[1])
        @test occursinerror("zzz", notebook.cells[1])
        @test occursinerror("xxx", notebook.cells[2])
        @test occursinerror("yyy", notebook.cells[2])
        @test occursinerror("zzz", notebook.cells[2])
        @test occursinerror("xxx", notebook.cells[3])
        @test occursinerror("yyy", notebook.cells[3])
        @test occursinerror("zzz", notebook.cells[3])

        setcode(notebook.cells[3], "zzz = 3")
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[1].output.body == "3"
        @test notebook.cells[2].output.body == "3"
        @test notebook.cells[3].output.body == "3"

        update_run!(🍭, notebook, notebook.cells[4:5])
        @test occursinerror("Cyclic reference", notebook.cells[4])
        @test occursinerror("aaa", notebook.cells[4])
        @test occursinerror("bbb", notebook.cells[4])
        @test occursinerror("Cyclic reference", notebook.cells[5])
        @test occursinerror("aaa", notebook.cells[5])
        @test occursinerror("bbb", notebook.cells[5])

        WorkspaceManager.unmake_workspace((🍭, notebook))
    end

    @testset "Variable deletion" begin
        notebook = Notebook([
            Cell("x = 1"),
            Cell("y = x"),
            Cell("struct a; x end"),
            Cell("a")
        ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1:2])
        @test notebook.cells[1].output.body == notebook.cells[2].output.body

        setcode(notebook.cells[1], "")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[1].errored == false
        @test occursinerror("x not defined", notebook.cells[2])

        update_run!(🍭, notebook, notebook.cells[4])
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].errored == false
        @test notebook.cells[4].errored == false
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].errored == false
        @test notebook.cells[4].errored == false
        setcode(notebook.cells[3], "struct a; x; y end")
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].errored == false
        @test notebook.cells[4].errored == false
        setcode(notebook.cells[3], "")
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].errored == false
        @test notebook.cells[4].errored == true

        WorkspaceManager.unmake_workspace((🍭, notebook))
    end

    @testset "Recursion" begin
        notebook = Notebook([
            Cell("f(n) = n * f(n-1)"),

            Cell("k = 1"),
            Cell("""begin
                g(n) = h(n-1) + k
                h(n) = n > 0 ? g(n-1) : 0
            end"""),

            Cell("h(4)"),
        ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[1].output.body == "f" || startswith(notebook.cells[1].output.body, "f (generic function with ")
        @test notebook.cells[1].errored == false

        update_run!(🍭, notebook, notebook.cells[2:3])
        @test notebook.cells[2].errored == false
        @test notebook.cells[3].errored == false
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].errored == false

        update_run!(🍭, notebook, notebook.cells[4])
        @test notebook.cells[4].output.body == "2"

        setcode(notebook.cells[2], "k = 2")
        update_run!(🍭, notebook, notebook.cells[2])
        @test notebook.cells[4].output.body == "4"

        WorkspaceManager.unmake_workspace((🍭, notebook))
    end

    @testset "Variable cannot reference its previous value" begin
        notebook = Notebook([
        Cell("x = 3")
    ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1])
        setcode(notebook.cells[1], "x = x + 1")
        update_run!(🍭, notebook, notebook.cells[1])
        @test occursinerror("UndefVarError", notebook.cells[1])

        WorkspaceManager.unmake_workspace((🍭, notebook))
    end

    notebook = Notebook([
        Cell("y = 1"),
        Cell("f(x) = x + y"),
        Cell("f(3)"),

        Cell("g(a,b) = a+b"),
        Cell("g(5,6)"),

        Cell("h(x::Int) = x"),
        Cell("h(7)"),
        Cell("h(8.0)"),

        Cell("p(x) = 9"),
        Cell("p isa Function"),

        Cell("module Something
            export a
            a(x::String) = \"🐟\"
        end"),
        Cell("using .Something"),
        Cell("a(x::Int) = x"),
        Cell("a(\"i am a \")"),
        Cell("a(15)"),

        Cell("module Different
            export b
            b(x::String) = \"🐟\"
        end"),
        Cell("import .Different: b"),
        Cell("b(x::Int) = x"),
        Cell("b(\"i am a \")"),
        Cell("b(20)"),

        Cell("module Wow
            export c
            c(x::String) = \"🐟\"
        end"),
        Cell("begin
            import .Wow: c
            c(x::Int) = x
        end"),
        Cell("c(\"i am a \")"),
        Cell("c(24)"),

        Cell("Ref((25,:fish))"),
        Cell("begin
            import Base: show
            show(io::IO, x::Ref{Tuple{Int,Symbol}}) = write(io, \"🐟\")
        end"),

        Cell("Base.isodd(n::Integer) = \"🎈\""),
        Cell("Base.isodd(28)"),
        Cell("isodd(29)"),

        Cell("using Dates"),
        Cell("year(DateTime(31))"),
    ])
    fakeclient.connected_notebook = notebook

    @testset "Changing functions" begin

        update_run!(🍭, notebook, notebook.cells[2])
        @test notebook.cells[2].errored == false

        update_run!(🍭, notebook, notebook.cells[1])
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].output.body == "4"

        setcode(notebook.cells[1], "y = 2")
        update_run!(🍭, notebook, notebook.cells[1])
        @test notebook.cells[3].output.body == "5"
        @test notebook.cells[2].errored == false

        setcode(notebook.cells[1], "y")
        update_run!(🍭, notebook, notebook.cells[1])
        @test occursinerror("UndefVarError", notebook.cells[1])
        @test notebook.cells[2].errored == false
        @test occursinerror("UndefVarError", notebook.cells[3])

        update_run!(🍭, notebook, notebook.cells[4])
        update_run!(🍭, notebook, notebook.cells[5])
        @test notebook.cells[5].output.body == "11"

        setcode(notebook.cells[4], "g(a) = a+a")
        update_run!(🍭, notebook, notebook.cells[4])
        @test notebook.cells[4].errored == false
        @test notebook.cells[5].errored == true

        setcode(notebook.cells[5], "g(5)")
        update_run!(🍭, notebook, notebook.cells[5])
        @test notebook.cells[5].output.body == "10"

        update_run!(🍭, notebook, notebook.cells[6])
        update_run!(🍭, notebook, notebook.cells[7])
        update_run!(🍭, notebook, notebook.cells[8])
        @test notebook.cells[6].errored == false
        @test notebook.cells[7].errored == false
        @test notebook.cells[8].errored == true

        setcode(notebook.cells[6], "h(x::Float64) = 2.0 * x")
        update_run!(🍭, notebook, notebook.cells[6])
        @test notebook.cells[6].errored == false
        @test notebook.cells[7].errored == true
        @test notebook.cells[8].errored == false

        update_run!(🍭, notebook, notebook.cells[9:10])
        @test notebook.cells[9].errored == false
        @test notebook.cells[10].output.body == "true"

        setcode(notebook.cells[9], "p = p")
        update_run!(🍭, notebook, notebook.cells[9])
        @test occursinerror("UndefVarError", notebook.cells[9])

        setcode(notebook.cells[9], "p = 9")
        update_run!(🍭, notebook, notebook.cells[9])
        @test notebook.cells[9].errored == false
        @test notebook.cells[10].output.body == "false"

        setcode(notebook.cells[9], "p(x) = 9")
        update_run!(🍭, notebook, notebook.cells[9])
        @test notebook.cells[9].errored == false
        @test notebook.cells[10].output.body == "true"
    end

    @testset "Extending imported functions" begin
        update_run!(🍭, notebook, notebook.cells[11:15])
        @test_broken notebook.cells[11].errored == false
        @test_broken notebook.cells[12].errored == false # multiple definitions for `Something` should be okay? == false
        @test notebook.cells[13].errored == false
        @test notebook.cells[14].errored == true # the definition for a was created before `a` was used, so it hides the `a` from `Something`
        @test notebook.cells[15].output.body == "15"

        @test_nowarn update_run!(🍭, notebook, notebook.cells[13:15])
        @test notebook.cells[13].errored == false
        @test notebook.cells[14].errored == true # the definition for a was created before `a` was used, so it hides the `a` from `Something`
        @test notebook.cells[15].output.body == "15"

        @test_nowarn update_run!(🍭, notebook, notebook.cells[16:20])
        @test notebook.cells[16].errored == false
        @test occursinerror("Multiple", notebook.cells[17])
        @test occursinerror("Multiple", notebook.cells[18])
        @test occursinerror("UndefVarError", notebook.cells[19])
        @test occursinerror("UndefVarError", notebook.cells[20])

        @test_nowarn update_run!(🍭, notebook, notebook.cells[21:24])
        @test notebook.cells[21].errored == false
        @test notebook.cells[22].errored == false
        @test notebook.cells[23].errored == false
        @test notebook.cells[23].output.body == "\"🐟\""
        @test notebook.cells[24].output.body == "24"

        setcode(notebook.cells[22], "import .Wow: c")
        @test_nowarn update_run!(🍭, notebook, notebook.cells[22])
        @test notebook.cells[22].errored == false
        @test notebook.cells[23].output.body == "\"🐟\""
        @test notebook.cells[23].errored == false
        @test notebook.cells[24].errored == true # the extension should no longer exist

        # https://github.com/fonsp/Pluto.jl/issues/59
        original_repr = Pluto.PlutoRunner.format_output(Ref((25, :fish)))[1]
        @test_nowarn update_run!(🍭, notebook, notebook.cells[25])
        @test notebook.cells[25].output.body isa Dict
        @test_nowarn update_run!(🍭, notebook, notebook.cells[26])
        @test_broken notebook.cells[25].output.body == "🐟" # cell'🍭 don't automatically call `show` again when a new overload is defined - that'🍭 a minor issue
        @test_nowarn update_run!(🍭, notebook, notebook.cells[25])
        @test notebook.cells[25].output.body == "🐟"

        setcode(notebook.cells[26], "")
        @test_nowarn update_run!(🍭, notebook, notebook.cells[26])
        @test_nowarn update_run!(🍭, notebook, notebook.cells[25])
        @test notebook.cells[25].output.body isa Dict

        @test_nowarn update_run!(🍭, notebook, notebook.cells[28:29])
        @test notebook.cells[28].output.body == "false"
        @test notebook.cells[29].output.body == "true"
        @test_nowarn update_run!(🍭, notebook, notebook.cells[27])
        @test notebook.cells[28].output.body == "\"🎈\""
        @test_broken notebook.cells[29].output.body == "\"🎈\"" # adding the overload doesn't trigger automatic re-eval because `isodd` doesn't match `Base.isodd`
        @test_nowarn update_run!(🍭, notebook, notebook.cells[28:29])
        @test notebook.cells[28].output.body == "\"🎈\""
        @test notebook.cells[29].output.body == "\"🎈\""

        setcode(notebook.cells[27], "")
        update_run!(🍭, notebook, notebook.cells[27])
        @test notebook.cells[28].output.body == "false"
        @test_broken notebook.cells[29].output.body == "true" # removing the overload doesn't trigger automatic re-eval because `isodd` doesn't match `Base.isodd`
        update_run!(🍭, notebook, notebook.cells[28:29])
        @test notebook.cells[28].output.body == "false"
        @test notebook.cells[29].output.body == "true"
    end

    @testset "Using external libraries" begin
        update_run!(🍭, notebook, notebook.cells[30:31])
        @test notebook.cells[30].errored == false
        @test notebook.cells[31].output.body == "31"
        update_run!(🍭, notebook, notebook.cells[31])
        @test notebook.cells[31].output.body == "31"

        setcode(notebook.cells[30], "")
        update_run!(🍭, notebook, notebook.cells[30:31])
        @test occursinerror("UndefVarError", notebook.cells[31])
    end
    WorkspaceManager.unmake_workspace((🍭, notebook))

    @testset "Functional programming" begin
        notebook = Notebook([
            Cell("a = 1"),
            Cell("map(2:2) do val; (a = val; 2*val) end |> last"),

            Cell("b = 3"),
            Cell("g = f"),
            Cell("f(x) = x + b"),
            Cell("g(6)"),

            Cell("h = [x -> x + b][1]"),
            Cell("h(8)"),
        ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1:2])
        @test notebook.cells[1].output.body == "1"
        @test notebook.cells[2].output.body == "4"

        update_run!(🍭, notebook, notebook.cells[3:6])
        @test notebook.cells[3].errored == false
        @test notebook.cells[4].errored == false
        @test notebook.cells[5].errored == false
        @test notebook.cells[6].errored == false
        @test notebook.cells[6].output.body == "9"

        setcode(notebook.cells[3], "b = -3")
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[6].output.body == "3"

        update_run!(🍭, notebook, notebook.cells[7:8])
        @test notebook.cells[7].errored == false
        @test notebook.cells[8].output.body == "5"

        setcode(notebook.cells[3], "b = 3")
        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[8].output.body == "11"

        WorkspaceManager.unmake_workspace((🍭, notebook))

    end

    @testset "Global assignments inside functions" begin
    # We currently have a slightly relaxed version of immutable globals:
    # globals can only be mutated/assigned _in a single cell_.
        notebook = Notebook([
            Cell("x = 1"),
            Cell("x = 2"),
            Cell("y = -3; y = 3"),
            Cell("z = 4"),
            Cell("let global z = 5 end"),
            Cell("wowow"),
            Cell("function floep(x) global wowow = x end"),
            Cell("floep(8)"),
            Cell("v"),
            Cell("function g(x) global v = x end; g(10)"),
            Cell("g(11)"),
            Cell("let
                    local r = 0
                    function f()
                        r = 12
                    end
                    f()
                    r
                end"),
            Cell("apple"),
            Cell("map(14:14) do i; global apple = orange; end"),
            Cell("orange = 15"),
        ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1])
        update_run!(🍭, notebook, notebook.cells[2])
        @test occursinerror("Multiple definitions for x", notebook.cells[1])
        @test occursinerror("Multiple definitions for x", notebook.cells[1])

        setcode(notebook.cells[2], "x + 1")
        update_run!(🍭, notebook, notebook.cells[2])
        @test notebook.cells[1].output.body == "1"
        @test notebook.cells[2].output.body == "2"

        update_run!(🍭, notebook, notebook.cells[3])
        @test notebook.cells[3].output.body == "3"

        update_run!(🍭, notebook, notebook.cells[4])
        update_run!(🍭, notebook, notebook.cells[5])
        @test occursinerror("Multiple definitions for z", notebook.cells[4])
        @test occursinerror("Multiple definitions for z", notebook.cells[5])

        update_run!(🍭, notebook, notebook.cells[6:7])
        @test occursinerror("UndefVarError", notebook.cells[6])

        # @test_broken occursinerror("assigns to global", notebook.cells[7])
        # @test_broken occursinerror("wowow", notebook.cells[7])
        # @test_broken occursinerror("floep", notebook.cells[7])

        update_run!(🍭, notebook, notebook.cells[8])
        @test_broken !occursinerror("UndefVarError", notebook.cells[6])

        update_run!(🍭, notebook, notebook.cells[9:10])
        @test !occursinerror("UndefVarError", notebook.cells[9])
        @test notebook.cells[10].errored == false

        update_run!(🍭, notebook, notebook.cells[11])
        @test_broken notebook.cells[9].errored == true
        @test_broken notebook.cells[10].errored == true
        @test_broken notebook.cells[11].errored == true

        update_run!(🍭, notebook, notebook.cells[12])
        @test notebook.cells[12].output.body == "12"

        update_run!(🍭, notebook, notebook.cells[13:15])
        @test notebook.cells[13].output.body == "15"
        @test notebook.cells[14].errored == false

        setcode(notebook.cells[15], "orange = 10005")
        update_run!(🍭, notebook, notebook.cells[15])
        @test notebook.cells[13].output.body == "10005"

        WorkspaceManager.unmake_workspace((🍭, notebook))
    end

    @testset "No top level return" begin
        notebook = Notebook([
            Cell("return 10"),
            Cell("return (0, 0)"),
            Cell("return (0, 0)"),
            Cell("return (0, 0, 0)"),
            Cell("begin return \"a string\" end"),
            Cell("""
                let
                    return []
                end
            """),
            Cell("""filter(1:3) do x
                return true
            end"""),

            # create struct to disable the function-generating optimization
            Cell("struct A1 end; return 10"),
            Cell("struct A2 end; return (0, 0)"),
            Cell("struct A3 end; return (0, 0)"),
            Cell("struct A4 end; return (0, 0, 0)"),
            Cell("struct A5 end; begin return \"a string\" end"),
            Cell("""
                struct A6 end; let
                    return []
                end
            """),
            Cell("""struct A7 end; filter(1:3) do x
                return true
            end"""),
        ])

        update_run!(🍭, notebook, notebook.cells)
        @test occursinerror("You can only use return inside a function.", notebook.cells[1])
        @test occursinerror("You can only use return inside a function.", notebook.cells[2])
        @test occursinerror("You can only use return inside a function.", notebook.cells[3])
        @test occursinerror("You can only use return inside a function.", notebook.cells[4])
        @test occursinerror("You can only use return inside a function.", notebook.cells[5])
        @test occursinerror("You can only use return inside a function.", notebook.cells[6])
        @test notebook.cells[7].errored == false

        @test occursinerror("You can only use return inside a function.", notebook.cells[8])
        @test occursinerror("You can only use return inside a function.", notebook.cells[9])
        @test occursinerror("You can only use return inside a function.", notebook.cells[10])
        @test occursinerror("You can only use return inside a function.", notebook.cells[11])
        @test occursinerror("You can only use return inside a function.", notebook.cells[12])
        @test occursinerror("You can only use return inside a function.", notebook.cells[13])
        @test notebook.cells[14].errored == false

        WorkspaceManager.unmake_workspace((🍭, notebook))
    end

    @testset "Function wrapping" begin
        notebook = Notebook([
            Cell("false && jlaksdfjalskdfj"),
            Cell("fonsi = 2"),
            Cell("""
            filter(1:fonsi) do x
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                false
            end |> length
            """),
            Cell("4"),
            Cell("[5]"),
            Cell("6 / 66"),
            Cell("false && (seven = 7)"),
            Cell("seven"),

            Cell("nine = :identity"),
            Cell("nine"),
            Cell("@__FILE__; nine"),
            Cell("@__FILE__; twelve = :identity"),
            Cell("@__FILE__; twelve"),
            Cell("twelve"),

            Cell("fifteen = :(1 + 1)"),
            Cell("fifteen"),
            Cell("@__FILE__; fifteen"),
            Cell("@__FILE__; eighteen = :(1 + 1)"),
            Cell("@__FILE__; eighteen"),
            Cell("eighteen"),

            Cell("qb = quote value end"),
            Cell("typeof(qb)"),

            Cell("qn0 = QuoteNode(:value)"),
            Cell("qn1 = :(:value)"),
            Cell("qn0"),
            Cell("qn1"),

            Cell("""
                 named_tuple(obj::T) where {T} = NamedTuple{fieldnames(T),Tuple{fieldtypes(T)...}}(ntuple(i -> getfield(obj, i), fieldcount(T)))
            """),
            Cell("named_tuple")
        ])

        update_run!(🍭, notebook, notebook.cells)
        @test notebook.cells[1].errored == false
        @test notebook.cells[1].output.body == "false"
        @test notebook.cells[22].output.body == "Expr"
        @test notebook.cells[25].output.body == ":(:value)"
        @test notebook.cells[26].output.body == ":(:value)"

        function benchmark(fonsi)
            filter(1:fonsi) do x
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                x = sum(1 for z in 1:x)
                false
            end |> length
        end

        bad = @elapsed benchmark(2)
        good = 0.01 * @elapsed for i in 1:100
            benchmark(2)
        end

        update_run!(🍭, notebook, notebook.cells)
        @test 0.1 * good < notebook.cells[3].runtime / 1.0e9 < 0.5 * bad

        old = notebook.cells[4].output.body
        setcode(notebook.cells[4], "4.0")
        update_run!(🍭, notebook, notebook.cells[4])
        @test old != notebook.cells[4].output.body

        old = notebook.cells[5].output.body
        setcode(notebook.cells[5], "[5.0]")
        update_run!(🍭, notebook, notebook.cells[5])
        @test old != notebook.cells[5].output.body

        old = notebook.cells[6].output.body
        setcode(notebook.cells[6], "66 / 6")
        update_run!(🍭, notebook, notebook.cells[6])
        @test old != notebook.cells[6].output.body

        @test notebook.cells[7].errored == false
        @test notebook.cells[7].output.body == "false"

        @test occursinerror("UndefVarError", notebook.cells[8])

        @test notebook.cells[9].output.body == ":identity"
        @test notebook.cells[10].output.body == ":identity"
        @test notebook.cells[11].output.body == ":identity"
        @test notebook.cells[12].output.body == ":identity"
        @test notebook.cells[13].output.body == ":identity"
        @test notebook.cells[14].output.body == ":identity"

        @test notebook.cells[15].output.body == ":(1 + 1)"
        @test notebook.cells[16].output.body == ":(1 + 1)"
        @test notebook.cells[17].output.body == ":(1 + 1)"
        @test notebook.cells[18].output.body == ":(1 + 1)"
        @test notebook.cells[19].output.body == ":(1 + 1)"
        @test notebook.cells[20].output.body == ":(1 + 1)"

        @test notebook.cells[27].errored == false
        @test notebook.topology.codes[notebook.cells[27]].function_wrapped == false
        @test notebook.cells[28].errored == false

        WorkspaceManager.unmake_workspace((🍭, notebook))

        @testset "Expression hash" begin
            same(a,b) = Pluto.PlutoRunner.expr_hash(a) == Pluto.PlutoRunner.expr_hash(b)

            @test same(:(1), :(1))
            @test !same(:(1), :(1.0))
            @test same(:(x + 1), :(x + 1))
            @test !same(:(x + 1), :(x + 1.0))
            @test same(:(1 |> a |> a |> a), :(1 |> a |> a |> a))
            @test same(:(a(b(1,2))), :(a(b(1,2))))
            @test !same(:(a(b(1,2))), :(a(b(1,3))))
            @test !same(:(a(b(1,2))), :(a(b(1,1))))
            @test !same(:(a(b(1,2))), :(a(b(2,1))))
        end
    end

    @testset "Run multiple" begin
        notebook = Notebook([
            Cell("x = []"),
            Cell("b = a + 2; push!(x,2)"),
            Cell("c = b + a; push!(x,3)"),
            Cell("a = 1; push!(x,4)"),
            Cell("a + b +c; push!(x,5)"),

            Cell("a = 1; push!(x,6)"),

            Cell("n = m; push!(x,7)"),
            Cell("m = n; push!(x,8)"),
            Cell("n = 1; push!(x,9)"),

            Cell("push!(x,10)"),
            Cell("push!(x,11)"),
            Cell("push!(x,12)"),
            Cell("push!(x,13)"),
            Cell("push!(x,14)"),

            Cell("join(x, '-')"),

            Cell("φ(16)"),
            Cell("φ(χ) = χ + υ"),
            Cell("υ = 18"),

            Cell("f(19)"),
            Cell("f(x) = x + g(x)"),
            Cell("g(x) = x + y"),
            Cell("y = 22"),
        ])
        fakeclient.connected_notebook = notebook

        update_run!(🍭, notebook, notebook.cells[1])

        @testset "Basic" begin
            update_run!(🍭, notebook, notebook.cells[2:5])

            update_run!(🍭, notebook, notebook.cells[15])
            @test notebook.cells[15].output.body == "\"4-2-3-5\""
        end

        @testset "Errors" begin
            update_run!(🍭, notebook, notebook.cells[6:9])

            # should all err, no change to `x`
            update_run!(🍭, notebook, notebook.cells[15])
            @test notebook.cells[15].output.body == "\"4-2-3-5\""
        end

        @testset "Maintain order when possible" begin
            update_run!(🍭, notebook, notebook.cells[10:14])

            update_run!(🍭, notebook, notebook.cells[15])
            @test notebook.cells[15].output.body == "\"4-2-3-5-10-11-12-13-14\""

            update_run!(🍭, notebook, notebook.cells[1]) # resets `x`, only 10-14 should run, in order
            @test notebook.cells[15].output.body == "\"10-11-12-13-14\""
            update_run!(🍭, notebook, notebook.cells[15])
            @test notebook.cells[15].output.body == "\"10-11-12-13-14\""
        end

        update_run!(🍭, notebook, notebook.cells[16:18])
        @test notebook.cells[16].errored == false
        @test notebook.cells[16].output.body == "34"
        @test notebook.cells[17].errored == false
        @test notebook.cells[18].errored == false

        setcode(notebook.cells[18], "υ = 8")
        update_run!(🍭, notebook, notebook.cells[18])
        @test notebook.cells[16].output.body == "24"

        update_run!(🍭, notebook, notebook.cells[19:22])
        @test notebook.cells[19].errored == false
        @test notebook.cells[19].output.body == "60"
        @test notebook.cells[20].errored == false
        @test notebook.cells[21].errored == false
        @test notebook.cells[22].errored == false

        setcode(notebook.cells[22], "y = 0")
        update_run!(🍭, notebook, notebook.cells[22])
        @test notebook.cells[19].output.body == "38"

        WorkspaceManager.unmake_workspace((🍭, notebook))
    end
end