"""
    gen_code(graph::DAG)

Generate the code for a given graph. The return value is a named tuple of:

- `code::Expr`: The julia expression containing the code for the whole graph.
- `inputSymbols::Dict{String, Vector{Symbol}}`: A dictionary of symbols mapping the names of the input nodes of the graph to the symbols their inputs should be provided on.
- `outputSymbol::Symbol`: The symbol of the final calculated value

See also: [`execute`](@ref)
"""
function gen_code(graph::DAG, machine::Machine)
    sched = schedule_dag(GreedyScheduler(), graph, machine)

    codeAcc = Vector{Expr}()
    sizehint!(codeAcc, length(graph.nodes))

    for node in sched
        # TODO: this is kind of ugly, should init nodes be scheduled differently from the rest?
        if (node isa DataTaskNode && length(node.children) == 0)
            push!(codeAcc, get_init_expression(node, entry_device(machine)))
            continue
        end
        push!(codeAcc, get_expression(node))
    end

    # get inSymbols
    inputSyms = Dict{String, Vector{Symbol}}()
    for node in get_entry_nodes(graph)
        if !haskey(inputSyms, node.name)
            inputSyms[node.name] = Vector{Symbol}()
        end

        push!(inputSyms[node.name], Symbol("$(to_var_name(node.id))_in"))
    end

    # get outSymbol
    outSym = Symbol(to_var_name(get_exit_node(graph).id))

    return (code = Expr(:block, codeAcc...), inputSymbols = inputSyms, outputSymbol = outSym)
end

function gen_cache_init_code(machine::Machine)
    initializeCaches = Vector{Expr}()

    for device in machine.devices
        push!(initializeCaches, gen_cache_init_code(device))
    end

    return Expr(:block, initializeCaches...)
end

function gen_input_assignment_code(
    inputSymbols::Dict{String, Vector{Symbol}},
    processDescription::AbstractProcessDescription,
    machine::Machine,
    processInputSymbol::Symbol = :input,
)
    @assert length(inputSymbols) >=
            sum(values(in_particles(processDescription))) + sum(values(out_particles(processDescription))) "Number of input Symbols is smaller than the number of particles in the process description"

    assignInputs = Vector{Expr}()
    for (name, symbols) in inputSymbols
        type = type_from_name(name)
        index = parse(Int, name[2:end])

        p = nothing

        if (index > in_particles(processDescription)[type])
            index -= in_particles(processDescription)[type]
            @assert index <= out_particles(processDescription)[type] "Too few particles of type $type in input particles for this process"

            p = "filter(x -> typeof(x) <: $type, out_particles($(processInputSymbol)))[$(index)]"
        else
            p = "filter(x -> typeof(x) <: $type, in_particles($(processInputSymbol)))[$(index)]"
        end

        for symbol in symbols
            # TODO: how to get the "default" cpu device?
            device = entry_device(machine)
            evalExpr = eval(gen_access_expr(device, symbol))
            push!(assignInputs, Meta.parse("$(evalExpr)::ParticleValue{$type} = ParticleValue($p, 1.0)"))
        end
    end

    return Expr(:block, assignInputs...)
end

"""
    get_compute_function(graph::DAG, process::AbstractProcessDescription, machine::Machine)

Return a function of signature `compute_<id>(input::AbstractProcessInput)`, which will return the result of the DAG computation on the given input.
"""
function get_compute_function(graph::DAG, process::AbstractProcessDescription, machine::Machine)
    (code, inputSymbols, outputSymbol) = gen_code(graph, machine)

    initCaches = gen_cache_init_code(machine)
    assignInputs = gen_input_assignment_code(inputSymbols, process, machine, :input)

    functionId = to_var_name(UUIDs.uuid1(rng[1]))
    resSym = eval(gen_access_expr(entry_device(machine), outputSymbol))
    expr = Meta.parse(
        "function compute_$(functionId)(input::AbstractProcessInput) $initCaches; $assignInputs; $code; return $resSym; end",
    )

    func = eval(expr)

    return func
end

"""
    execute(graph::DAG, process::AbstractProcessDescription, machine::Machine, input::AbstractProcessInput)

Execute the code of the given `graph` on the given input particles.

This is essentially shorthand for
    ```julia
    compute_graph = get_compute_function(graph, process)
    result = compute_graph(particles)
    ```

See also: [`parse_dag`](@ref), [`parse_process`](@ref), [`gen_process_input`](@ref)
"""
function execute(graph::DAG, process::AbstractProcessDescription, machine::Machine, input::AbstractProcessInput)
    (code, inputSymbols, outputSymbol) = gen_code(graph, machine)

    initCaches = gen_cache_init_code(machine)
    assignInputs = gen_input_assignment_code(inputSymbols, process, machine, :input)


    functionId = to_var_name(UUIDs.uuid1(rng[1]))
    resSym = eval(gen_access_expr(entry_device(machine), outputSymbol))
    expr = Meta.parse(
        "function compute_$(functionId)(input::AbstractProcessInput) $initCaches; $assignInputs; $code; return $resSym; end",
    )
    func = eval(expr)

    result = 0
    try
        result = @eval $func($input)
    catch e
        println("Error while evaluating: $e")

        # if we find a uuid in the exception we can color it in so it's easier to spot
        uuidRegex = r"[0-9a-f]{8}_[0-9a-f]{4}_[0-9a-f]{4}_[0-9a-f]{4}_[0-9a-f]{12}"
        m = match(uuidRegex, string(e))

        functionStr = string(expr)
        if (isa(m, RegexMatch))
            functionStr = replace(functionStr, m.match => "\033[31m$(m.match)\033[0m")
        end

        println("Function:\n$functionStr")
        @assert false
    end

    return result
end
