#include "macos-clipboard-server.hpp"

#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>

#include <qlogging.h>
#include <QBuffer>
#include <QClipboard>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
#include <QUrl>
#include <QVariant>

#include "services/clipboard/clipboard-mime.hpp"

namespace {
NSString *const CONCEALED_UTI = @"org.nspasteboard.ConcealedType";
NSString *const TRANSIENT_UTI = @"org.nspasteboard.TransientType";
NSString *const AUTO_GENERATED_UTI = @"org.nspasteboard.AutoGeneratedType";
NSString *const SOURCE_UTI = @"org.nspasteboard.source";

NSString *sourceBundleId(const Clipboard::CopyOptions &options) {
  return options.sourceApp && !options.sourceApp->isEmpty() ? options.sourceApp->toNSString()
                                                            : [[NSBundle mainBundle] bundleIdentifier];
}

NSString *utiForImageMime(const QString &mime) {
  if (mime == "image/png") return @"public.png";
  if (mime == "image/jpeg" || mime == "image/jpg") return @"public.jpeg";
  if (mime == "image/tiff") return @"public.tiff";
  if (mime == "image/gif") return @"com.compuserve.gif";
  if (mime == "image/bmp") return @"com.microsoft.bmp";
  if (mime == "image/svg+xml") return @"public.svg-image";
  return nil;
}

// Qt's UTI for unmapped mime types; the poll read-back relies on it to resolve the original mime
NSString *anymimeUti(const QString &mime) {
  QString mangled = mime;
  mangled.replace(QLatin1Char('/'), QLatin1String("--"));
  return (QLatin1String("com.trolltech.anymime.") + mangled).toNSString();
}

NSData *toNSData(const QByteArray &bytes) {
  return [NSData dataWithBytes:bytes.constData() length:static_cast<NSUInteger>(bytes.size())];
}
} // namespace

// lazy clipboard data resolution for heavy stuff like images or rich text
@interface VicinaeClipboardFlavorProvider : NSObject <NSPasteboardItemDataProvider>
- (instancetype)initWithMimeData:(QMimeData *)mime;
@end

@implementation VicinaeClipboardFlavorProvider {
  QMimeData *_mime;
  NSData *_imageBytesCache;
}

- (instancetype)initWithMimeData:(QMimeData *)mime {
  if ((self = [super init])) { _mime = mime; }
  return self;
}

- (void)dealloc {
  if (_mime) { _mime->deleteLater(); }
}

- (NSData *)imageBytes {
  if (_imageBytesCache) { return _imageBytesCache; }

  for (const QString &format : _mime->formats()) {
    if (format.startsWith("image/") && format != "image/svg+xml") {
      _imageBytesCache = toNSData(_mime->data(format));
      return _imageBytesCache;
    }
  }

  const auto image = qvariant_cast<QImage>(_mime->imageData());
  if (image.isNull()) { return nil; }

  QByteArray png;
  QBuffer buffer(&png);
  image.save(&buffer, "PNG");
  _imageBytesCache = toNSData(png);
  return _imageBytesCache;
}

- (void)pasteboardItem:(NSPasteboardItem *)item provideDataForType:(NSPasteboardType)type {
  if ([type isEqualToString:NSPasteboardTypeRTF] && _mime->hasHtml()) {
    NSString *html = _mime->html().toNSString();
    NSAttributedString *rich =
        [[NSAttributedString alloc] initWithData:[html dataUsingEncoding:NSUTF8StringEncoding]
                                         options:@{NSDocumentTypeDocumentAttribute : NSHTMLTextDocumentType}
                              documentAttributes:nil
                                           error:nil];
    NSData *rtf = [rich dataFromRange:NSMakeRange(0, rich.length)
                   documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFTextDocumentType}
                                error:nil];
    if (rtf) { [item setData:rtf forType:NSPasteboardTypeRTF]; }
  } else if ([type isEqualToString:NSPasteboardTypePNG]) {
    if (NSData *png = [self imageBytes]) { [item setData:png forType:NSPasteboardTypePNG]; }
  } else if ([type isEqualToString:NSPasteboardTypeTIFF]) {
    if (NSData *source = [self imageBytes]) {
      NSData *tiff = [[NSImage alloc] initWithData:source].TIFFRepresentation;
      if (tiff) { [item setData:tiff forType:NSPasteboardTypeTIFF]; }
    }
  }
}

@end

MacosClipboardServer::MacosClipboardServer() {
  m_timer.setInterval(POLL_INTERVAL_MS);
  m_timer.setTimerType(Qt::CoarseTimer);
  connect(&m_timer, &QTimer::timeout, this, &MacosClipboardServer::poll);
}

MacosClipboardServer::~MacosClipboardServer() { endAppNapActivity(); }

QString MacosClipboardServer::id() const { return "macos"; }

bool MacosClipboardServer::isActivatable() const { return QGuiApplication::platformName() == "cocoa"; }

bool MacosClipboardServer::isAlive() const { return m_timer.isActive(); }

bool MacosClipboardServer::start() {
  m_lastChangeCount = currentChangeCount();
  beginAppNapActivity();
  m_timer.start();
  return true;
}

bool MacosClipboardServer::stop() {
  m_timer.stop();
  endAppNapActivity();
  return true;
}

void MacosClipboardServer::beginAppNapActivity() {
  if (m_appNapActivity) return;
  // NSActivityUserInitiated would also prevent idle system sleep
  m_appNapActivity = (__bridge_retained void *)[[NSProcessInfo processInfo]
      beginActivityWithOptions:NSActivityUserInitiatedAllowingIdleSystemSleep
                        reason:@"Clipboard monitoring"];
}

void MacosClipboardServer::endAppNapActivity() {
  if (!m_appNapActivity) return;
  [[NSProcessInfo processInfo] endActivity:(__bridge_transfer NSObject *)m_appNapActivity];
  m_appNapActivity = nullptr;
}

long long MacosClipboardServer::currentChangeCount() const {
  return static_cast<long long>([[NSPasteboard generalPasteboard] changeCount]);
}

MacosClipboardServer::PasteboardInfo MacosClipboardServer::inspectPasteboard() const {
  @autoreleasepool {
    PasteboardInfo info;
    NSPasteboard *pb = [NSPasteboard generalPasteboard];

    for (NSPasteboardItem *item in pb.pasteboardItems) {
      for (NSString *type in item.types) {
        if ([type isEqualToString:CONCEALED_UTI] || [type isEqualToString:TRANSIENT_UTI] ||
            [type isEqualToString:AUTO_GENERATED_UTI]) {
          info.drop = true;
          return info;
        }
      }
      if (!info.sourceApp) {
        NSString *src = [item stringForType:SOURCE_UTI];
        if (src.length > 0) info.sourceApp = QString::fromNSString(src);
      }
    }
    return info;
  }
}

std::optional<QString> MacosClipboardServer::frontmostBundleId() const {
  @autoreleasepool {
    NSRunningApplication *front = [[NSWorkspace sharedWorkspace] frontmostApplication];
    if (!front || front.bundleIdentifier.length == 0) return std::nullopt;
    return QString::fromNSString(front.bundleIdentifier);
  }
}

void MacosClipboardServer::poll() {
  long long const cc = currentChangeCount();
  if (cc == m_lastChangeCount) return;
  m_lastChangeCount = cc;

  auto info = inspectPasteboard();
  if (info.drop) {
    qInfo() << "macOS: dropping concealed/transient/auto-generated selection";
    return;
  }

  auto selection = Clipboard::selectionFromMimeData(QGuiApplication::clipboard()->mimeData());

  if (!selection) return;

  // The pasteboard changed while we were reading it, let the next tick handle it
  if (currentChangeCount() != cc) return;

  selection->sourceApp = info.sourceApp ? info.sourceApp : frontmostBundleId();
  emit selectionAdded(*selection);
}

bool MacosClipboardServer::writeClipboard(QMimeData *data, const Clipboard::CopyOptions &options) {
  @autoreleasepool {
    NSPasteboardItem *item = [[NSPasteboardItem alloc] init];

    if (data->hasFormat("text/plain")) {
      [item setString:data->text().toNSString() forType:NSPasteboardTypeString];
    } else if (data->hasFormat("text/plain;charset=utf-8")) {
      [item setString:QString::fromUtf8(data->data("text/plain;charset=utf-8")).toNSString()
              forType:NSPasteboardTypeString];
    }
    if (data->hasHtml()) { [item setString:data->html().toNSString() forType:NSPasteboardTypeHTML]; }

    bool hasImageBytes = false;
    for (const QString &format : data->formats()) {
      if (format == "application/x-qt-image" || format.startsWith("text/plain") || format == "text/html" ||
          format == "text/uri-list") {
        continue;
      }

      NSData *payload = toNSData(data->data(format));
      if (format.startsWith("image/")) {
        if (NSString *uti = utiForImageMime(format)) { [item setData:payload forType:uti]; }
        if (format != "image/svg+xml") { hasImageBytes = true; }
      }
      [item setData:payload forType:anymimeUti(format)];
    }

    NSMutableArray<NSPasteboardType> *lazyTypes = [NSMutableArray array];
    if (data->hasHtml()) { [lazyTypes addObject:NSPasteboardTypeRTF]; }

    bool hasImageSource = hasImageBytes;
    if (!hasImageSource && data->hasImage() && !qvariant_cast<QImage>(data->imageData()).isNull()) {
      hasImageSource = true;
      [lazyTypes addObject:NSPasteboardTypePNG];
    }
    if (hasImageSource && ![item.types containsObject:NSPasteboardTypeTIFF]) {
      [lazyTypes addObject:NSPasteboardTypeTIFF];
    }

    if (options.concealed) { [item setString:@"" forType:CONCEALED_UTI]; }
    if (options.transient) { [item setString:@"" forType:TRANSIENT_UTI]; }
    if (NSString *source = sourceBundleId(options); source.length > 0) {
      [item setString:source forType:SOURCE_UTI];
    }

    NSMutableArray<NSPasteboardItem *> *objects = [NSMutableArray array];
    if (item.types.count > 0) { [objects addObject:item]; }

    // one pasteboard item per pasted "thing": the first URL rides on the primary item so a file
    // and its preview flavors don't paste as two separate entities
    bool firstUrl = true;
    for (const QUrl &url : data->urls()) {
      NSPasteboardType type = url.isLocalFile() ? NSPasteboardTypeFileURL : NSPasteboardTypeURL;
      NSString *value = url.toString(QUrl::FullyEncoded).toNSString();

      if (firstUrl && objects.count > 0) {
        [item setString:value forType:type];
      } else {
        NSPasteboardItem *urlItem = [[NSPasteboardItem alloc] init];
        [urlItem setString:value forType:type];
        [objects addObject:urlItem];
      }
      firstUrl = false;
    }

    if (lazyTypes.count > 0) {
      auto *provider = [[VicinaeClipboardFlavorProvider alloc] initWithMimeData:data];
      [item setDataProvider:provider forTypes:lazyTypes];
    } else {
      data->deleteLater();
    }

    if (objects.count == 0) { return false; }

    NSPasteboard *pb = [NSPasteboard generalPasteboard];
    [pb clearContents];
    return [pb writeObjects:objects] == YES;
  }
}
