#include "wayland/clipboard_service.h"

#include "config/atomic_file.h"
#include "config/config_limits.h"
#include "core/log.h"
#include "ext-data-control-v1-client-protocol.h"
#include "security/encrypted_file_store.h"
#include "util/file_utils.h"
#include "wlr-data-control-unstable-v1-client-protocol.h"

#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <fcntl.h>
#include <filesystem>
#include <fstream>
#include <limits>
#include <memory>
#include <nlohmann/json.hpp>
#include <poll.h>
#include <sodium.h>
#include <stdexcept>
#include <string_view>
#include <unistd.h>
#include <unordered_set>

namespace {

  constexpr std::size_t kMinHistoryMaxEntries = static_cast<std::size_t>(noctalia::config::kClipboardHistoryMinEntries);
  constexpr std::size_t kMaxHistoryMaxEntries = static_cast<std::size_t>(noctalia::config::kClipboardHistoryMaxEntries);
  // Sized against the per-entry image limit rather than against text: at 64 MiB,
  // two full-screen screenshots filled the entire budget and evicted every text
  // entry behind them.
  constexpr std::size_t kDefaultMaxHistoryBytes = 256U * 1024U * 1024U;
  // Per-entry limits are split by kind because the two have nothing in common:
  // a 4 MiB text item is pathological, while a full-screen grab of photographic
  // content on a 4K display lands around 20 MiB as ordinary PNG. mutter draws
  // the same distinction (MAX_TEXT_SIZE 4 MiB, MAX_IMAGE_SIZE 200 MiB).
  constexpr std::size_t kMaxTextEntryBytes = 4U * 1024U * 1024U;
  constexpr std::size_t kMaxImageEntryBytes = 32U * 1024U * 1024U;
  // Ceiling on the live selection kept for adoption. Deliberately far above the
  // history limits, because an item too large to store is still one the user
  // expects to paste; it only exists to keep a pathological copy out of a
  // process that stays resident. Matches mutter's MAX_IMAGE_SIZE, which bounds
  // the equivalent cache in GNOME.
  constexpr std::size_t kMaxSelectionBackupBytes = 200U * 1024U * 1024U;
  constexpr std::size_t kMaxManifestBytes = 16U * 1024U * 1024U;
  constexpr std::size_t kPreviewBytes = 200;
  constexpr std::string_view kManifestPurpose = "clipboard-manifest-v1";
  constexpr std::string_view kPayloadPurpose = "clipboard-payload-v1";
  constexpr std::array kTextMimeTypes = {
      std::string_view{"text/plain;charset=utf-8"},
      std::string_view{"text/plain"},
      std::string_view{"UTF8_STRING"},
  };

  constexpr std::array kImageMimeTypes = {
      std::string_view{"image/png"},
      std::string_view{"image/jpeg"},
      std::string_view{"image/jxl"},
  };

  constexpr std::array kPasswordHintMimeTypes = {
      std::string_view{"x-kde-passwordManagerHint"},
  };

  [[nodiscard]] bool selectionAdvertisesPasswordHint(const std::vector<std::string>& mimeTypes) {
    for (const std::string& advertised : mimeTypes) {
      for (const std::string_view hint : kPasswordHintMimeTypes) {
        if (advertised == hint) {
          return true;
        }
      }
    }
    return false;
  }

  constexpr Logger kLog("clipboard");
  std::uint64_t gStorageCounter = 0;

  void secureFile(const std::filesystem::path& path) {
    std::error_code ec;
    if (!FileUtils::setPrivateFilePermissions(path, ec)) {
      kLog.warn("failed to secure clipboard storage path {}: {}", path.string(), ec.message());
    }
  }

  void secureDirectory(const std::filesystem::path& path) {
    std::error_code ec;
    if (!FileUtils::setPrivateDirectoryPermissions(path, ec)) {
      kLog.warn("failed to secure clipboard storage path {}: {}", path.string(), ec.message());
    }
  }

  void secureClipboardStorage(const std::filesystem::path& root) {
    namespace fs = std::filesystem;

    std::error_code ec;
    if (!fs::exists(root, ec)) {
      if (ec) {
        kLog.warn("failed to inspect clipboard storage {}: {}", root.string(), ec.message());
      }
      return;
    }

    secureDirectory(root);
    fs::recursive_directory_iterator current(root, fs::directory_options::skip_permission_denied, ec);
    const fs::recursive_directory_iterator end;
    while (!ec && current != end) {
      const fs::file_status status = current->status(ec);
      if (ec) {
        break;
      }
      if (fs::is_directory(status)) {
        secureDirectory(current->path());
      } else if (fs::is_regular_file(status)) {
        secureFile(current->path());
      }
      current.increment(ec);
    }
    if (ec) {
      kLog.warn("failed to inspect clipboard storage {}: {}", root.string(), ec.message());
    }
  }

  void ensurePrivateDirectory(const std::filesystem::path& path) {
    std::error_code ec;
    if (!FileUtils::createPrivateDirectories(path, ec)) {
      throw std::filesystem::filesystem_error("failed to create private clipboard directory", path, ec);
    }
  }

  std::string_view binaryView(const std::vector<std::uint8_t>& data) {
    return {reinterpret_cast<const char*>(data.data()), data.size()};
  }

  std::string extensionForImageMimeType(std::string_view mimeType) {
    if (mimeType == "image/png")
      return ".png";
    if (mimeType == "image/jpeg" || mimeType == "image/jpg")
      return ".jpg";
    if (mimeType == "image/webp")
      return ".webp";
    if (mimeType == "image/jxl")
      return ".jxl";
    if (mimeType == "image/gif")
      return ".gif";
    if (mimeType == "image/bmp")
      return ".bmp";
    if (mimeType == "image/tiff")
      return ".tiff";
    if (mimeType == "image/svg+xml")
      return ".svg";
    if (mimeType == "image/avif")
      return ".avif";
    if (mimeType == "image/heic")
      return ".heic";
    return ".img";
  }

  std::string exportExtensionForEntry(const ClipboardEntry& entry) {
    std::string extension = extensionForImageMimeType(entry.dataMimeType);
    if (extension != ".img") {
      return extension;
    }

    for (const auto& mimeType : entry.mimeTypes) {
      extension = extensionForImageMimeType(mimeType);
      if (extension != ".img") {
        return extension;
      }
    }
    return extension;
  }

  void closeFd(int& fd) {
    if (fd >= 0) {
      close(fd);
      fd = -1;
    }
  }

  void* bindExtManager(wl_registry* registry, std::uint32_t name, std::uint32_t version) {
    const auto bindVersion = std::min(version, 1U);
    return wl_registry_bind(registry, name, &ext_data_control_manager_v1_interface, bindVersion);
  }

  void destroyExtManager(void* manager) {
    ext_data_control_manager_v1_destroy(static_cast<ext_data_control_manager_v1*>(manager));
  }

  void* getExtDataDevice(void* manager, wl_seat* seat) {
    return ext_data_control_manager_v1_get_data_device(static_cast<ext_data_control_manager_v1*>(manager), seat);
  }

  void destroyExtDevice(void* device) {
    ext_data_control_device_v1_destroy(static_cast<ext_data_control_device_v1*>(device));
  }

  int addExtDeviceListener(void* device, const void* listener, void* data) {
    return ext_data_control_device_v1_add_listener(
        static_cast<ext_data_control_device_v1*>(device),
        static_cast<const ext_data_control_device_v1_listener*>(listener), data
    );
  }

  void* createExtDataSource(void* manager) {
    return ext_data_control_manager_v1_create_data_source(static_cast<ext_data_control_manager_v1*>(manager));
  }

  void destroyExtSource(void* source) {
    ext_data_control_source_v1_destroy(static_cast<ext_data_control_source_v1*>(source));
  }

  int addExtSourceListener(void* source, const void* listener, void* data) {
    return ext_data_control_source_v1_add_listener(
        static_cast<ext_data_control_source_v1*>(source),
        static_cast<const ext_data_control_source_v1_listener*>(listener), data
    );
  }

  void extSourceOffer(void* source, const char* mimeType) {
    ext_data_control_source_v1_offer(static_cast<ext_data_control_source_v1*>(source), mimeType);
  }

  void extDeviceSetSelection(void* device, void* source) {
    ext_data_control_device_v1_set_selection(
        static_cast<ext_data_control_device_v1*>(device), static_cast<ext_data_control_source_v1*>(source)
    );
  }

  void destroyExtOffer(void* offer) {
    ext_data_control_offer_v1_destroy(static_cast<ext_data_control_offer_v1*>(offer));
  }

  int addExtOfferListener(void* offer, const void* listener, void* data) {
    return ext_data_control_offer_v1_add_listener(
        static_cast<ext_data_control_offer_v1*>(offer),
        static_cast<const ext_data_control_offer_v1_listener*>(listener), data
    );
  }

  void extOfferReceive(void* offer, const char* mimeType, int fd) {
    ext_data_control_offer_v1_receive(static_cast<ext_data_control_offer_v1*>(offer), mimeType, fd);
  }

  void* bindWlrManager(wl_registry* registry, std::uint32_t name, std::uint32_t version) {
    const auto bindVersion = std::min(version, 2U);
    return wl_registry_bind(registry, name, &zwlr_data_control_manager_v1_interface, bindVersion);
  }

  void destroyWlrManager(void* manager) {
    zwlr_data_control_manager_v1_destroy(static_cast<zwlr_data_control_manager_v1*>(manager));
  }

  void* getWlrDataDevice(void* manager, wl_seat* seat) {
    return zwlr_data_control_manager_v1_get_data_device(static_cast<zwlr_data_control_manager_v1*>(manager), seat);
  }

  void destroyWlrDevice(void* device) {
    zwlr_data_control_device_v1_destroy(static_cast<zwlr_data_control_device_v1*>(device));
  }

  int addWlrDeviceListener(void* device, const void* listener, void* data) {
    return zwlr_data_control_device_v1_add_listener(
        static_cast<zwlr_data_control_device_v1*>(device),
        static_cast<const zwlr_data_control_device_v1_listener*>(listener), data
    );
  }

  void* createWlrDataSource(void* manager) {
    return zwlr_data_control_manager_v1_create_data_source(static_cast<zwlr_data_control_manager_v1*>(manager));
  }

  void destroyWlrSource(void* source) {
    zwlr_data_control_source_v1_destroy(static_cast<zwlr_data_control_source_v1*>(source));
  }

  int addWlrSourceListener(void* source, const void* listener, void* data) {
    return zwlr_data_control_source_v1_add_listener(
        static_cast<zwlr_data_control_source_v1*>(source),
        static_cast<const zwlr_data_control_source_v1_listener*>(listener), data
    );
  }

  void wlrSourceOffer(void* source, const char* mimeType) {
    zwlr_data_control_source_v1_offer(static_cast<zwlr_data_control_source_v1*>(source), mimeType);
  }

  void wlrDeviceSetSelection(void* device, void* source) {
    zwlr_data_control_device_v1_set_selection(
        static_cast<zwlr_data_control_device_v1*>(device), static_cast<zwlr_data_control_source_v1*>(source)
    );
  }

  void destroyWlrOffer(void* offer) {
    zwlr_data_control_offer_v1_destroy(static_cast<zwlr_data_control_offer_v1*>(offer));
  }

  int addWlrOfferListener(void* offer, const void* listener, void* data) {
    return zwlr_data_control_offer_v1_add_listener(
        static_cast<zwlr_data_control_offer_v1*>(offer),
        static_cast<const zwlr_data_control_offer_v1_listener*>(listener), data
    );
  }

  void wlrOfferReceive(void* offer, const char* mimeType, int fd) {
    zwlr_data_control_offer_v1_receive(static_cast<zwlr_data_control_offer_v1*>(offer), mimeType, fd);
  }

  const DataControlOps kExtDataControlOps = {
      .managerInterfaceName = ext_data_control_manager_v1_interface.name,
      .bindManager = &bindExtManager,
      .destroyManager = &destroyExtManager,
      .getDataDevice = &getExtDataDevice,
      .destroyDevice = &destroyExtDevice,
      .addDeviceListener = &addExtDeviceListener,
      .createDataSource = &createExtDataSource,
      .destroySource = &destroyExtSource,
      .addSourceListener = &addExtSourceListener,
      .sourceOffer = &extSourceOffer,
      .deviceSetSelection = &extDeviceSetSelection,
      .destroyOffer = &destroyExtOffer,
      .addOfferListener = &addExtOfferListener,
      .offerReceive = &extOfferReceive,
  };

  const DataControlOps kWlrDataControlOps = {
      .managerInterfaceName = zwlr_data_control_manager_v1_interface.name,
      .bindManager = &bindWlrManager,
      .destroyManager = &destroyWlrManager,
      .getDataDevice = &getWlrDataDevice,
      .destroyDevice = &destroyWlrDevice,
      .addDeviceListener = &addWlrDeviceListener,
      .createDataSource = &createWlrDataSource,
      .destroySource = &destroyWlrSource,
      .addSourceListener = &addWlrSourceListener,
      .sourceOffer = &wlrSourceOffer,
      .deviceSetSelection = &wlrDeviceSetSelection,
      .destroyOffer = &destroyWlrOffer,
      .addOfferListener = &addWlrOfferListener,
      .offerReceive = &wlrOfferReceive,
  };

  void handleExtDataOffer(void* data, ext_data_control_device_v1* /*device*/, ext_data_control_offer_v1* offer) {
    static_cast<ClipboardService*>(data)->handleDataOffer(offer);
  }

  void handleExtSelection(void* data, ext_data_control_device_v1* /*device*/, ext_data_control_offer_v1* offer) {
    static_cast<ClipboardService*>(data)->handleSelection(offer);
  }

  void handleExtFinished(void* data, ext_data_control_device_v1* /*device*/) {
    static_cast<ClipboardService*>(data)->handleDeviceFinished();
  }

  void handleExtPrimarySelection(void* data, ext_data_control_device_v1* /*device*/, ext_data_control_offer_v1* offer) {
    static_cast<ClipboardService*>(data)->handlePrimarySelection(offer);
  }

  const ext_data_control_device_v1_listener kExtDeviceListener = {
      .data_offer = &handleExtDataOffer,
      .selection = &handleExtSelection,
      .finished = &handleExtFinished,
      .primary_selection = &handleExtPrimarySelection,
  };

  void handleExtOfferMimeType(void* data, ext_data_control_offer_v1* offer, const char* mimeType) {
    static_cast<ClipboardService*>(data)->handleOfferMimeType(offer, mimeType);
  }

  const ext_data_control_offer_v1_listener kExtOfferListener = {
      .offer = &handleExtOfferMimeType,
  };

  void handleExtSourceSend(void* data, ext_data_control_source_v1* source, const char* mimeType, int fd) {
    static_cast<ClipboardService*>(data)->handleSourceSend(source, mimeType, fd);
  }

  void handleExtSourceCancelled(void* data, ext_data_control_source_v1* source) {
    static_cast<ClipboardService*>(data)->handleSourceCancelled(source);
  }

  const ext_data_control_source_v1_listener kExtSourceListener = {
      .send = &handleExtSourceSend,
      .cancelled = &handleExtSourceCancelled,
  };

  void handleWlrDataOffer(void* data, zwlr_data_control_device_v1* /*device*/, zwlr_data_control_offer_v1* offer) {
    static_cast<ClipboardService*>(data)->handleDataOffer(offer);
  }

  void handleWlrSelection(void* data, zwlr_data_control_device_v1* /*device*/, zwlr_data_control_offer_v1* offer) {
    static_cast<ClipboardService*>(data)->handleSelection(offer);
  }

  void handleWlrFinished(void* data, zwlr_data_control_device_v1* /*device*/) {
    static_cast<ClipboardService*>(data)->handleDeviceFinished();
  }

  void
  handleWlrPrimarySelection(void* data, zwlr_data_control_device_v1* /*device*/, zwlr_data_control_offer_v1* offer) {
    static_cast<ClipboardService*>(data)->handlePrimarySelection(offer);
  }

  const zwlr_data_control_device_v1_listener kWlrDeviceListener = {
      .data_offer = &handleWlrDataOffer,
      .selection = &handleWlrSelection,
      .finished = &handleWlrFinished,
      .primary_selection = &handleWlrPrimarySelection,
  };

  void handleWlrOfferMimeType(void* data, zwlr_data_control_offer_v1* offer, const char* mimeType) {
    static_cast<ClipboardService*>(data)->handleOfferMimeType(offer, mimeType);
  }

  const zwlr_data_control_offer_v1_listener kWlrOfferListener = {
      .offer = &handleWlrOfferMimeType,
  };

  void handleWlrSourceSend(void* data, zwlr_data_control_source_v1* source, const char* mimeType, int fd) {
    static_cast<ClipboardService*>(data)->handleSourceSend(source, mimeType, fd);
  }

  void handleWlrSourceCancelled(void* data, zwlr_data_control_source_v1* source) {
    static_cast<ClipboardService*>(data)->handleSourceCancelled(source);
  }

  const zwlr_data_control_source_v1_listener kWlrSourceListener = {
      .send = &handleWlrSourceSend,
      .cancelled = &handleWlrSourceCancelled,
  };

  const void* deviceListenerFor(const DataControlOps& ops) {
    return &ops == &kExtDataControlOps ? static_cast<const void*>(&kExtDeviceListener)
                                       : static_cast<const void*>(&kWlrDeviceListener);
  }

  const void* offerListenerFor(const DataControlOps& ops) {
    return &ops == &kExtDataControlOps ? static_cast<const void*>(&kExtOfferListener)
                                       : static_cast<const void*>(&kWlrOfferListener);
  }

  const void* sourceListenerFor(const DataControlOps& ops) {
    return &ops == &kExtDataControlOps ? static_cast<const void*>(&kExtSourceListener)
                                       : static_cast<const void*>(&kWlrSourceListener);
  }

} // namespace

ClipboardService::ClipboardService(security::StorageKeyProvider& storageKeyProvider)
    : m_maxHistoryBytes(kDefaultMaxHistoryBytes), m_storageKeyProvider(storageKeyProvider) {}

void ClipboardService::setMaxHistoryBytesForTesting(std::size_t maxBytes) {
  m_maxHistoryBytes = maxBytes;
  trimHistoryToBudget();
}

void ClipboardService::setHistoryRetentionEnabled(bool enabled) {
  if (m_historyRetention == enabled) {
    return;
  }
  m_historyRetention = enabled;

  if (enabled) {
    if (m_dataKey.has_value()) {
      (void)loadPersistedHistory();
    }
  } else {
    // Keep only the live selection (most recent unpinned entry) in memory so
    // paste keeps working; drop pins and older/persisted history.
    std::deque<ClipboardEntry> live;
    for (auto& entry : m_history) {
      if (!entry.pinned) {
        live.push_back(std::move(entry));
        break;
      }
    }
    m_history = std::move(live);
    m_historyBytes = m_history.empty() ? 0 : m_history.front().byteSize;
  }

  ++m_changeSerial;
  notifyChanged();
}

void ClipboardService::setKeepFromClosedApps(bool enabled) {
  if (m_keepFromClosedApps == enabled) {
    return;
  }
  m_keepFromClosedApps = enabled;
  if (!enabled) {
    m_selectionBackup.reset();
    m_pendingOrphanAdopt = false;
  }
}

void ClipboardService::setMaxHistoryEntries(std::size_t maxEntries) {
  maxEntries = std::clamp(maxEntries, kMinHistoryMaxEntries, kMaxHistoryMaxEntries);
  if (m_maxHistoryEntries == maxEntries) {
    return;
  }
  m_maxHistoryEntries = maxEntries;
  if (!m_historyRetention) {
    return;
  }
  trimHistoryToBudget();
  ++m_changeSerial;
  notifyChanged();
}

ClipboardService::~ClipboardService() { cleanup(); }

const DataControlOps* extDataControlOps() { return &kExtDataControlOps; }

const DataControlOps* wlrDataControlOps() { return &kWlrDataControlOps; }

bool ClipboardEntry::isImage() const {
  return std::ranges::any_of(mimeTypes, [](const std::string& mimeType) { return mimeType.starts_with("image/"); });
}

bool ClipboardService::bind(void* manager, const DataControlOps* ops, wl_seat* seat) {
  if (manager == nullptr || ops == nullptr || seat == nullptr) {
    cleanup();
    return false;
  }

  if (m_manager == manager && m_ops == ops && m_seat == seat && m_device != nullptr) {
    return true;
  }

  cleanup();

  m_manager = manager;
  m_ops = ops;
  m_seat = seat;
  m_device = m_ops->getDataDevice(m_manager, m_seat);
  if (m_device == nullptr) {
    kLog.warn("failed to create data control device");
    return false;
  }

  if (m_ops->addDeviceListener(m_device, deviceListenerFor(*m_ops), this) != 0) {
    kLog.warn("failed to attach clipboard device listener");
    cleanup();
    return false;
  }

  kLog.info("clipboard service bound via {}", m_ops->managerInterfaceName);
  return true;
}

void ClipboardService::cleanup() {
  m_pendingOrphanAdopt = false;
  cancelActiveRead();
  cancelActiveWrites();
  clearOffers();

  if (m_ops != nullptr) {
    for (auto& outgoing : m_outgoingSources) {
      if (outgoing.source != nullptr) {
        m_ops->destroySource(outgoing.source);
      }
    }
  }
  m_outgoingSources.clear();

  if (m_device != nullptr && m_ops != nullptr) {
    m_ops->destroyDevice(m_device);
  }

  m_device = nullptr;
  m_selectionOffer = nullptr;
  m_seat = nullptr;
  m_ops = nullptr;
  m_manager = nullptr;
}

bool ClipboardService::isAvailable() const noexcept { return m_device != nullptr; }

const std::deque<ClipboardEntry>& ClipboardService::history() const noexcept { return m_history; }

std::uint64_t ClipboardService::changeSerial() const noexcept { return m_changeSerial; }

ClipboardPersistenceState ClipboardService::persistenceState() const noexcept { return m_persistenceState; }

bool ClipboardService::persistenceMigrationPending() const noexcept { return m_persistenceMigrationPending; }

void ClipboardService::syncPersistence() {
  m_dataKey.reset();
  setPersistenceState(ClipboardPersistenceState::Opening, m_persistenceMigrationPending);

  if (m_storageKeyProvider.state() == security::StorageKeyState::Ready) {
    auto key = m_storageKeyProvider.deriveKey(security::StorageKeyPurpose::ClipboardHistory);
    if (!key.has_value()) {
      setPersistenceState(ClipboardPersistenceState::BackendError, m_persistenceMigrationPending);
      return;
    }
    activatePersistenceKey(std::move(*key));
    return;
  }

  ClipboardPersistenceState state = ClipboardPersistenceState::BackendError;
  switch (m_storageKeyProvider.state()) {
  case security::StorageKeyState::Opening:
    state = ClipboardPersistenceState::Opening;
    break;
  case security::StorageKeyState::Unavailable:
    state = ClipboardPersistenceState::Unavailable;
    break;
  case security::StorageKeyState::Cancelled:
    state = ClipboardPersistenceState::Cancelled;
    break;
  case security::StorageKeyState::DeniedOrLocked:
    state = ClipboardPersistenceState::DeniedOrLocked;
    break;
  case security::StorageKeyState::MissingKey:
    state = ClipboardPersistenceState::MissingKey;
    break;
  case security::StorageKeyState::Ready:
  case security::StorageKeyState::BackendError:
    break;
  }
  setPersistenceState(state, m_persistenceMigrationPending);
}

void ClipboardService::retryPersistence() { m_storageKeyProvider.retry(); }

bool ClipboardService::clearEncryptedPersistenceForRecovery() {
  namespace fs = std::filesystem;

  m_dataKey.reset();
  std::error_code ec;
  fs::remove(manifestPath(), ec);
  if (ec) {
    kLog.warn("failed to remove encrypted clipboard manifest during recovery");
    setPersistenceState(ClipboardPersistenceState::BackendError, m_persistenceMigrationPending);
    return false;
  }

  const fs::path entriesDir(entriesDirectory());
  if (fs::exists(entriesDir, ec)) {
    for (const auto& entry : fs::directory_iterator(entriesDir, ec)) {
      if (entry.is_regular_file(ec) && entry.path().extension() == ".enc") {
        fs::remove(entry.path(), ec);
      }
      if (ec) {
        break;
      }
    }
  }
  if (ec) {
    kLog.warn("failed to remove encrypted clipboard payloads during recovery");
    setPersistenceState(ClipboardPersistenceState::BackendError, m_persistenceMigrationPending);
    return false;
  }

  m_history.clear();
  m_historyBytes = 0;
  ++m_changeSerial;
  notifyChanged();
  setPersistenceState(ClipboardPersistenceState::Opening, m_persistenceMigrationPending);
  return true;
}

bool ClipboardService::hasEncryptedPersistence() const {
  std::error_code ec;
  const bool exists = std::filesystem::exists(manifestPath(), ec);
  return !ec && exists;
}

void ClipboardService::activatePersistenceKey(security::SecureKey key) {
  m_dataKey = std::move(key);
  if (!loadPersistedHistory()) {
    if (m_persistenceState == ClipboardPersistenceState::Opening) {
      setPersistenceState(ClipboardPersistenceState::BackendError, m_persistenceMigrationPending);
    }
    return;
  }

  std::error_code ec;
  bool migrationPending = std::filesystem::exists(legacyManifestPath(), ec);
  if (!ec && !migrationPending && std::filesystem::exists(entriesDirectory(), ec)) {
    for (const auto& entry : std::filesystem::directory_iterator(entriesDirectory(), ec)) {
      if (entry.is_regular_file(ec) && entry.path().extension() == ".bin") {
        migrationPending = true;
        break;
      }
      if (ec) {
        break;
      }
    }
  }
  setPersistenceState(ClipboardPersistenceState::Ready, !ec && migrationPending);
}

void ClipboardService::setPersistenceState(ClipboardPersistenceState state, bool migrationPending) {
  if (m_persistenceState == state && m_persistenceMigrationPending == migrationPending) {
    return;
  }
  m_persistenceState = state;
  m_persistenceMigrationPending = migrationPending;
  if (m_persistenceChangeCallback) {
    m_persistenceChangeCallback();
  }
}

std::size_t ClipboardService::addPollFds(std::vector<pollfd>& fds) const {
  const std::size_t start = fds.size();
  if (m_activeRead.fd >= 0) {
    fds.push_back({.fd = m_activeRead.fd, .events = POLLIN | POLLHUP | POLLERR, .revents = 0});
  }
  for (const auto& write : m_activeWrites) {
    if (write.fd >= 0) {
      fds.push_back({.fd = write.fd, .events = POLLOUT | POLLHUP | POLLERR, .revents = 0});
    }
  }
  return fds.size() - start;
}

bool ClipboardService::ensureEntryLoaded(std::size_t index) {
  if (index >= m_history.size()) {
    return false;
  }
  return loadEntryPayload(m_history[index]);
}

std::optional<std::string> ClipboardService::imageDataUri(std::size_t index) {
  if (index >= m_history.size() || !m_history[index].isImage() || !loadEntryPayload(m_history[index])) {
    return std::nullopt;
  }

  const ClipboardEntry& entry = m_history[index];
  const std::size_t encodedSize = sodium_base64_encoded_len(entry.data.size(), sodium_base64_VARIANT_ORIGINAL);
  std::string source = "data:application/octet-stream;base64,";
  const std::size_t prefixSize = source.size();
  source.resize(prefixSize + encodedSize);
  if (sodium_bin2base64(
          source.data() + static_cast<std::ptrdiff_t>(prefixSize), encodedSize, entry.data.data(), entry.data.size(),
          sodium_base64_VARIANT_ORIGINAL
      )
      == nullptr) {
    return std::nullopt;
  }
  source.resize(prefixSize + std::char_traits<char>::length(source.c_str() + static_cast<std::ptrdiff_t>(prefixSize)));
  return source;
}

std::optional<std::string> ClipboardService::exportEntryForExternalTool(std::size_t index) {
  namespace fs = std::filesystem;

  if (index >= m_history.size()) {
    return std::nullopt;
  }

  ClipboardEntry& entry = m_history[index];
  if (!entry.isImage()) {
    return std::nullopt;
  }

  const bool wasLoaded = entry.payloadLoaded;
  if (!loadEntryPayload(entry)) {
    return std::nullopt;
  }

  try {
    if (entry.storageId.empty()) {
      entry.storageId = generateStorageId();
    }

    const fs::path exportDir = fs::path(stateDirectory()) / "exports";
    ensurePrivateDirectory(stateDirectory());
    ensurePrivateDirectory(exportDir);
    const fs::path exportPath = exportDir / (entry.storageId + exportExtensionForEntry(entry));

    if (!writeTextFileAtomic(exportPath, binaryView(entry.data), FileUtils::privateFileMode())) {
      throw std::runtime_error("failed to write exported clipboard image");
    }

    if (!wasLoaded) {
      evictPayloadData(entry);
    }
    return exportPath.string();
  } catch (const std::exception& e) {
    if (!wasLoaded) {
      evictPayloadData(entry);
    }
    kLog.warn("failed to export clipboard image: {}", e.what());
    return std::nullopt;
  }
}

void ClipboardService::evictEntryPayload(std::size_t index) {
  if (index >= m_history.size()) {
    return;
  }
  const ClipboardEntry& entry = m_history[index];
  if (!m_dataKey.has_value() || !isValidStorageId(entry.storageId)) {
    return;
  }
  std::error_code ec;
  if (!std::filesystem::is_regular_file(payloadPathForId(entry.storageId), ec) || ec) {
    return;
  }
  evictPayloadData(m_history[index]);
}

void ClipboardService::evictAllPayloads() {
  for (std::size_t i = 0; i < m_history.size(); ++i) {
    evictEntryPayload(i);
  }
}

std::optional<std::string> ClipboardService::clipboardText() {
  for (auto& entry : m_history) {
    // Pinned entries sit at the front but are not the newest capture; paste
    // must reflect the most recent real clipboard content.
    if (entry.pinned || entry.isImage()) {
      continue;
    }
    if (!loadEntryPayload(entry) || entry.data.empty()) {
      continue;
    }
    return std::string(entry.data.begin(), entry.data.end());
  }
  return std::nullopt;
}

void ClipboardService::setClipboardText(std::string text) { (void)copyText(std::move(text)); }

bool ClipboardService::copyText(std::string text) {
  std::vector<std::uint8_t> data(text.begin(), text.end());
  return copyData({"text/plain;charset=utf-8", "text/plain"}, std::move(data));
}

bool ClipboardService::copyText(std::string text, std::string mimeType) {
  std::vector<std::uint8_t> data(text.begin(), text.end());
  return copyData({std::move(mimeType)}, std::move(data));
}

bool ClipboardService::copyImagePng(std::vector<std::uint8_t> png) {
  if (png.empty()) {
    return false;
  }
  return copyData({"image/png"}, std::move(png));
}

std::size_t ClipboardService::maxEntryBytesFor(std::string_view mimeType) {
  return isTextMimeType(mimeType) ? kMaxTextEntryBytes : kMaxImageEntryBytes;
}

// Only one flavour of the selection is ever read, and every advertised type is
// served from that same buffer, so the offer must not claim types the payload
// cannot actually satisfy — text aliases are the one safe widening.
std::vector<std::string> ClipboardService::mimeTypesForPayload(const std::string& dataMimeType) {
  std::vector<std::string> mimeTypes;
  mimeTypes.push_back(dataMimeType);
  if (isTextMimeType(dataMimeType)) {
    if (!std::ranges::contains(mimeTypes, "text/plain;charset=utf-8")) {
      mimeTypes.emplace_back("text/plain;charset=utf-8");
    }
    if (!std::ranges::contains(mimeTypes, "text/plain")) {
      mimeTypes.emplace_back("text/plain");
    }
  }
  return mimeTypes;
}

bool ClipboardService::copyEntry(const ClipboardEntry& entry) {
  if (entry.data.empty() || entry.dataMimeType.empty()) {
    return false;
  }

  return copyData(mimeTypesForPayload(entry.dataMimeType), entry.data);
}

std::size_t ClipboardService::pinnedCount() const noexcept {
  std::size_t count = 0;
  while (count < m_history.size() && m_history[count].pinned) {
    ++count;
  }
  return count;
}

bool ClipboardService::promoteEntry(std::size_t index) {
  if (index >= m_history.size()) {
    return false;
  }
  // Pinned entries keep their position in the pinned block; copying one must
  // not reorder the user's pins.
  if (m_history[index].pinned) {
    return true;
  }

  // Unpinned entries move to the top of the unpinned region (just below the
  // contiguous pinned block at the front).
  const std::size_t target = pinnedCount();
  if (index == target) {
    return true;
  }

  ClipboardEntry entry = std::move(m_history[index]);
  m_history.erase(m_history.begin() + static_cast<std::ptrdiff_t>(index));
  entry.capturedAt = std::chrono::system_clock::now();
  entry.timestamp = std::chrono::steady_clock::now();
  m_history.insert(m_history.begin() + static_cast<std::ptrdiff_t>(target), std::move(entry));
  ++m_changeSerial;
  persistHistory();
  notifyChanged();
  return true;
}

bool ClipboardService::setEntryPinned(std::size_t index, bool pinned) {
  if (index >= m_history.size()) {
    return false;
  }
  if (m_history[index].pinned == pinned) {
    return true;
  }

  ClipboardEntry entry = std::move(m_history[index]);
  m_history.erase(m_history.begin() + static_cast<std::ptrdiff_t>(index));
  entry.pinned = pinned;

  if (pinned) {
    // Most-recently-pinned first: new pins go to the very front.
    m_history.push_front(std::move(entry));
  } else {
    // Unpinned entries return to the top of the unpinned region, just below
    // the remaining pinned block.
    const std::size_t target = pinnedCount();
    m_history.insert(m_history.begin() + static_cast<std::ptrdiff_t>(target), std::move(entry));
  }

  ++m_changeSerial;
  persistHistory();
  notifyChanged();
  return true;
}

bool ClipboardService::removeHistoryEntry(std::size_t index) {
  if (index >= m_history.size()) {
    return false;
  }
  const std::size_t removedBytes = m_history[index].byteSize;
  m_history.erase(m_history.begin() + static_cast<std::ptrdiff_t>(index));
  if (m_historyBytes >= removedBytes) {
    m_historyBytes -= removedBytes;
  } else {
    m_historyBytes = 0;
  }
  ++m_changeSerial;
  persistHistory();
  notifyChanged();
  return true;
}

void ClipboardService::clearUnpinnedHistory() {
  const std::size_t firstUnpinned = pinnedCount();
  if (firstUnpinned >= m_history.size()) {
    return;
  }

  std::size_t removedBytes = 0;
  for (std::size_t i = firstUnpinned; i < m_history.size(); ++i) {
    removedBytes += m_history[i].byteSize;
  }
  m_history.erase(m_history.begin() + static_cast<std::ptrdiff_t>(firstUnpinned), m_history.end());
  if (m_historyBytes >= removedBytes) {
    m_historyBytes -= removedBytes;
  } else {
    m_historyBytes = 0;
  }
  ++m_changeSerial;
  persistHistory();
  notifyChanged();
}

void ClipboardService::clearHistory() {
  if (m_history.empty()) {
    return;
  }
  m_history.clear();
  m_historyBytes = 0;
  ++m_changeSerial;
  persistHistory();
  notifyChanged();
}

bool ClipboardService::copyData(std::vector<std::string> mimeTypes, std::vector<std::uint8_t> data) {
  if (data.empty()) {
    return false;
  }
  return copyData(std::move(mimeTypes), std::make_shared<const std::vector<std::uint8_t>>(std::move(data)));
}

bool ClipboardService::copyData(
    std::vector<std::string> mimeTypes, std::shared_ptr<const std::vector<std::uint8_t>> data
) {
  if (m_device == nullptr || m_ops == nullptr) {
    return false;
  }
  if (mimeTypes.empty() || data == nullptr || data->empty()) {
    return false;
  }

  void* source = m_ops->createDataSource(m_manager);
  if (source == nullptr) {
    return false;
  }

  if (m_ops->addSourceListener(source, sourceListenerFor(*m_ops), this) != 0) {
    m_ops->destroySource(source);
    return false;
  }

  for (const auto& mimeType : mimeTypes) {
    m_ops->sourceOffer(source, mimeType.c_str());
  }
  m_outgoingSources.push_back(
      OutgoingSource{
          .source = source,
          .mimeTypes = std::move(mimeTypes),
          .data = std::move(data),
      }
  );
  m_ops->deviceSetSelection(m_device, source);
  return true;
}

void ClipboardService::setChangeCallback(ChangeCallback callback) { m_changeCallback = std::move(callback); }

void ClipboardService::setPersistenceChangeCallback(ChangeCallback callback) {
  m_persistenceChangeCallback = std::move(callback);
}

void ClipboardService::dispatchReadEvents(short revents) {
  if (m_activeRead.fd < 0) {
    return;
  }

  if ((revents & POLLERR) != 0) {
    finishRead(true);
    return;
  }

  std::array<std::uint8_t, 16384> buffer{};
  for (;;) {
    const ssize_t bytesRead = read(m_activeRead.fd, buffer.data(), buffer.size());
    if (bytesRead > 0) {
      const auto nextSize = m_activeRead.buffer.size() + static_cast<std::size_t>(bytesRead);
      // The read has to run to the ceiling of whatever might still want the
      // bytes: an item too large for history can still be worth keeping alive
      // for paste, so aborting at the history limit would silently cap
      // adoption too.
      const std::size_t readLimit =
          m_keepFromClosedApps ? kMaxSelectionBackupBytes : maxEntryBytesFor(m_activeRead.mimeType);
      if (nextSize > readLimit) {
        kLog.warn("discarding oversized clipboard selection ({} bytes)", nextSize);
        finishRead(true);
        return;
      }
      m_activeRead.buffer.insert(m_activeRead.buffer.end(), buffer.begin(), buffer.begin() + bytesRead);
      continue;
    }

    if (bytesRead == 0) {
      finishRead(false);
      return;
    }

    if (errno == EINTR) {
      continue;
    }
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
      break;
    }

    finishRead(true);
    return;
  }

  if ((revents & POLLHUP) != 0) {
    finishRead(false);
  }
}

void ClipboardService::dispatchPollEvents(const std::vector<pollfd>& fds, std::size_t startIdx, std::size_t count) {
  const std::size_t end = std::min(fds.size(), startIdx + count);
  for (std::size_t i = startIdx; i < end; ++i) {
    if (fds[i].revents == 0) {
      continue;
    }
    if (fds[i].fd == m_activeRead.fd) {
      dispatchReadEvents(fds[i].revents);
    }
  }

  for (std::size_t i = startIdx; i < end; ++i) {
    if (fds[i].revents == 0) {
      continue;
    }
    dispatchWriteEvents(fds[i].fd, fds[i].revents);
  }

  flushPendingOrphanAdopt();
}

void ClipboardService::handleDataOffer(void* offer) {
  if (offer == nullptr || m_ops == nullptr) {
    return;
  }

  m_offers.push_back(
      OfferState{
          .offer = offer,
          .mimeTypes = {},
      }
  );
  if (m_ops->addOfferListener(offer, offerListenerFor(*m_ops), this) != 0) {
    kLog.warn("failed to attach clipboard offer listener");
  }
}

void ClipboardService::handleOfferMimeType(void* offer, const char* mimeType) {
  auto* state = findOffer(offer);
  if (state == nullptr || mimeType == nullptr) {
    return;
  }
  state->mimeTypes.emplace_back(mimeType);
}

void ClipboardService::handleSelection(void* offer) {
  cancelActiveRead();
  destroyOffer(m_selectionOffer);
  m_selectionOffer = offer;

  if (offer == nullptr) {
    // A null selection is normal during handoff between clients. Defer adoption
    // until the next poll tick so a replacement offer in the same dispatch batch
    // can arrive first; only adopt if the selection is still empty then.
    m_pendingOrphanAdopt = true;
    return;
  }

  m_pendingOrphanAdopt = false;
  m_selectionBackup.reset();
  if (!startReceive(offer)) {
    kLog.debug("selection offer has no supported MIME types");
  }
}

// A short read is indistinguishable from a complete one at the protocol level:
// there is no length, and a writer that dies mid-transfer just closes the pipe.
// Re-offering truncated bytes would hand every paste target a corrupt file, so
// formats that say where they end are checked before the shell adopts them.
bool ClipboardService::payloadLooksComplete(std::string_view mimeType, std::span<const std::uint8_t> data) {
  static constexpr std::array kPngTrailer =
      std::to_array<std::uint8_t>({0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82});
  static constexpr std::array kJpegTrailer = std::to_array<std::uint8_t>({0xFF, 0xD9});
  static constexpr std::array kGifTrailer = std::to_array<std::uint8_t>({0x3B});

  const auto endsWith = [&data](std::span<const std::uint8_t> trailer) {
    return data.size() >= trailer.size() && std::ranges::equal(data.last(trailer.size()), trailer);
  };

  if (mimeType == "image/png") {
    return endsWith(kPngTrailer);
  }
  if (mimeType == "image/jpeg" || mimeType == "image/jpg") {
    return endsWith(kJpegTrailer);
  }
  if (mimeType == "image/gif") {
    return endsWith(kGifTrailer);
  }

  // Everything else carries no end marker worth trusting; text in particular is
  // still useful when partial.
  return true;
}

void ClipboardService::flushPendingOrphanAdopt() {
  if (!m_pendingOrphanAdopt || m_selectionOffer != nullptr) {
    return;
  }
  m_pendingOrphanAdopt = false;
  adoptOrphanedSelection();
}

void ClipboardService::adoptOrphanedSelection() {
  if (!m_keepFromClosedApps || !m_selectionBackup.has_value()) {
    return;
  }

  const SelectionBackup backup = std::move(*m_selectionBackup);
  m_selectionBackup.reset();

  if (backup.data == nullptr || backup.data->empty()) {
    return;
  }

  if (!payloadLooksComplete(backup.dataMimeType, *backup.data)) {
    kLog.warn("not adopting orphaned selection: {} payload is truncated", backup.dataMimeType);
    return;
  }

  if (copyData(backup.mimeTypes, backup.data)) {
    kLog.debug("adopted orphaned selection: {} ({} bytes)", backup.dataMimeType, backup.data->size());
  }
}

void ClipboardService::handlePrimarySelection(void* offer) { destroyOffer(offer); }

void ClipboardService::handleDeviceFinished() { cleanup(); }

void ClipboardService::handleSourceSend(void* source, const char* mimeType, int fd) {
  const auto it = std::ranges::find(m_outgoingSources, source, &OutgoingSource::source);
  if (it != m_outgoingSources.end() && mimeType != nullptr) {
    const auto mimeIt = std::ranges::find(it->mimeTypes, std::string_view(mimeType));
    if (mimeIt != it->mimeTypes.end()) {
      if (queueOutgoingWrite(source, fd, it->data)) {
        return;
      }
    }
  }
  if (fd >= 0) {
    close(fd);
  }
}

void ClipboardService::handleSourceCancelled(void* source) {
  const auto it = std::ranges::find(m_outgoingSources, source, &OutgoingSource::source);
  if (it == m_outgoingSources.end()) {
    return;
  }

  if (m_ops != nullptr && it->source != nullptr) {
    m_ops->destroySource(it->source);
  }
  for (auto& write : m_activeWrites) {
    if (write.source == source) {
      write.source = nullptr;
    }
  }
  m_outgoingSources.erase(it);
}

const ClipboardService::OfferState* ClipboardService::findOffer(void* offer) const {
  const auto it = std::ranges::find(m_offers, offer, &OfferState::offer);
  return it != m_offers.end() ? &*it : nullptr;
}

ClipboardService::OfferState* ClipboardService::findOffer(void* offer) {
  const auto it = std::ranges::find(m_offers, offer, &OfferState::offer);
  return it != m_offers.end() ? &*it : nullptr;
}

void ClipboardService::destroyOffer(void* offer) {
  if (offer == nullptr || m_ops == nullptr) {
    return;
  }

  const auto it = std::ranges::find(m_offers, offer, &OfferState::offer);
  if (it == m_offers.end()) {
    return;
  }

  m_ops->destroyOffer(offer);
  m_offers.erase(it);
}

void ClipboardService::clearOffers() {
  if (m_ops != nullptr) {
    for (auto& offer : m_offers) {
      if (offer.offer != nullptr) {
        m_ops->destroyOffer(offer.offer);
      }
    }
  }
  m_offers.clear();
}

void ClipboardService::cancelActiveRead() {
  closeFd(m_activeRead.fd);
  m_activeRead.buffer.clear();
  m_activeRead.mimeType.clear();
  m_activeRead.offeredMimeTypes.clear();
  m_activeRead.offer = nullptr;
}

void ClipboardService::cancelActiveWrites() {
  for (auto& write : m_activeWrites) {
    closeFd(write.fd);
  }
  m_activeWrites.clear();
}

bool ClipboardService::startReceive(void* offer) {
  if (m_ops == nullptr) {
    return false;
  }

  const OfferState* state = findOffer(offer);
  if (state == nullptr) {
    return false;
  }

  if (selectionAdvertisesPasswordHint(state->mimeTypes)) {
    kLog.debug("ignoring clipboard selection: password-hint MIME advertised");
    return false;
  }

  const std::string mimeType = chooseMimeType(*state);
  if (mimeType.empty()) {
    return false;
  }

  int pipeFds[2] = {-1, -1};
  if (pipe2(pipeFds, O_CLOEXEC | O_NONBLOCK) != 0) {
    return false;
  }

  m_ops->offerReceive(offer, mimeType.c_str(), pipeFds[1]);
  close(pipeFds[1]);

  m_activeRead.fd = pipeFds[0];
  m_activeRead.offer = offer;
  m_activeRead.mimeType = mimeType;
  m_activeRead.buffer.clear();
  m_activeRead.offeredMimeTypes = state->mimeTypes;
  return true;
}

void ClipboardService::finishRead(bool discard) {
  const bool shouldStore = !discard && !m_activeRead.buffer.empty();
  const std::string mimeType = m_activeRead.mimeType;
  auto mimeTypes = std::move(m_activeRead.offeredMimeTypes);
  auto data = std::move(m_activeRead.buffer);
  const void* offer = m_activeRead.offer;

  cancelActiveRead();

  if (offer == m_selectionOffer) {
    destroyOffer(m_selectionOffer);
    m_selectionOffer = nullptr;
  }

  if (!shouldStore) {
    return;
  }

  // A selection too large to hold is simply not backed up; the reset in
  // handleSelection already dropped the previous one, so nothing stale can be
  // adopted in its place.
  if (m_keepFromClosedApps && data.size() > kMaxSelectionBackupBytes) {
    kLog.debug("selection of {} bytes is too large to keep for adoption", data.size());
  }

  ClipboardEntry entry;
  if (m_keepFromClosedApps && data.size() <= kMaxSelectionBackupBytes) {
    // Backed up before the history entry is built, and shared rather than
    // copied: this is the buffer that can be large, while the history copy
    // below is bounded by the per-kind entry limit.
    const auto payload = std::make_shared<const std::vector<std::uint8_t>>(std::move(data));
    m_selectionBackup = SelectionBackup{
        .mimeTypes = mimeTypesForPayload(mimeType),
        .dataMimeType = mimeType,
        .data = payload,
    };

    if (payload->size() > maxEntryBytesFor(mimeType)) {
      // History would drop it; do not copy bytes that are not going to be kept.
      return;
    }
    entry.data = *payload;
  } else {
    entry.data = std::move(data);
  }

  entry.storageId = generateStorageId();
  entry.mimeTypes = std::move(mimeTypes);
  if (!std::ranges::contains(entry.mimeTypes, mimeType)) {
    entry.mimeTypes.push_back(mimeType);
  }
  entry.dataMimeType = mimeType;
  entry.byteSize = entry.data.size();
  entry.payloadLoaded = true;
  entry.payloadPath = payloadPathForId(entry.storageId);
  entry.capturedAt = std::chrono::system_clock::now();
  entry.timestamp = std::chrono::steady_clock::now();
  if (isTextMimeType(mimeType)) {
    entry.textPreview = buildTextPreview(entry.data);
  }

  addToHistory(std::move(entry));
}

void ClipboardService::addToHistory(ClipboardEntry entry) {
  if (entry.byteSize == 0) {
    entry.byteSize = entry.data.size();
  }

  if (entry.byteSize == 0) {
    return;
  }

  if (!entry.data.empty() && isTextMimeType(entry.dataMimeType) && isEmptyTextPayload(entry.data)) {
    return;
  }

  if (entry.storageId.empty()) {
    entry.storageId = generateStorageId();
  }
  if (entry.payloadPath.empty()) {
    entry.payloadPath = payloadPathForId(entry.storageId);
  }

  if (entry.textPreview.empty() && !entry.data.empty() && isTextMimeType(entry.dataMimeType)) {
    entry.textPreview = buildTextPreview(entry.data);
  }

  if (!m_historyRetention) {
    // History is disabled: retain only the live selection in memory (so paste
    // still works) and never persist. Ignore the self-copy echo of unchanged
    // content to avoid needless churn.
    if (!m_history.empty()
        && !entry.data.empty()
        && m_history.front().byteSize == entry.byteSize
        && m_history.front().data == entry.data
        && m_history.front().dataMimeType == entry.dataMimeType) {
      return;
    }
    m_history.clear();
    m_historyBytes = entry.byteSize;
    m_history.push_back(std::move(entry));
    ++m_changeSerial;
    notifyChanged();
    return;
  }

  // Pinned entries form a contiguous block at the front; new captures join the
  // top of the unpinned region just below it.
  const std::size_t insertAt = pinnedCount();

  // Dedup against the most recent capture (the first unpinned entry) and
  // against every pinned entry. The latter matters because copying/actioning a
  // pinned entry echoes its content back as a fresh selection, which would
  // otherwise reappear as a duplicate unpinned entry at the top.
  if (!entry.data.empty()) {
    auto matchesExisting = [&](ClipboardEntry& current) {
      const bool currentWasLoaded = current.payloadLoaded;
      bool samePayload = false;
      if (current.byteSize == entry.byteSize) {
        const bool currentLoaded = current.payloadLoaded || loadEntryPayload(current);
        samePayload = currentLoaded && current.data == entry.data;
        if (!currentWasLoaded) {
          evictPayloadData(current);
        }
      }
      const bool sameMime = current.dataMimeType == entry.dataMimeType;
      const bool equivalentText = isTextMimeType(current.dataMimeType) && isTextMimeType(entry.dataMimeType);
      return samePayload && (sameMime || equivalentText);
    };

    for (std::size_t i = 0; i < insertAt && i < m_history.size(); ++i) {
      if (matchesExisting(m_history[i])) {
        return;
      }
    }
    if (insertAt < m_history.size() && matchesExisting(m_history[insertAt])) {
      return;
    }
  }

  const std::size_t entryBytes = entry.byteSize;
  if (entryBytes > maxEntryBytesFor(entry.dataMimeType)) {
    return;
  }

  m_history.insert(m_history.begin() + static_cast<std::ptrdiff_t>(insertAt), std::move(entry));
  m_historyBytes += entryBytes;
  trimHistoryToBudget();

  ++m_changeSerial;
  // Trimming can take the entry that was just inserted straight back out, when
  // that entry alone does not fit the budget, so the slot cannot be indexed
  // unconditionally afterwards.
  const bool entryKept = insertAt < m_history.size();
  const bool persisted = persistHistory();
  if (persisted && entryKept) {
    evictPayloadData(m_history[insertAt]);
  }
  const std::string latestMime =
      !entryKept || m_history[insertAt].mimeTypes.empty() ? "" : m_history[insertAt].mimeTypes.front();
  kLog.debug("clipboard history size={} entries={} latest_mime={}", m_historyBytes, m_history.size(), latestMime);
  notifyChanged();
}

bool ClipboardService::loadPersistedHistory() {
  namespace fs = std::filesystem;

  if (!m_dataKey.has_value()) {
    return false;
  }

  secureClipboardStorage(stateDirectory());

  std::error_code ec;
  if (fs::exists(manifestPath(), ec)) {
    return !ec && loadEncryptedHistory();
  }
  if (ec) {
    kLog.warn("failed to inspect encrypted clipboard manifest: {}", ec.message());
    return false;
  }
  if (fs::exists(legacyManifestPath(), ec)) {
    return !ec && migrateLegacyHistory();
  }
  if (ec) {
    kLog.warn("failed to inspect legacy clipboard manifest: {}", ec.message());
    return false;
  }
  return true;
}

bool ClipboardService::loadEncryptedHistory() {
  const auto result = security::readEncryptedFile(
      manifestPath(), *m_dataKey,
      security::EncryptionContext{.purpose = std::string(kManifestPurpose), .objectId = "index"}, kMaxManifestBytes
  );
  if (!result.succeeded()) {
    kLog.warn("failed to decrypt clipboard manifest (status={})", static_cast<int>(result.status));
    if (result.status != security::EncryptedReadStatus::IoError) {
      setPersistenceState(ClipboardPersistenceState::RecoveryRequired, m_persistenceMigrationPending);
    }
    return false;
  }

  std::deque<ClipboardEntry> entries;
  if (!parseManifest(result.plaintext, false, entries)) {
    kLog.warn("decrypted clipboard manifest is invalid");
    setPersistenceState(
        result.status == security::EncryptedReadStatus::IoError ? ClipboardPersistenceState::BackendError
                                                                : ClipboardPersistenceState::RecoveryRequired,
        m_persistenceMigrationPending
    );
    return false;
  }

  if (m_historyRetention) {
    mergePersistedHistory(std::move(entries));
    trimHistoryToBudget();
    ++m_changeSerial;
    notifyChanged();
  }

  if (!removeLegacyStorage()) {
    kLog.warn("encrypted clipboard history loaded, but legacy plaintext cleanup is incomplete");
  }
  kLog.info("loaded encrypted clipboard history");
  return true;
}

bool ClipboardService::migrateLegacyHistory() {
  std::ifstream file(legacyManifestPath(), std::ios::binary | std::ios::ate);
  if (!file.is_open()) {
    kLog.warn("failed to open legacy clipboard manifest");
    return false;
  }
  const auto manifestSize = file.tellg();
  if (manifestSize <= 0 || static_cast<std::uintmax_t>(manifestSize) > kMaxManifestBytes) {
    kLog.warn("legacy clipboard manifest has an invalid size");
    return false;
  }

  std::vector<std::uint8_t> contents(static_cast<std::size_t>(manifestSize));
  file.seekg(0);
  file.read(reinterpret_cast<char*>(contents.data()), manifestSize);
  if (!file.good()) {
    kLog.warn("failed to read legacy clipboard manifest");
    return false;
  }

  std::deque<ClipboardEntry> legacyEntries;
  if (!parseManifest(contents, true, legacyEntries)) {
    kLog.warn("legacy clipboard manifest is invalid");
    return false;
  }
  for (auto& entry : legacyEntries) {
    if (!loadLegacyEntryPayload(entry) || entry.data.size() != entry.byteSize) {
      kLog.warn("failed to read legacy clipboard payload {}", entry.storageId);
      return false;
    }
  }

  // Only the non-retention path replaces the live history, so only it needs the session entries kept
  // aside for the restores below.
  std::deque<ClipboardEntry> sessionEntries;
  m_historyBytes = 0;
  if (m_historyRetention) {
    for (const auto& entry : m_history) {
      m_historyBytes += entry.byteSize;
    }
    mergePersistedHistory(std::move(legacyEntries));
  } else {
    sessionEntries = std::move(m_history);
    m_history = std::move(legacyEntries);
    for (const auto& entry : m_history) {
      m_historyBytes += entry.byteSize;
    }
  }

  if (!persistHistory(true)) {
    if (!m_historyRetention) {
      m_history = std::move(sessionEntries);
      m_historyBytes = 0;
      for (const auto& entry : m_history) {
        m_historyBytes += entry.byteSize;
      }
    }
    return false;
  }

  if (!removeLegacyStorage()) {
    if (!m_historyRetention) {
      m_history = std::move(sessionEntries);
      m_historyBytes = 0;
      for (const auto& entry : m_history) {
        m_historyBytes += entry.byteSize;
      }
    }
    return true;
  }

  if (!m_historyRetention) {
    m_history = std::move(sessionEntries);
    m_historyBytes = 0;
    for (const auto& entry : m_history) {
      m_historyBytes += entry.byteSize;
    }
  } else {
    trimHistoryToBudget();
    ++m_changeSerial;
    notifyChanged();
  }
  kLog.info("migrated clipboard history to encrypted storage");
  return true;
}

bool ClipboardService::parseManifest(
    std::span<const std::uint8_t> contents, bool legacy, std::deque<ClipboardEntry>& entries
) const {
  try {
    const nlohmann::json json = nlohmann::json::parse(contents.begin(), contents.end());
    if (!json.is_object() || !json.contains("entries") || !json.at("entries").is_array()) {
      return false;
    }

    std::size_t totalBytes = 0;
    std::unordered_set<std::string> ids;
    for (const auto& item : json.at("entries")) {
      if (!item.is_object()) {
        return false;
      }

      ClipboardEntry entry;
      entry.storageId = item.at("id").get<std::string>();
      entry.mimeTypes = item.at("mime_types").get<std::vector<std::string>>();
      entry.dataMimeType = item.at("data_mime_type").get<std::string>();
      entry.textPreview = item.value("text_preview", "");
      entry.byteSize = item.at("byte_size").get<std::size_t>();
      entry.pinned = item.value("pinned", false);
      entry.payloadLoaded = false;

      const auto capturedAtMs = item.value("captured_at_ms", std::int64_t{0});
      entry.capturedAt = std::chrono::system_clock::time_point(std::chrono::milliseconds(capturedAtMs));

      if (!isValidStorageId(entry.storageId)
          || !ids.insert(entry.storageId).second
          || entry.dataMimeType.empty()
          || entry.byteSize == 0
          || entry.byteSize > maxEntryBytesFor(entry.dataMimeType)
          || totalBytes > std::numeric_limits<std::size_t>::max() - entry.byteSize) {
        return false;
      }
      totalBytes += entry.byteSize;
      entry.payloadPath = legacy ? legacyPayloadPathForId(entry.storageId) : payloadPathForId(entry.storageId);
      entries.push_back(std::move(entry));
    }

    std::ranges::stable_partition(entries, [](const ClipboardEntry& entry) { return entry.pinned; });
    return true;
  } catch (const std::exception&) {
    return false;
  }
}

void ClipboardService::mergePersistedHistory(std::deque<ClipboardEntry> entries) {
  std::unordered_set<std::string> activeIds;
  activeIds.reserve(m_history.size() + entries.size());
  for (const auto& entry : m_history) {
    activeIds.insert(entry.storageId);
  }

  for (auto& entry : entries) {
    if (!activeIds.insert(entry.storageId).second) {
      continue;
    }
    m_historyBytes += entry.byteSize;
    if (entry.pinned) {
      m_history.insert(m_history.begin() + static_cast<std::ptrdiff_t>(pinnedCount()), std::move(entry));
    } else {
      m_history.push_back(std::move(entry));
    }
  }
}

bool ClipboardService::removeLegacyStorage() {
  namespace fs = std::filesystem;

  std::error_code ec;
  const fs::path entriesDir(entriesDirectory());
  if (fs::exists(entriesDir, ec)) {
    for (const auto& entry : fs::directory_iterator(entriesDir, ec)) {
      if (entry.is_regular_file(ec) && entry.path().extension() == ".bin") {
        fs::remove(entry.path(), ec);
      }
      if (ec) {
        return false;
      }
    }
  }
  if (ec) {
    return false;
  }
  fs::remove(legacyManifestPath(), ec);
  return !ec;
}

bool ClipboardService::persistHistory(bool force) {
  if ((!m_historyRetention && !force) || !m_dataKey.has_value()) {
    return false;
  }

  namespace fs = std::filesystem;

  try {
    ensurePrivateDirectory(stateDirectory());
    ensurePrivateDirectory(entriesDirectory());

    nlohmann::json entries = nlohmann::json::array();
    std::unordered_set<std::string> activeStorageIds;
    activeStorageIds.reserve(m_history.size());

    for (auto& entry : m_history) {
      if (entry.storageId.empty()) {
        entry.storageId = generateStorageId();
      }
      if (!isValidStorageId(entry.storageId)) {
        throw std::runtime_error("invalid clipboard storage id");
      }
      entry.payloadPath = payloadPathForId(entry.storageId);

      activeStorageIds.insert(entry.storageId);
      if (entry.payloadLoaded && !entry.data.empty()) {
        if (!security::writeEncryptedFile(
                entry.payloadPath, entry.data, *m_dataKey,
                security::EncryptionContext{
                    .purpose = std::string(kPayloadPurpose),
                    .objectId = entry.storageId,
                }
            )) {
          throw std::runtime_error("failed to write clipboard payload");
        }
      }

      const auto capturedAtMs =
          std::chrono::duration_cast<std::chrono::milliseconds>(entry.capturedAt.time_since_epoch()).count();
      entries.push_back({
          {"id", entry.storageId},
          {"mime_types", entry.mimeTypes},
          {"data_mime_type", entry.dataMimeType},
          {"text_preview", entry.textPreview},
          {"byte_size", entry.byteSize},
          {"captured_at_ms", capturedAtMs},
          {"pinned", entry.pinned},
      });
    }

    const fs::path manifest(manifestPath());
    const std::string manifestContent = nlohmann::json{{"entries", entries}}.dump(2);
    const auto manifestBytes =
        std::span(reinterpret_cast<const std::uint8_t*>(manifestContent.data()), manifestContent.size());
    if (!security::writeEncryptedFile(
            manifest, manifestBytes, *m_dataKey,
            security::EncryptionContext{.purpose = std::string(kManifestPurpose), .objectId = "index"}
        )) {
      throw std::runtime_error("failed to write clipboard manifest");
    }
    m_storageKeyProvider.noteEncryptedDataExists();

    const fs::path entriesDir(entriesDirectory());
    if (fs::exists(entriesDir)) {
      for (const auto& dirEntry : fs::directory_iterator(entriesDir)) {
        if (dirEntry.is_regular_file()
            && dirEntry.path().extension() == ".enc"
            && !activeStorageIds.contains(dirEntry.path().stem().string())) {
          fs::remove(dirEntry.path());
        }
      }
    }
    return true;
  } catch (const std::exception& e) {
    kLog.warn("failed to persist clipboard history: {}", e.what());
    return false;
  }
}

void ClipboardService::trimHistoryToBudget() {
  // Pinned entries are exempt from the budget and do not count toward it.
  // Only the unpinned tail is trimmed; since the pinned block is contiguous at
  // the front, the back is always unpinned while any unpinned entry remains.
  std::size_t unpinnedCount = 0;
  std::size_t unpinnedBytes = 0;
  for (const auto& entry : m_history) {
    if (!entry.pinned) {
      ++unpinnedCount;
      unpinnedBytes += entry.byteSize;
    }
  }

  const auto eraseAt = [this, &unpinnedCount, &unpinnedBytes](std::size_t index) {
    const std::size_t removedBytes = m_history[index].byteSize;
    unpinnedBytes -= removedBytes;
    --unpinnedCount;
    m_historyBytes = m_historyBytes >= removedBytes ? m_historyBytes - removedBytes : 0;
    m_history.erase(m_history.begin() + static_cast<std::ptrdiff_t>(index));
  };

  // Oldest first, since entries are inserted at the front of the unpinned run.
  const auto oldestUnpinnedImage = [this]() -> std::optional<std::size_t> {
    for (std::size_t i = m_history.size(); i-- > 0;) {
      if (!m_history[i].pinned && m_history[i].isImage()) {
        return i;
      }
    }
    return std::nullopt;
  };

  while (!m_history.empty() && !m_history.back().pinned) {
    // Too many items is about how far back the history reaches, so it drops the
    // oldest whatever it is.
    if (unpinnedCount > m_maxHistoryEntries) {
      eraseAt(m_history.size() - 1);
      continue;
    }

    // Byte pressure is a different problem with a different cause: text entries
    // are kilobytes, so the budget is only ever exhausted by images. Dropping
    // the oldest image instead of the oldest entry keeps a screenshot from
    // evicting a day of copied snippets. Falls back to the oldest entry when no
    // unpinned image is left, so this always terminates.
    if (unpinnedBytes > m_maxHistoryBytes) {
      eraseAt(oldestUnpinnedImage().value_or(m_history.size() - 1));
      continue;
    }

    break;
  }
}

bool ClipboardService::loadEntryPayload(ClipboardEntry& entry) {
  if (entry.payloadLoaded) {
    return !entry.data.empty();
  }
  if (!m_dataKey.has_value() || !isValidStorageId(entry.storageId)) {
    return false;
  }
  entry.payloadPath = payloadPathForId(entry.storageId);

  auto result = security::readEncryptedFile(
      entry.payloadPath, *m_dataKey,
      security::EncryptionContext{
          .purpose = std::string(kPayloadPurpose),
          .objectId = entry.storageId,
      },
      maxEntryBytesFor(entry.dataMimeType)
  );
  if (!result.succeeded() || result.plaintext.empty() || result.plaintext.size() != entry.byteSize) {
    if (result.status != security::EncryptedReadStatus::NotFound) {
      kLog.warn("failed to decrypt clipboard payload {} (status={})", entry.storageId, static_cast<int>(result.status));
    }
    setPersistenceState(ClipboardPersistenceState::RecoveryRequired, m_persistenceMigrationPending);
    return false;
  }

  entry.data = std::move(result.plaintext);
  entry.payloadLoaded = true;
  if (entry.textPreview.empty() && isTextMimeType(entry.dataMimeType)) {
    entry.textPreview = buildTextPreview(entry.data);
  }
  return true;
}

bool ClipboardService::loadLegacyEntryPayload(ClipboardEntry& entry) {
  if (!isValidStorageId(entry.storageId)
      || entry.byteSize == 0
      || entry.byteSize > maxEntryBytesFor(entry.dataMimeType)) {
    return false;
  }
  entry.payloadPath = legacyPayloadPathForId(entry.storageId);

  std::ifstream file(entry.payloadPath, std::ios::binary | std::ios::ate);
  if (!file.is_open()) {
    return false;
  }
  const auto size = file.tellg();
  if (size <= 0
      || static_cast<std::uintmax_t>(size) > maxEntryBytesFor(entry.dataMimeType)
      || static_cast<std::size_t>(size) != entry.byteSize) {
    return false;
  }

  entry.data.resize(entry.byteSize);
  file.seekg(0);
  file.read(reinterpret_cast<char*>(entry.data.data()), size);
  if (!file.good()) {
    entry.data.clear();
    entry.data.shrink_to_fit();
    return false;
  }
  entry.payloadLoaded = true;
  if (entry.textPreview.empty() && isTextMimeType(entry.dataMimeType)) {
    entry.textPreview = buildTextPreview(entry.data);
  }
  return true;
}

void ClipboardService::evictPayloadData(ClipboardEntry& entry) {
  if (entry.data.empty() || entry.payloadPath.empty()) {
    return;
  }
  entry.data.clear();
  entry.data.shrink_to_fit();
  entry.payloadLoaded = false;
}

std::string ClipboardService::stateDirectory() {
  const std::string dir = FileUtils::stateDir();
  if (!dir.empty()) {
    return dir + "/clipboard";
  }
  return "/tmp/noctalia-clipboard";
}

std::string ClipboardService::manifestPath() { return stateDirectory() + "/index.enc"; }

std::string ClipboardService::legacyManifestPath() { return stateDirectory() + "/index.json"; }

std::string ClipboardService::entriesDirectory() { return stateDirectory() + "/entries"; }

std::string ClipboardService::payloadPathForId(std::string_view storageId) {
  return entriesDirectory() + "/" + std::string(storageId) + ".enc";
}

std::string ClipboardService::legacyPayloadPathForId(std::string_view storageId) {
  return entriesDirectory() + "/" + std::string(storageId) + ".bin";
}

bool ClipboardService::isValidStorageId(std::string_view storageId) {
  return !storageId.empty() && storageId.size() <= 128 && std::ranges::all_of(storageId, [](char ch) {
    return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_';
  });
}

std::string ClipboardService::generateStorageId() {
  const auto now =
      std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch())
          .count();
  return std::to_string(now) + "-" + std::to_string(++gStorageCounter);
}

std::string ClipboardService::chooseMimeType(const OfferState& offer) const {
  const auto choose = [&offer](const auto& preferred) -> std::string {
    for (std::string_view mimeType : preferred) {
      const auto it = std::ranges::find(offer.mimeTypes, mimeType);
      if (it != offer.mimeTypes.end()) {
        return *it;
      }
    }
    return {};
  };

  if (std::string mimeType = choose(kTextMimeTypes); !mimeType.empty()) {
    return mimeType;
  }
  return choose(kImageMimeTypes);
}

bool ClipboardService::isTextMimeType(std::string_view mimeType) {
  return std::ranges::contains(kTextMimeTypes, mimeType);
}

bool ClipboardService::isEmptyTextPayload(const std::vector<std::uint8_t>& data) {
  bool sawContent = false;
  for (std::uint8_t byte : data) {
    if (byte == 0) {
      continue;
    }
    if (byte == ' ' || byte == '\n' || byte == '\r' || byte == '\t' || byte == '\f' || byte == '\v') {
      continue;
    }
    sawContent = true;
    break;
  }
  return !sawContent;
}

bool ClipboardService::queueOutgoingWrite(void* source, int fd, std::shared_ptr<const std::vector<std::uint8_t>> data) {
  if (fd < 0 || data == nullptr || data->empty()) {
    return false;
  }

  const int flags = fcntl(fd, F_GETFL, 0);
  if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
    kLog.warn("failed to make clipboard send fd nonblocking");
    return false;
  }

  m_activeWrites.push_back(
      ActiveWrite{
          .fd = fd,
          .source = source,
          .data = std::move(data),
          .offset = 0,
      }
  );
  drainOutgoingWrite(m_activeWrites.size() - 1);
  return true;
}

void ClipboardService::dispatchWriteEvents(int fd, short revents) {
  if (fd < 0 || revents == 0) {
    return;
  }

  const auto findWrite = [this, fd]() { return std::ranges::find(m_activeWrites, fd, &ActiveWrite::fd); };

  auto it = findWrite();
  if (it == m_activeWrites.end()) {
    return;
  }

  if ((revents & POLLOUT) != 0) {
    drainOutgoingWrite(static_cast<std::size_t>(it - m_activeWrites.begin()));
    it = findWrite();
  }

  if (it != m_activeWrites.end() && (revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
    closeActiveWrite(static_cast<std::size_t>(it - m_activeWrites.begin()));
  }
}

void ClipboardService::drainOutgoingWrite(std::size_t index) {
  if (index >= m_activeWrites.size()) {
    return;
  }

  auto& writeState = m_activeWrites[index];
  if (writeState.fd < 0 || writeState.data == nullptr) {
    closeActiveWrite(index);
    return;
  }

  const auto& data = *writeState.data;
  while (writeState.offset < data.size()) {
    const auto* bytes = reinterpret_cast<const char*>(data.data() + writeState.offset);
    const std::size_t remaining = data.size() - writeState.offset;
    const ssize_t written = write(writeState.fd, bytes, remaining);
    if (written > 0) {
      writeState.offset += static_cast<std::size_t>(written);
      continue;
    }
    if (written < 0 && errno == EINTR) {
      continue;
    }
    if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
      return;
    }
    closeActiveWrite(index);
    return;
  }

  closeActiveWrite(index);
}

void ClipboardService::closeActiveWrite(std::size_t index) {
  if (index >= m_activeWrites.size()) {
    return;
  }
  closeFd(m_activeWrites[index].fd);
  if (index + 1 < m_activeWrites.size()) {
    m_activeWrites[index] = std::move(m_activeWrites.back());
  }
  m_activeWrites.pop_back();
}

std::string ClipboardService::buildTextPreview(const std::vector<std::uint8_t>& data) {
  const std::size_t limit = std::min<std::size_t>(data.size(), kPreviewBytes);
  // Forward-scan to find the last complete UTF-8 sequence within the byte limit.
  std::size_t safeSize = 0;
  std::size_t i = 0;
  while (i < limit) {
    const auto b = static_cast<uint8_t>(data[i]);
    int seqLen = 1;
    if ((b & 0x80) == 0)
      seqLen = 1;
    else if ((b & 0xE0) == 0xC0)
      seqLen = 2;
    else if ((b & 0xF0) == 0xE0)
      seqLen = 3;
    else if ((b & 0xF8) == 0xF0)
      seqLen = 4;
    if (i + static_cast<std::size_t>(seqLen) > limit)
      break;
    safeSize = i + seqLen;
    i += seqLen;
  }
  std::string preview(reinterpret_cast<const char*>(data.data()), safeSize);
  std::ranges::replace(preview, '\n', ' ');
  std::ranges::replace(preview, '\r', ' ');
  std::ranges::replace(preview, '\t', ' ');
  return preview;
}

void ClipboardService::notifyChanged() const {
  if (m_changeCallback) {
    m_changeCallback();
  }
}
