// SPDX-License-Identifier: Apache-2.0
#pragma once

#include <vtbackend/ColorPalette.hpp>
#include <vtbackend/Image.hpp>
#include <vtbackend/Terminal.hpp>

#include <vtrasterizer/BackgroundRenderer.hpp>
#include <vtrasterizer/CursorRenderer.hpp>
#include <vtrasterizer/DecorationRenderer.hpp>
#include <vtrasterizer/Decorator.hpp>
#include <vtrasterizer/GlyphScaling.hpp>
#include <vtrasterizer/GridMetrics.hpp>
#include <vtrasterizer/ImageRenderer.hpp>
#include <vtrasterizer/RenderTarget.hpp>
#include <vtrasterizer/TextRenderer.hpp>

#include <crispy/Size.hpp>
#include <crispy/StrongLRUHashtable.hpp>

#include <gsl/pointers>

#include <atomic>
#include <format>
#include <memory>
#include <mutex>
#include <optional>
#include <span>
#include <vector>

namespace vtrasterizer
{

struct RenderCursor
{
    crispy::Point position;
    vtbackend::CursorShape shape;
    int width;

    RenderCursor(crispy::Point position, vtbackend::CursorShape shape, int width):
        position(position), shape(shape), width(width)
    {
    }
};

/**
 * Renders a terminal's screen to the current OpenGL context.
 */
class Renderer
{
  public:
    friend class RendererTest; //!< Grants unit tests access to the deferred-reconfiguration internals.

    /** Constructs a Renderer instances.
     *
     * @p fonts              Reference to the set of loaded fonts to be used for rendering text.
     * @p colorPalette       User-configurable color profile to use to map terminal colors to.
     * @p projectionMatrix   Projection matrix to apply to the rendered scene when rendering the screen.
     * @p atlasDirectMapping Indicates whether or not direct mapped tiles are allowed.
     * @p atlasTileCount     Number of tiles guaranteed to be available in LRU cache.
     */
    /// @p pageMargin The grid's origin in device pixels, as the profile configures it.
    ///
    /// Supplied here rather than left at zero until the first applyResize(): gridMetrics() is the
    /// published origin every consumer maps through — including the GUI's mouse hit-test, which
    /// subtracts it from the pointer position — and an event delivered before the first geometry
    /// change would otherwise be mapped with no margin at all and report a cell several rows and
    /// columns away from the one the user clicked.
    Renderer(vtbackend::PageSize pageSize,
             PageMargin pageMargin,
             FontDescriptions fontDescriptions,
             vtbackend::ColorPalette const& colorPalette,
             crispy::StrongHashtableSize atlasHashtableSlotCount,
             crispy::LRUCapacity atlasTileCount,
             bool atlasDirectMapping,
             /// The font locator this renderer's shaper resolves font descriptions through. Injected
             /// rather than fetched from FontLocatorProvider so a renderer can be built against
             /// MockFontLocator, and fixed for the renderer's lifetime like every other collaborator.
             text::FontLocator& fontLocator,
             Decorator hyperlinkNormal,
             Decorator hyperlinkHover,
             // Must match Config's `text_scaling_method` default. When these disagreed, every
             // renderer test silently exercised the method the app does NOT ship.
             GlyphScalingMethod textScalingMethod = GlyphScalingMethod::Rerasterize);

    /// Returns the live cell size from the grid metrics.
    ///
    /// @warning Reads the live _gridMetrics without synchronization; intended for render-thread /
    ///          construction-time use only. UI-thread callers must use gridMetrics().cellSize, which
    ///          returns the published (mutex-guarded) copy.
    [[nodiscard]] ImageSize cellSize() const noexcept { return _gridMetrics.cellSize; }

    /// Initializes the render and all render subsystems with the given RenderTarget
    /// and then informs all renderables about the newly created texture atlas.
    void setRenderTarget(RenderTarget& renderTarget);
    RenderTarget& renderTarget() noexcept { return *_renderTarget; }
    [[nodiscard]] bool hasRenderTarget() const noexcept { return _renderTarget != nullptr; }

    /// Severs this renderer's (and every renderable's) references into the render target BEFORE the
    /// target is destroyed, when the renderer itself survives the target.
    ///
    /// The scene graph destroys and re-creates its context (unexposed-window grabs, GPU loss,
    /// invalidation on teardown) while the terminal renderer lives on; the display then destroys the
    /// RenderTarget and later hands the renderer a fresh one. Without this detach, the setup pass in
    /// between (staged font reconfig -> configureTextureAtlas) dereferences the freed target on the
    /// render thread — a use-after-free. After detaching, that setup pass behaves exactly like the
    /// very first pre-target run (its no-target guards apply), and setRenderTarget() re-wires
    /// everything, atlas included.
    void detachRenderTarget() noexcept;

    bool setFontSize(text::FontSize fontSize);
    void updateFontMetrics();

    /// Returns the most recently *published* font descriptions.
    ///
    /// Returns a snapshot by value (not a reference into the live state): the live _fontDescriptions is
    /// mutated on the render thread during applyPendingReconfig(), so a UI-thread reader must observe a
    /// mutex-guarded copy rather than reading the live field lock-free (which would be a data race).
    /// A font change (setFonts/setFontSize) becomes visible here only after the render thread applies it.
    ///
    /// @note Thread-safe with respect to concurrent reconfiguration requests.
    [[nodiscard]] FontDescriptions fontDescriptions() const noexcept
    {
        auto const l = std::scoped_lock { _reconfigMutex };
        return _publishedFontDescriptions;
    }

    /// Returns just the published font DPI, without deep-copying the whole FontDescriptions.
    ///
    /// fontDescriptions() copies five text::FontDescription objects (each with std::strings and feature
    /// vectors) under the lock. Callers that only need the DPI — e.g. the dedup guard in
    /// TerminalDisplay::applyFontDPI() — use this to avoid that heap-allocating copy.
    [[nodiscard]] DPI publishedFontDPI() const noexcept
    {
        auto const l = std::scoped_lock { _reconfigMutex };
        return _publishedFontDescriptions.dpi;
    }

    /// Stages a full font-descriptions change to be applied on the render thread.
    ///
    /// The text shaper is not thread-safe and is read by the render thread every frame, so this only
    /// *stages* the descriptions (on the UI thread); the shaper reconfiguration, font loading and
    /// grid-metrics/atlas rebuild happen on the render thread in applyPendingReconfig(). The new cell
    /// size therefore appears via gridMetrics()/fontDescriptions() only after a frame (or a setup-time
    /// applyStagedReconfigDuringSetup()), not synchronously on return. A staged size-only change is
    /// superseded by the full descriptions staged here.
    ///
    /// @param fontDescriptions  the new font configuration to stage.
    void setFonts(FontDescriptions fontDescriptions);

    /// Stages a DPI-only font change without clobbering an already-staged font-descriptions change.
    ///
    /// Unlike reading fontDescriptions() and re-staging the whole thing via setFonts(), this updates
    /// only the .dpi field of the *effective* descriptions — the ones already staged in a pending
    /// reconfig if present, otherwise the live ones. A concurrent font-family change staged just before
    /// (e.g. by a config reload) therefore keeps its family and merely picks up the new DPI, instead of
    /// being silently overwritten with the live descriptions + new DPI.
    ///
    /// @param dpi  the new device pixels-per-inch to apply to the font configuration.
    void setFontDPI(DPI dpi);

    /// Returns the most recently *published* grid metrics.
    ///
    /// Geometry writers (setPageSize/applyResize) publish synchronously; font writers (setFonts/
    /// setFontSize) only stage, so the new cell size appears only after a frame (or setup-time apply).
    ///
    /// @note Thread-safe with respect to concurrent reconfiguration requests.
    [[nodiscard]] GridMetrics gridMetrics() const noexcept
    {
        auto const l = std::scoped_lock { _reconfigMutex };
        return _publishedMetrics;
    }

    /// Returns the most recently *published* cell size, lock-free.
    ///
    /// Equivalent to gridMetrics().cellSize but without taking _reconfigMutex or copying the whole
    /// GridMetrics struct — for the many UI-thread hot paths (per-frame sync, scroll, IME, size
    /// recompute) that need only the cell size. Reading it also cannot tear against a concurrent
    /// render-thread font apply, unlike two separate gridMetrics().cellSize reads.
    [[nodiscard]] ImageSize publishedCellSize() const noexcept
    {
        return _publishedCellSize.load(std::memory_order_acquire);
    }

    /// Atomically reads and clears the "a font reconfiguration was applied" flag.
    ///
    /// A font change (setFontSize/setFonts) is applied lazily on the render thread, so the new cell
    /// size only becomes available after a frame. The display polls this after rendering and, when it
    /// returns true, re-derives the terminal page size against the now-current cell size — otherwise a
    /// font/DPI change would size the grid using the previous cell size for a frame.
    ///
    /// @return true if a font reconfiguration has been applied since the last call.
    [[nodiscard]] bool consumeFontReconfigApplied() noexcept
    {
        return _fontReconfigApplied.exchange(false, std::memory_order_acq_rel);
    }

    void setHyperlinkDecoration(Decorator normal, Decorator hover)
    {
        _decorationRenderer.setHyperlinkDecoration(normal, hover);
    }

    /// Requests a new page size (in columns/lines).
    ///
    /// The change is published immediately (visible via gridMetrics()) and the actual
    /// grid-metrics mutation is applied on the render thread at the start of the next frame.
    ///
    /// @param screenSize  the new page size.
    void setPageSize(vtbackend::PageSize screenSize) noexcept
    {
        auto const l = std::scoped_lock { _reconfigMutex };
        _publishedMetrics.pageSize = screenSize;
        stagePendingGeometryLocked();
    }

    /// Requests a combined render-surface resize: pixel size, page size and margin.
    ///
    /// Consolidates the three geometry writers so the render target and grid metrics are
    /// updated atomically (on the render thread) rather than in three separately-observable steps.
    ///
    /// @param newPixelSize  new render surface size in pixels.
    /// @param newPageSize   new page size in columns/lines.
    /// @param newMargin     new page margin in pixels.
    void applyResize(vtbackend::ImageSize newPixelSize,
                     vtbackend::PageSize newPageSize,
                     PageMargin newMargin) noexcept
    {
        auto const l = std::scoped_lock { _reconfigMutex };
        _publishedMetrics.pageSize = newPageSize;
        _publishedMetrics.pageMargin = newMargin;
        auto& pending = ensurePendingLocked();
        pending.geometry = PendingReconfig::Geometry { .pixelSize = newPixelSize,
                                                       .pageSize = newPageSize,
                                                       .pageMargin = newMargin };
    }

    /**
     * Renders the given @p terminal to the current OpenGL context.
     *
     * @param terminal       The terminal to render
     * @param pressureHint   Indicates whether or not this render will most likely be
     *                       updated right after again, allowing a few optimizations
     *                       to perform that reduce visual features as they are
     *                       CPU intensive but allow to render fast.
     *                       The user shall not notice that, because this frame
     *                       is known already to be updated right after again.
     *
     * @return true if a font reconfiguration was applied during this frame and the display must
     *         re-derive its geometry against the new cell size (see applyPendingReconfig()). The flag is
     *         consumed here, under _applyMutex, so it cannot also be consumed concurrently by a GUI-thread
     *         applyStagedReconfigDuringSetup().
     */
    [[nodiscard]] bool render(vtbackend::Terminal& terminal, bool pressureHint);

    /// Synchronously applies any staged font/geometry reconfiguration.
    ///
    /// Used during display setup (before any frame, when a caller must read the resulting cell metrics
    /// immediately, e.g. to size the texture atlas tile) and on the not-renderable (minimized/occluded)
    /// path, where no frame will paint to apply the staged change.
    ///
    /// Takes _applyMutex so it is mutually exclusive with the render thread's per-frame apply+render —
    /// safe even if an already in-flight frame has not yet observed the window losing exposure.
    ///
    /// @return true if a font reconfiguration was applied (and thus the "font reconfig applied" signal was
    ///         consumed). Consuming under _applyMutex here — rather than via a separate
    ///         consumeFontReconfigApplied() after the lock is released — is what prevents the render
    ///         thread's render() from racing the GUI thread for the same one-shot signal.
    [[nodiscard]] bool applyStagedReconfigDuringSetup()
    {
        auto const applyGuard = std::scoped_lock { _applyMutex };
        applyPendingReconfig();
        return consumeFontReconfigApplied();
    }

    /// Serializes a whole render-thread frame against a GUI-thread reconfiguration.
    ///
    /// render() takes the same lock, but a frame is more than render(): the render target's pipeline and
    /// texture creation, the per-frame command staging and the draw replay all live in the display around
    /// it, and all of them touch state that applyStagedReconfigDuringSetup() mutates -- the atlas backend's
    /// scheduled commands, its atlas texture, and the atlas size the rasterizer normalizes tiles against.
    /// Holding this for the frame is what makes the "mutually exclusive with an in-flight frame" claim on
    /// applyStagedReconfigDuringSetup() true rather than nearly true.
    ///
    /// In #2040 it was not true: a GUI-thread atlas rebuild landed inside the render thread's
    /// multi-millisecond atlas texture allocation, and every glyph rasterized afterwards addressed the
    /// pre-rebuild texture size.
    ///
    /// @return a lock guard the caller keeps for the duration of the frame phase.
    [[nodiscard]] std::unique_lock<std::recursive_mutex> lockForFrame()
    {
        return std::unique_lock { _applyMutex };
    }

    /// Raises the glyph atlas budget to cover @p pageSize, before a render target is attached.
    ///
    /// A renderer is constructed from the PROFILE's page size, which is the right answer only for a
    /// window's first pane: a freshly split pane is created at a fraction of the window, and a maximized
    /// one at several times the profile. Without this the atlas is built for the profile and the real
    /// extent, arriving one call later, immediately rebuilds it one power-of-two band larger -- throwing
    /// away a multi-megabyte texture per pane, per split, on the pane's very first frame.
    ///
    /// Only meaningful before setRenderTarget(): once an atlas exists the same growth happens through
    /// the ordinary staged-geometry path, and doing it here would rebuild rather than reserve.
    ///
    /// @param pageSize the page the pane will actually render.
    void reserveAtlasForPage(vtbackend::PageSize pageSize)
    {
        auto const applyGuard = std::scoped_lock { _applyMutex };
        growAtlasForPage(pageSize);
    }

    void discardImage(vtbackend::Image const& image);

    void clearCache();

    void inspect(std::ostream& textOutput) const;

    std::array<gsl::not_null<Renderable*>, 5> renderables()
    {
        return std::array<gsl::not_null<Renderable*>, 5> {
            &_backgroundRenderer, &_cursorRenderer, &_decorationRenderer, &_imageRenderer, &_textRenderer
        };
    }

    [[nodiscard]] std::array<gsl::not_null<Renderable const*>, 5> renderables() const
    {
        return std::array<gsl::not_null<Renderable const*>, 5> {
            &_backgroundRenderer, &_cursorRenderer, &_decorationRenderer, &_imageRenderer, &_textRenderer
        };
    }

    /// Returns the index of the first cell whose line offset >= @p statusLineBoundary.
    ///
    /// @param cells                Sorted vector of render cells ordered by line offset.
    /// @param statusLineBoundary   Line count that separates main display from the status line.
    /// @return Index into @p cells at the partition point; equals cells.size() if all cells
    ///         belong to the main display.
    [[nodiscard]] static size_t findCellPartitionPoint(std::vector<vtbackend::RenderCell> const& cells,
                                                       vtbackend::LineCount statusLineBoundary);

    /// Returns the index of the first line whose offset >= @p statusLineBoundary.
    ///
    /// @param lines                Sorted vector of render lines ordered by line offset.
    /// @param statusLineBoundary   Line count that separates main display from the status line.
    /// @return Index into @p lines at the partition point; equals lines.size() if all lines
    ///         belong to the main display.
    [[nodiscard]] static size_t findLinePartitionPoint(std::vector<vtbackend::RenderLine> const& lines,
                                                       vtbackend::LineCount statusLineBoundary);

  private:
    /// Internal implementation of render(), wrapped in a try/catch for graceful degradation.
    [[nodiscard]] bool renderImpl(vtbackend::Terminal& terminal, bool pressure);

    void configureTextureAtlas();

    /// Clamps a wanted tile budget to what the GPU can actually hold at the current cell size.
    ///
    /// The page-derived budget has no upper bound of its own — a maximized pane on a high-DPI display
    /// asks for a texture larger than the driver's maximum, which does not allocate and leaves the pane
    /// with no glyphs at all. @see atlasbudget::MaxAtlasTextureEdge.
    /// @param wanted The budget the page asked for.
    /// @return @p wanted, or the ceiling when it exceeds it.
    [[nodiscard]] crispy::LRUCapacity boundedAtlasTileCount(crispy::LRUCapacity wanted) const noexcept;

    /// Raises the atlas tile budget if @p pageSize needs more tiles than the atlas currently holds, and
    /// rebuilds the atlas when it does.
    ///
    /// Called from the geometry branch of applyPendingReconfig(), i.e. on the render thread under
    /// _applyMutex, because it replaces the texture atlas the in-flight frame would otherwise be reading.
    /// Grow-only.
    /// @param pageSize The page size being applied.
    void growAtlasForPage(vtbackend::PageSize pageSize);

    /// Sets the smooth scroll Y pixel offset on all sub-renderers.
    ///
    /// @param offset  Y pixel offset for smooth scrolling.
    void setSmoothScrollOffset(int offset);

    /// Renders a span of cells to the background, decoration, text, and image renderers.
    ///
    /// @param cells          Contiguous sub-range of RenderCell entries to render.
    /// @param yPixelOffset   Sub-cell Y pixel offset for smooth scrolling (default: 0).
    void renderCells(std::span<vtbackend::RenderCell const> cells, int yPixelOffset = 0);

    /// Renders a span of lines to the background, decoration, and text renderers.
    ///
    /// @param lines  Contiguous sub-range of RenderLine entries to render.
    void renderLines(std::span<vtbackend::RenderLine const> lines);

    void executeImageDiscards();

    /// The atlas sizing the *configuration* asked for, kept apart from the effective sizing below.
    ///
    /// The effective budget is raised to cover the page actually being rendered, and a resize can raise
    /// it again — so the configured floor has to survive that, or each recomputation would compound the
    /// previous one instead of being derived afresh.
    crispy::StrongHashtableSize _configuredAtlasHashtableSlotCount;
    crispy::LRUCapacity _configuredAtlasTileCount;

    crispy::StrongHashtableSize _atlasHashtableSlotCount;
    crispy::LRUCapacity _atlasTileCount;
    bool _atlasDirectMapping;

    RenderTarget* _renderTarget = nullptr;

    Renderable::DirectMappingAllocator _directMappingAllocator;
    std::unique_ptr<Renderable::TextureAtlas> _textureAtlas;

    /// Resolves font descriptions to files. Constructor-injected and fixed thereafter.
    text::FontLocator& _fontLocator;

    FontDescriptions _fontDescriptions;
    std::unique_ptr<text::Shaper> _textShaper;
    FontKeys _fonts;

    /// The live grid metrics, mutated *only* on the render thread (at the start of renderImpl()).
    ///
    /// All sub-renderers hold a const reference to this object and read it throughout a frame,
    /// hence it must never be written while a frame is in flight. UI-thread writers therefore do
    /// not touch it directly; they stage a request (see _pendingReconfig) that the render thread
    /// applies between frames.
    GridMetrics _gridMetrics;

    /// A staged, not-yet-applied reconfiguration request produced by UI-thread writers.
    ///
    /// Carries the *inputs* of a pending change. The render thread consumes it at the top of
    /// renderImpl() to rebuild grid metrics / the texture atlas on the render thread, eliminating
    /// the data race between UI-thread geometry/font changes and the render thread.
    struct PendingReconfig
    {
        /// Pending geometry change: new page size, margin and (optionally) render-surface pixel size.
        struct Geometry
        {
            std::optional<vtbackend::ImageSize> pixelSize; //!< New render surface size, if it changed.
            vtbackend::PageSize pageSize;                  //!< New page size in columns/lines.
            PageMargin pageMargin;                         //!< New page margin in pixels.
        };

        /// Pending font change. Either a size-only change (fontSize) or a full font-descriptions
        /// change (fontDescriptions, which supersedes a size-only change). Only the *inputs* are
        /// staged; the heavyweight work (font loading, metric computation via the non-thread-safe
        /// text shaper, atlas rebuild) is performed on the render thread during applyPendingReconfig().
        std::optional<text::FontSize> fontSize;           //!< Set when only the font size changed.
        std::optional<FontDescriptions> fontDescriptions; //!< Set when the full font config changed.
        std::optional<Geometry> geometry;                 //!< Set when a geometry change is pending.
    };

    /// Protects _pendingReconfig and _publishedMetrics, and serializes the render-thread apply.
    mutable std::mutex _reconfigMutex;

    /// Serializes the heavyweight apply (font load, atlas rebuild, _gridMetrics mutation) in
    /// applyPendingReconfig() against itself across threads.
    ///
    /// applyPendingReconfig() normally runs only on the render thread (at the top of renderImpl()), but
    /// applyStagedReconfigDuringSetup() also invokes it from the GUI thread for the non-renderable
    /// (minimized/occluded) case and for a discrete font change. A window losing exposure does not
    /// synchronously stop an already in-flight frame, so the GUI-thread apply could otherwise overlap the
    /// render-thread apply and race on _gridMetrics / the texture atlas / the non-thread-safe shaper.
    /// Holding this mutex across the whole apply body makes the two mutually exclusive. It is distinct
    /// from _reconfigMutex (which only guards the short staged-request/published-metrics critical
    /// sections) so a UI-thread gridMetrics() reader is not blocked for the multi-millisecond font load.
    ///
    /// Recursive because the frame guard (lockForFrame()) nests over renderImpl()'s own acquisition. The
    /// alternative -- splitting render() into locked and unlocked twins -- moves the invariant out of the
    /// type and into a rule every caller has to remember, and the unlocked twin is exactly the mistake
    /// #2040 was.
    std::recursive_mutex _applyMutex;

    /// The most recently *requested* grid metrics, observable synchronously via gridMetrics().
    ///
    /// Kept in sync by UI-thread writers so callers needing the new cell size immediately are not
    /// blocked on the deferred render-thread apply. Guarded by _reconfigMutex.
    GridMetrics _publishedMetrics;

    /// Mutex-guarded snapshot of _fontDescriptions for UI-thread readers (fontDescriptions(),
    /// setFontDPI()). The live _fontDescriptions is render-thread-owned and mutated without the mutex
    /// during applyPendingReconfig(); a UI-thread reader must therefore observe this guarded copy
    /// instead of racing that write. Published by the render thread (under _reconfigMutex) whenever it
    /// applies a font change, and seeded at construction. Guarded by _reconfigMutex.
    FontDescriptions _publishedFontDescriptions;

    /// Lock-free mirror of _publishedMetrics.cellSize for publishedCellSize(). Written wherever the
    /// published cell size changes (construction and the render-thread font apply); read without the
    /// mutex by UI-thread hot paths. The cell size only changes via font/DPI reconfiguration, never via
    /// geometry writers, so this stays consistent with _publishedMetrics.cellSize.
    std::atomic<ImageSize> _publishedCellSize;

    /// The staged reconfiguration awaiting application by the render thread. Guarded by _reconfigMutex.
    std::optional<PendingReconfig> _pendingReconfig;

    /// Mirrors `_pendingReconfig.has_value()` for a lock-free fast path: applyPendingReconfig() runs on
    /// every frame and consults this first, only acquiring _reconfigMutex when something is actually
    /// staged. Written under _reconfigMutex; read relaxed on the render thread.
    std::atomic<bool> _reconfigPending { false };

    /// Set by applyPendingReconfig() (render thread) when a font change was applied; consumed by the
    /// display (consumeFontReconfigApplied()) to trigger a page-size recompute against the new cell
    /// size. Atomic because it is written on the render thread and read on the display thread.
    std::atomic<bool> _fontReconfigApplied { false };

    /// The terminal total page size observed by the per-frame reconcile in renderImpl() on the previous
    /// frame. Render-thread-only. Used to distinguish a terminal-driven page-size change (status-line
    /// toggle: an edge — the value changed since last frame) from a UI-driven resize still in flight (the
    /// terminal total has not changed yet, only the renderer's already-correct grid metrics differ). Only
    /// a terminal-driven edge triggers the reconcile, so the reconcile never reverts a just-published
    /// UI resize. nullopt until the first frame seeds it.
    std::optional<vtbackend::PageSize> _lastObservedTotalPageSize;

    /// Ensures a PendingReconfig exists and returns it. Caller must hold _reconfigMutex.
    PendingReconfig& ensurePendingLocked()
    {
        if (!_pendingReconfig)
            _pendingReconfig.emplace();
        _reconfigPending.store(true, std::memory_order_relaxed);
        return *_pendingReconfig;
    }

    /// Stages a geometry-only pending request derived from the current _publishedMetrics.
    ///
    /// A render-surface pixel size already staged by a prior applyResize() is preserved; a page-size-
    /// or margin-only change leaves the pixel size untouched (std::nullopt → render size unchanged).
    /// Caller must hold _reconfigMutex.
    void stagePendingGeometryLocked()
    {
        auto& pending = ensurePendingLocked();
        auto const pixelSize = pending.geometry ? pending.geometry->pixelSize : std::nullopt;
        pending.geometry = PendingReconfig::Geometry { .pixelSize = pixelSize,
                                                       .pageSize = _publishedMetrics.pageSize,
                                                       .pageMargin = _publishedMetrics.pageMargin };
    }

    /// Applies any staged reconfiguration. Called on the render thread at the start of renderImpl().
    void applyPendingReconfig();

    /// Publishes the render-thread-owned font-derived metrics and font descriptions for UI-thread
    /// readers (gridMetrics()/publishedCellSize()/fontDescriptions()).
    ///
    /// Acquires _reconfigMutex for the short copy. Called from applyPendingReconfig() after a font
    /// apply (whether it succeeded or threw), so readers always observe metrics consistent with the
    /// live grid metrics. Does not touch page geometry, which the UI-thread geometry writers own.
    void publishFontMetricsAndDescriptions();

    /// Applies a full font-descriptions change (shaper reconfiguration, font loading, grid-metrics
    /// rebuild, atlas reconfiguration). Runs on the render thread from applyPendingReconfig().
    ///
    /// @param fontDescriptions  the new font descriptions to apply.
    void applyFontDescriptions(FontDescriptions fontDescriptions);

    std::mutex _imageDiscardLock;                       //!< Lock guard for accessing _discardImageQueue.
    std::vector<vtbackend::ImageId> _discardImageQueue; //!< List of images to be discarded.

    BackgroundRenderer _backgroundRenderer;
    ImageRenderer _imageRenderer;
    TextRenderer _textRenderer;
    DecorationRenderer _decorationRenderer;
    CursorRenderer _cursorRenderer;
};

} // namespace vtrasterizer
