struct Frame
    image::Image
    view::ImageView
    framebuffer::Framebuffer
end

struct FrameSynchronization
    image_acquired::Semaphore
    image_rendered::Semaphore
    has_rendered::Fence
end

FrameSynchronization(device) = FrameSynchronization(Semaphore(device), Semaphore(device), Fence(device; flags = FENCE_CREATE_SIGNALED_BIT))

struct FrameState
    device::Device
    swapchain::Ref{Created{SwapchainKHR,SwapchainCreateInfoKHR}}
    render_pass::RenderPass
    frames::Vector{Frame}
    current_frame::Ref{Frame}
    frame_count::Ref{Int}
    syncs::Dictionary{Frame,FrameSynchronization}
end

Vulkan.device(fs::FrameState) = fs.device

function FrameState(swapchain::Created{SwapchainKHR,SwapchainCreateInfoKHR}, render_pass)
    max_in_flight = info(swapchain).min_image_count
    fs = FrameState(device(render_pass), Ref(swapchain), render_pass, [], Ref{Frame}(), Ref(0), Dictionary{Frame,FrameSynchronization}())
    update!(fs)
end

function Vulkan.SurfaceCapabilitiesKHR(fs::FrameState)
    unwrap(get_physical_device_surface_capabilities_khr(device(fs).physical_device, info(fs.swapchain[]).surface))
end

function recreate_swapchain!(fs::FrameState, new_extent::Extent2D)
    swapchain = fs.swapchain[]
    swapchain_info = setproperties(info(swapchain), old_swapchain = handle(swapchain), image_extent = new_extent)
    swapchain_handle = unwrap(create_swapchain_khr(device(fs), swapchain_info))
    fs.swapchain[] = Created(swapchain_handle, swapchain_info)
    fs
end

function get_frames(fs::FrameState)
    swapchain = fs.swapchain[]
    extent = info(swapchain).image_extent
    map(unwrap(get_swapchain_images_khr(device(fs), swapchain))) do img
        view = ImageView(
            device(fs),
            img,
            IMAGE_VIEW_TYPE_2D,
            info(swapchain).image_format,
            ComponentMapping(fill(COMPONENT_SWIZZLE_IDENTITY, 4)...),
            ImageSubresourceRange(IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1),
        )

        fb = Framebuffer(device(fs), fs.render_pass, [view], extent.width, extent.height, 1)
        Frame(img, view, fb)
    end
end

function update!(fs::FrameState)
    empty!(fs.frames)
    empty!(fs.syncs)

    # TODO: make fields of returned-only structs high-level in Vulkan.jl
    _extent = SurfaceCapabilitiesKHR(fs).current_extent.vks
    new_extent = Extent2D(_extent.width, _extent.height)
    if new_extent ≠ info(fs.swapchain[]).image_extent
        recreate_swapchain!(fs, new_extent)
    end

    append!(fs.frames, get_frames(fs))
    fs.current_frame[] = first(fs.frames)

    foreach(fs.frames) do frame
        insert!(fs.syncs, frame, FrameSynchronization(device(fs.render_pass)))
    end
    fs
end

"""
    command_buffers(frame_state, app)

Command buffers generated by the application.
"""
function command_buffers end

function next_frame!(fs::FrameState, dispatch::QueueDispatch, app)
    swapchain = fs.swapchain[]

    # acquire next image
    image_acquired = fs.syncs[fs.current_frame[]].image_acquired
    status = @timeit to "Acquire next image" acquire_next_image_khr(device(fs), swapchain, typemax(UInt64); semaphore = image_acquired)
    if !iserror(status)
        idx, result = unwrap(status)
        @assert result in (SUCCESS, SUBOPTIMAL_KHR) "$result: Could not retrieve next swapchain image"
        frame = fs.frames[idx + 1]
        sync = fs.syncs[frame]

        # submit rendering commands
        @timeit to "Submit rendering commands" begin
            cbuffs = @timeit to "Create command buffers" command_buffers(fs, app)
            img_acquired_info = SemaphoreSubmitInfoKHR(image_acquired, 0, 0; stage_mask = PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT_KHR)
            img_rendered_info = SemaphoreSubmitInfoKHR(sync.has_rendered, 0, 0; stage_mask = PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT_KHR)
            render_info = SubmitInfo2KHR([img_acquired_info], CommandBufferSubmitInfoKHR.(cbuffs, 0), [img_rendered_info])
            @timeit to "Wait for frame being rendered" wait_for_fences(device(fs), [sync.has_rendered], false, typemax(UInt64))
            reset_fences(device(fs), [sync.has_rendered])
            submit(dispatch, QUEUE_GRAPHICS_BIT, [render_info]; fence = sync.has_rendered)
        end

        # submit presentation commands
        @timeit to "Submit presentation commands" begin
            present_info = PresentInfoKHR([sync.image_rendered], [swapchain], [idx])
            unwrap(present(dispatch, present_info))
        end

        fs.frame_count[] += 1
        fs.current_frame[] = frame
    else
        err = unwrap_error(status)
        if err.code == ERROR_OUT_OF_DATE_KHR
            # recreate swapchain and start over
            @timeit to "Recreate swapchain" update!(fs)
            next_frame!(fs, rdr, app)
        end
    end
end

function wait_hasrendered(fs::FrameState)
    wait_for_fences(device(fs), getproperty.(fs.syncs, :has_rendered), true, typemax(UInt64))
end

"""
Encode a lifetime context that allows the deletion of staged resources once expired.
"""
abstract type LifeTimeContext end

"""
Work between frames is assumed to be independent,
and so is any resource attached to a particular piece of work.
"""
struct FrameContext <: LifeTimeContext
    state::FrameState
    pending::Vector{Any}
end

function next_frame!(ctx::FrameContext)
    next_frame!(ctx.state)
    foreach(ctx.pending) do resource
        finalize(resource)
    end
    empty!(ctx.pending)
end

function stage!(ctx::FrameContext, resource)
    push!(ctx.pending, resource)
end
