// SPDX-FileCopyrightText: 2026 fuddlesworth
// SPDX-License-Identifier: LGPL-2.1-or-later

#pragma once

#include <PhosphorZones/AssignmentEntry.h>
#include <PhosphorZones/IZoneLayoutRegistry.h>
#include <PhosphorZones/Layout.h>

#include <PhosphorConfig/IBackend.h>

#include <QHash>
#include <QPair>
#include <QString>
#include <QUuid>
#include <functional>
#include <memory>

namespace PhosphorZones {

/**
 * @brief Manual zone-layout registry + per-context assignment store.
 *
 * Concrete counterpart to @ref IZoneLayoutRegistry - mirrors the
 * PhosphorTiles @c AlgorithmRegistry shape (interface for the provider
 * contract, one concrete class for everything else). Composition roots
 * construct one instance per process and inject it into every consumer;
 * there is no process-global singleton.
 *
 * Responsibilities:
 *   - In-memory catalog of @ref Layout instances (enumeration, add,
 *     remove, duplicate, active-layout selection - inherited from
 *     @ref IZoneLayoutRegistry).
 *   - Per-context @ref AssignmentEntry table keyed by
 *     (screenId, virtualDesktop, activity), with cascading fallback
 *     (narrower keys beat wider keys; base screen is the widest).
 *   - Numbered quick-layout shortcut slots (1..9).
 *   - Built-in layout templates (columns, rows, grid, priority, focus).
 *   - JSON-file persistence for layouts; @ref PhosphorConfig::IBackend
 *     persistence for assignments + quick-slots.
 *
 * Schema strings (@c "Assignment:", @c "QuickLayouts", @c "ModeTracking")
 * are hardcoded lib-level constants inside @c layoutregistry_persistence.cpp.
 * They ARE the wire format - third-party compositors that reuse this
 * lib share them for free.
 */
class PHOSPHORZONES_EXPORT LayoutRegistry : public IZoneLayoutRegistry
{
    Q_OBJECT

    Q_PROPERTY(int layoutCount READ layoutCount NOTIFY layoutsChanged)
    // `activeLayout` Q_PROPERTY moved to IZoneLayoutRegistry alongside its
    // NOTIFY signal so the contract is visible to interface consumers.
    Q_PROPERTY(QString layoutDirectory READ layoutDirectory WRITE setLayoutDirectory NOTIFY layoutDirectoryChanged)

public:
    /**
     * @param backend Owned config-store backend (typically
     *                @c createAssignmentsBackend()). Required - asserted
     *                non-null; every persistence method dereferences it.
     * @param layoutSubdirectory XDG-relative path used for layout JSON
     *                discovery (e.g. @c "plasmazones/layouts"). The registry
     *                writes to
     *                @c QStandardPaths::writableLocation(GenericDataLocation)/\<subdir\>
     *                and reads the union of every @c GenericDataLocation
     *                entry containing that subdirectory, so system copies
     *                (in @c /usr/share/<subdir>) provide built-ins while
     *                the user-writable copy overrides them. Required -
     *                asserted non-empty.
     * @param parent Qt parent.
     */
    LayoutRegistry(std::unique_ptr<PhosphorConfig::IBackend> backend, QString layoutSubdirectory,
                   QObject* parent = nullptr);
    ~LayoutRegistry() override;

    // ─── IZoneLayoutRegistry ──────────────────────────────────────────────

    int layoutCount() const override
    {
        return m_layouts.size();
    }
    QVector<Layout*> layouts() const override
    {
        return m_layouts;
    }
    Layout* layout(int index) const override;
    Layout* layoutById(const QUuid& id) const override;
    Layout* layoutByName(const QString& name) const override;

    Q_INVOKABLE void addLayout(Layout* layout) override;
    Q_INVOKABLE void removeLayout(Layout* layout) override;
    Q_INVOKABLE void removeLayoutById(const QUuid& id) override;
    Q_INVOKABLE Layout* duplicateLayout(Layout* source) override;

    Layout* activeLayout() const override
    {
        return m_activeLayout;
    }
    Q_INVOKABLE void setActiveLayout(Layout* layout) override;
    Q_INVOKABLE void setActiveLayoutById(const QUuid& id) override;

    // ─── Layout persistence (disk JSON) ───────────────────────────────────

    QString layoutDirectory() const
    {
        return m_layoutDirectory;
    }
    void setLayoutDirectory(const QString& directory);

    Q_INVOKABLE void loadLayouts();
    Q_INVOKABLE void saveLayouts();
    void saveLayout(Layout* layout);

    Q_INVOKABLE void importLayout(const QString& filePath);
    Q_INVOKABLE void exportLayout(Layout* layout, const QString& filePath);

    // ─── Assignment persistence (via config backend) ──────────────────────

    Q_INVOKABLE void loadAssignments();
    Q_INVOKABLE void saveAssignments();

    // ─── Default-layout resolution ────────────────────────────────────────

    /// Resolve the effective default layout. Returns, in order:
    ///   1. The layout whose id matches the default-id provider (if set
    ///      and non-empty).
    ///   2. The first registered layout (by @c defaultOrder).
    ///   3. nullptr if no layouts are registered.
    ///
    /// @note Snap-only fallback. Unlike @ref assignmentIdForScreen and
    /// @ref assignmentEntryForScreen - which on cascade-miss consult
    /// both the snap and autotile level-1 providers - this method only
    /// consults the snap provider, because @ref Layout has no autotile
    /// counterpart. Autotile-mode resolution is the autotile engine's
    /// job, driven by @ref assignmentIdForScreen returning an
    /// @c "autotile:<algo>" id from the level-1 cascade.
    Q_INVOKABLE Layout* defaultLayout() const override;

    /**
     * @brief Inject a callback that returns the user-configured default
     * layout id (or empty if unset).
     *
     * Used by composition roots that own a settings object the lib
     * doesn't know about. The registry invokes the callback on every
     * @ref defaultLayout call (no caching) so the callback can re-read
     * settings on each call. Pass an empty function to disable.
     */
    void setDefaultLayoutIdProvider(std::function<QString()> provider);

    /**
     * @brief Inject a callback that returns the user-configured default
     * autotile algorithm id (or empty if autotile is not the user's
     * active default).
     *
     * Symmetric to @ref setDefaultLayoutIdProvider, completing the
     * level-1 (global) tier of the assignment hierarchy:
     *   1. global default - snap layout id AND autotile algorithm id
     *   2. monitor-level assignment
     *   3. virtual-desktop-level assignment
     *
     * On cascade-miss, @ref assignmentIdForScreen and
     * @ref assignmentEntryForScreen consult the snap provider first,
     * then the autotile provider; the first non-empty return wins.
     * Providers are pass-throughs from the composition root's settings
     * layer - each is expected to return empty when its mode is
     * disabled in settings, which means "autotile-only" users see
     * autotile as the natural cascade fallback (snap provider returns
     * empty → autotile wins) without any mode-priority logic in the
     * composition root. @ref layoutForScreen ignores the autotile
     * provider (it returns a snap @ref Layout*, which has no autotile
     * counterpart) and falls back to @ref defaultLayout as before.
     *
     * Invoked on every cascade-miss with no caching, so providers can
     * re-read settings cheaply per call. Pass an empty function to
     * disable.
     *
     * Thread-safety: the provider is read on every cascade query and
     * swapped via this setter without synchronization; both must run
     * on the same thread (the LayoutRegistry's owner thread, typically
     * the main Qt thread). The same applies to
     * @ref setDefaultLayoutIdProvider.
     */
    void setDefaultAutotileAlgorithmProvider(std::function<QString()> provider);

    /**
     * @brief Inject a callback that returns true when Snapping is the
     * user's preferred default mode (regardless of whether a default
     * snapping layout id is configured).
     *
     * Without this provider, @ref resolveDefaultAssignmentEntry can only
     * tell whether snap has a non-empty default *layout id*. When a user
     * has snapping enabled but never configured a global default layout
     * (a common, valid state - the user expects per-screen assignments
     * to drive everything), the @ref m_defaultLayoutIdProvider returns
     * empty and the cascade silently falls through to the autotile
     * branch - surfacing autotile content (e.g. "Tiling: Binary Split")
     * to a user who never wanted autotile.
     *
     * This provider lets the composition root express "snap mode is
     * preferred" independently of "snap has a default layout". When it
     * returns true, the resolver returns a Snapping entry with the
     * (possibly empty) snappingLayout from
     * @ref m_defaultLayoutIdProvider - `activeLayoutId()` then yields
     * empty, callers see "no assignment", and the OSD path correctly
     * suppresses rather than falling back to autotile.
     *
     * Optional. When unset, the resolver behaves as before
     * (snap-id-non-empty → snap; else autotile if available; else empty).
     * Same threading rules as the other two providers.
     */
    void setSnappingPreferredProvider(std::function<bool()> provider);

    // ─── Assignments (per-context routing) ────────────────────────────────

    /// Get the previous active layout (before the most recent
    /// @ref setActiveLayout). On first call, equals @ref activeLayout.
    /// Used by resnap-to-new-layout.
    Layout* previousLayout() const
    {
        return m_previousLayout;
    }

    Q_INVOKABLE void assignLayout(const QString& screenId, int virtualDesktop, const QString& activity, Layout* layout);
    Q_INVOKABLE void assignLayoutById(const QString& screenId, int virtualDesktop, const QString& activity,
                                      const QString& layoutId);

    /// Store a full entry directly (from KCM via D-Bus). Stores
    /// regardless of @c isValid() - mode-only entries are valid when
    /// explicitly set.
    void setAssignmentEntryDirect(const QString& screenId, int virtualDesktop, const QString& activity,
                                  const AssignmentEntry& entry);

    /// Update only the @c snappingLayout field for the (@p screenId,
    /// @p virtualDesktop, @p activity) entry, preserving mode and
    /// tilingAlgorithm. When no entry exists at that key, seeds a new
    /// entry from the cascade-resolved ambient state at that context so
    /// the recorded preference lands in the right mode. A pre-existing
    /// local entry — including one whose @c activeLayoutId() happens to
    /// be empty (e.g. a stored tile-only preference) — is used as-is
    /// as the seed so its opposite-field value is preserved.
    /// Empty @p layoutId clears the snap field; if both fields end up
    /// empty the entry is removed entirely.
    void setSnappingLayoutPreservingMode(const QString& screenId, int virtualDesktop, const QString& activity,
                                         const QString& layoutId);

    /// Update only the @c tilingAlgorithm field, mirroring
    /// @ref setSnappingLayoutPreservingMode for the tiling slot.
    void setTilingAlgorithmPreservingMode(const QString& screenId, int virtualDesktop, const QString& activity,
                                          const QString& algorithmId);

    /// "Promote" variant of @ref setSnappingLayoutPreservingMode used
    /// when the user's current rendered context is already in Snapping
    /// mode — i.e. the edit matches what's actively rendering. Wipes
    /// every other assignment entry on the same physical screen (and
    /// its VS variants) that would shadow this slot in the cascade,
    /// forces the slot to mode=Snapping with the new layoutId, and
    /// emits @c layoutAssigned. After this the slot is the cascade
    /// winner for every context on the screen, so setActiveLayout +
    /// onLayoutChanged see a real layout change and the resnap path
    /// actually moves windows. Snap and tile are independent fields
    /// (the slot's tile is left intact / inherited from the existing
    /// entry).
    ///
    /// Shadow scope by slot shape (see @ref clearShadowsForSlot):
    ///   - Monitor row (vd=0, activity=""):  ALL other entries on the
    ///     screen are wiped — this is the universal fallback so
    ///     anything more specific shadows it for some context.
    ///   - Per-desktop (vd>0, activity=""):  same-desktop entries
    ///     (vd, anyActivity) AND every per-activity entry
    ///     (0, activity!="") on the screen — the latter shadow the
    ///     per-desktop slot at cascade L2 for any context with that
    ///     activity, regardless of desktop, so leaving them intact
    ///     would defeat the "cascade winner" guarantee.
    ///   - Per-activity (vd=0, activity!=""):  entries with the same
    ///     activity on any desktop.
    ///   - Specific (vd>0, activity!=""):  no shadow possible — nothing
    ///     wiped.
    /// Promote is destructive: callers should reserve it for explicit
    /// user "make this the active layout" intent, not for ambient
    /// preference recording.
    void setSnappingLayoutPromoting(const QString& screenId, int virtualDesktop, const QString& activity,
                                    const QString& layoutId);

    /// Symmetric @c promoting variant for the tile field; forces
    /// mode=Autotile after wiping shadows.
    void setTilingAlgorithmPromoting(const QString& screenId, int virtualDesktop, const QString& activity,
                                     const QString& algorithmId);

    Q_INVOKABLE Layout* layoutForScreen(const QString& screenId, int virtualDesktop = 0,
                                        const QString& activity = QString()) const override;

    /// Resolve layout for @p screenId using the current desktop/activity
    /// context. @ref layoutForScreen already falls back to
    /// @ref defaultLayout internally when no explicit assignment matches,
    /// so this helper is a thin context-filling forwarder.
    Q_INVOKABLE Layout* resolveLayoutForScreen(const QString& screenId) const override
    {
        return layoutForScreen(screenId, m_currentVirtualDesktop, m_currentActivity);
    }

    Q_INVOKABLE void clearAssignment(const QString& screenId, int virtualDesktop = 0,
                                     const QString& activity = QString());
    Q_INVOKABLE bool hasExplicitAssignment(const QString& screenId, int virtualDesktop = 0,
                                           const QString& activity = QString()) const;

    /// Raw assignment id for a (screen, desktop, activity) context.
    /// Returns the stored string (manual-layout UUID or
    /// @c "autotile:<algorithmId>") without resolving to a @ref Layout*.
    /// On cascade-miss, falls through to the level-1 global defaults
    /// (snap provider first, then autotile provider; see
    /// @ref setDefaultLayoutIdProvider /
    /// @ref setDefaultAutotileAlgorithmProvider). Empty when every
    /// cascade level misses AND both providers return empty. Callers
    /// that need to distinguish "stored" from "synthesized fallback"
    /// must pair this with @ref hasExplicitAssignment.
    Q_INVOKABLE QString assignmentIdForScreen(const QString& screenId, int virtualDesktop = 0,
                                              const QString& activity = QString()) const override;

    /// Full entry for a (screen, desktop, activity) context. Shares
    /// the per-context cascade with @ref layoutForScreen up through
    /// level-2 (per-screen base entry), but the two diverge at level-1
    /// (global defaults): on cascade-miss this method synthesizes from
    /// BOTH providers - snap provider first, then autotile provider -
    /// using the same precedence as @ref assignmentIdForScreen, while
    /// @ref layoutForScreen consults only the snap provider via
    /// @ref defaultLayout. This means a caller mixing both APIs may
    /// see @c entry.mode == @c Autotile with @ref layoutForScreen
    /// returning a snap @ref Layout* (the historical pre-368 fallback
    /// shape, preserved so the autotile engine's
    /// @ref assignmentIdForScreen-driven activation path remains
    /// mode-aware while the snap engine's
    /// @ref layoutForScreen-driven path stays @ref Layout*-typed).
    /// Returns a default-constructed entry when neither provider
    /// returns a value. Callers that need raw stored state without
    /// the synth fallback must gate with @ref hasExplicitAssignment.
    AssignmentEntry assignmentEntryForScreen(const QString& screenId, int virtualDesktop = 0,
                                             const QString& activity = QString()) const;

    AssignmentEntry::Mode modeForScreen(const QString& screenId, int virtualDesktop = 0,
                                        const QString& activity = QString()) const;

    /// Per-field cascade readers — return the named field from the first
    /// entry in the cascade where it is non-empty. Crucially, these do
    /// NOT route through the @c activeLayoutId-based reject filter that
    /// @ref assignmentEntryForScreen uses, so a "stored-but-inactive"
    /// preference (e.g. an entry shaped like
    /// {mode=Snapping, snap="", tile="cluster"} that the partial-update
    /// path can produce) IS visible to the field-getter that targets
    /// the populated field. On total cascade miss they synthesize from
    /// the global default (snap provider for snap, autotile provider
    /// for tile) and may still return empty if no provider has a value.
    QString snappingLayoutForScreen(const QString& screenId, int virtualDesktop = 0,
                                    const QString& activity = QString()) const;
    QString tilingAlgorithmForScreen(const QString& screenId, int virtualDesktop = 0,
                                     const QString& activity = QString()) const;

    /// Flip mode to @c Snapping for every entry currently in @c Autotile
    /// (preserves @c snappingLayout + @c tilingAlgorithm). Emits
    /// @c layoutAssigned per affected screen; one save at end.
    void clearAutotileAssignments();

    /// Batch setters - clear existing, set new, save once at end.
    void setAllScreenAssignments(const QHash<QString, QString>& assignments);
    void setAllDesktopAssignments(const QHash<QPair<QString, int>, QString>& assignments);
    void setAllActivityAssignments(const QHash<QPair<QString, QString>, QString>& assignments);

    QHash<QPair<QString, int>, QString> desktopAssignments() const;
    QHash<QPair<QString, QString>, QString> activityAssignments() const;

    // ─── Quick-layout slots (1..9) ────────────────────────────────────────

    Q_INVOKABLE Layout* layoutForShortcut(int number) const;
    Q_INVOKABLE void applyQuickLayout(int number, const QString& screenId);
    void setQuickLayoutSlot(int number, const QString& layoutId);
    void setAllQuickLayoutSlots(const QHash<int, QString>& slots);
    QHash<int, QString> quickLayoutSlots() const
    {
        return m_quickLayoutShortcuts;
    }

    // ─── Layout cycling ───────────────────────────────────────────────────

    Q_INVOKABLE void cycleToPreviousLayout(const QString& screenId);
    Q_INVOKABLE void cycleToNextLayout(const QString& screenId);

    // ─── Built-in layouts ─────────────────────────────────────────────────

    Q_INVOKABLE void createBuiltInLayouts();
    QVector<Layout*> builtInLayouts() const;

    // ─── Context (desktop / activity) ─────────────────────────────────────

    Q_INVOKABLE int currentVirtualDesktop() const override
    {
        return m_currentVirtualDesktop;
    }
    Q_INVOKABLE QString currentActivity() const override
    {
        return m_currentActivity;
    }
    void setCurrentVirtualDesktop(int desktop)
    {
        m_currentVirtualDesktop = desktop;
    }
    void setCurrentActivity(const QString& activity)
    {
        m_currentActivity = activity;
    }

    // ─── Autotile layout overrides (per-algorithm user overrides) ─────────

    void saveAutotileOverrides(const QString& algorithmId, const QJsonObject& overrides);
    QJsonObject loadAutotileOverrides(const QString& algorithmId) const;

Q_SIGNALS:
    // layoutAdded / layoutRemoved / activeLayoutChanged / layoutAssigned are
    // inherited from IZoneLayoutRegistry - declared on the interface so
    // consumers that target the contract can connect without depending on
    // the concrete class. Emit sites still live in this TU and reach the
    // inherited signal via Q_OBJECT's metaobject chain.
    void layoutsChanged();
    void layoutDirectoryChanged();
    void layoutsLoaded();
    void layoutsSaved();

private:
    /// Remove every assignment entry on @p screenId's physical screen
    /// (and its VS variants) that would shadow @p (virtualDesktop, activity)
    /// in the cascade walk. Internal helper used by the @c promoting
    /// variants above; the target slot itself is left intact for the
    /// caller to write.
    void clearShadowsForSlot(const QString& screenId, int virtualDesktop, const QString& activity);

    void ensureLayoutDirectory();
    void loadLayoutsFromDirectory(const QString& directory);
    Layout* restoreSystemLayout(const QUuid& id, const QString& systemPath);
    QString layoutFilePath(const QUuid& id) const;
    void readAssignmentGroups(PhosphorConfig::IBackend* backend);
    void readQuickLayouts(PhosphorConfig::IBackend* backend);
    Layout* cycleLayoutImpl(const QString& screenId, int direction);
    bool shouldSkipLayoutAssignment(const QString& layoutId, const QString& context) const;
    void emitLayoutAssigned(const QString& screenId, int virtualDesktop, const QString& layoutId);
    /**
     * @brief Commit a targeted per-screen layout switch (from a quick-slot
     * shortcut, layout cycle, or any caller that wants to change ONLY the
     * named screen - not fan activeLayoutChanged across every screen).
     *
     * Writes the per-desktop assignment for @p screenId with empty activity
     * (so D-Bus/KCM lookups using empty activity see the entry), clears any
     * stale activity-keyed assignment that would shadow it in the cascade,
     * then updates the global active-layout pointer under a QSignalBlocker
     * so @ref activeLayoutChanged does NOT fire - preventing resnap on the
     * other screens. Equivalent to the write+update pattern both
     * applyQuickLayout and cycleLayoutImpl were open-coding before the
     * extraction.
     */
    void applyLayoutToScreen(const QString& screenId, Layout* layout);
    QJsonObject loadAllAutotileOverrides() const;
    void saveAllAutotileOverrides(const QJsonObject& all);
    Layout* resolveConfiguredDefault() const;

    /// Resolve the level-1 global default into an AssignmentEntry on
    /// cascade-miss. Three-tier precedence:
    ///   1. snapping-preferred provider (true → return Snapping with
    ///      possibly-empty layout, suppressing the autotile fallthrough
    ///      so users in snapping mode don't see autotile OSD content);
    ///   2. snap-id provider (non-empty → return Snapping with that id);
    ///   3. autotile provider (non-empty → return AutoTile with that
    ///      algorithm).
    /// Returns a default-constructed (invalid) entry when no provider
    /// has a value.
    AssignmentEntry resolveDefaultAssignmentEntry() const;

    std::function<QString()> m_defaultLayoutIdProvider; ///< Empty = provider disabled; falls back to first layout
    /// Empty = provider disabled. Returns the user's default autotile
    /// algorithm id when autotile is the active mode; returns empty
    /// when autotile is disabled (composition root's responsibility).
    /// Symmetric to @c m_defaultLayoutIdProvider; together they form
    /// the level-1 cascade tier.
    std::function<QString()> m_defaultAutotileAlgorithmProvider;
    /// Empty = provider unset (legacy behaviour). Returns true when
    /// the user has snapping mode enabled in settings, regardless of
    /// whether a global default snap layout id is configured. See
    /// @ref setSnappingPreferredProvider for the rationale.
    std::function<bool()> m_snappingPreferredProvider;
    std::unique_ptr<PhosphorConfig::IBackend> m_ownedBackend;
    PhosphorConfig::IBackend* m_configBackend = nullptr; ///< Borrowed alias of m_ownedBackend.get(); always non-null
    QString m_layoutSubdirectory; ///< XDG-relative (e.g. "plasmazones/layouts") - drives locateAll discovery
    QString m_layoutDirectory; ///< Absolute user-writable path derived from m_layoutSubdirectory
    QVector<Layout*> m_layouts;
    Layout* m_activeLayout = nullptr;
    Layout* m_previousLayout = nullptr; ///< Active layout before last setActiveLayout (for resnap)
    QHash<LayoutAssignmentKey, AssignmentEntry> m_assignments;
    QHash<int, QString> m_quickLayoutShortcuts; ///< slot number (1..9) → layout ID
    int m_currentVirtualDesktop = 1;
    QString m_currentActivity;
};

} // namespace PhosphorZones
