// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/download/download_status_updater.h"

#import <Foundation/Foundation.h>

#include "base/apple/foundation_util.h"
#include "base/memory/raw_ptr_exclusion.h"
#include "base/supports_user_data.h"
#include "base/time/time.h"
#import "chrome/browser/ui/cocoa/dock_icon.h"
#include "components/download/public/common/download_item.h"
#import "net/base/apple/url_conversions.h"

@interface CrDownloadCanceller : NSObject
- (instancetype)initWithDownloadItem:(download::DownloadItem*)download;
- (void)cancel;
- (void)invalidate;
@end

@implementation CrDownloadCanceller {
  // `_download` is owned by the DownloadManager. It must not be captured by
  // the NSProgress cancellationHandler, whose lifetime is controlled by the
  // system. CrNSProgressUserData invalidates this pointer before the owning
  // DownloadItem is destroyed.
  RAW_PTR_EXCLUSION download::DownloadItem* _download;
}

- (instancetype)initWithDownloadItem:(download::DownloadItem*)download {
  self = [super init];
  if (self) {
    _download = download;
  }
  return self;
}

- (void)cancel {
  if (_download) {
    _download->Cancel(/*user_cancel=*/true);
  }
}

- (void)invalidate {
  _download = nullptr;
}

@end

namespace {

const char kCrNSProgressUserDataKey[] = "CrNSProgressUserData";

class CrNSProgressUserData : public base::SupportsUserData::Data {
 public:
  CrNSProgressUserData(NSProgress* progress,
                       CrDownloadCanceller* canceller,
                       const base::FilePath& target)
      : target_(target) {
    progress_ = progress;
    canceller_ = canceller;
  }
  ~CrNSProgressUserData() override {
    [canceller_ invalidate];
    // Clear the handler to eagerly release the block. The __weak reference
    // in the block already guarantees safety if the block outlives us.
    progress_.cancellationHandler = nil;
    [progress_ unpublish];
  }

  NSProgress* progress() const { return progress_; }
  base::FilePath target() const { return target_; }
  void setTarget(const base::FilePath& target) { target_ = target; }

 private:
  CrDownloadCanceller* __strong canceller_;
  NSProgress* __strong progress_;
  base::FilePath target_;
};

void UpdateAppDockIcon(int download_count,
                       bool progress_known,
                       float progress) {
  DockIcon* dock_icon = [DockIcon sharedDockIcon];
  [dock_icon setDownloads:download_count];
  [dock_icon setIndeterminate:!progress_known];
  [dock_icon setProgress:progress];
  [dock_icon updateIcon];
}

CrNSProgressUserData* CreateOrGetNSProgress(download::DownloadItem* download) {
  CrNSProgressUserData* progress_data = static_cast<CrNSProgressUserData*>(
      download->GetUserData(&kCrNSProgressUserDataKey));
  if (progress_data)
    return progress_data;

  base::FilePath destination_path = download->GetFullPath();
  NSURL* destination_url = base::apple::FilePathToNSURL(destination_path);

  NSProgress* progress = [NSProgress progressWithTotalUnitCount:-1];
  progress.kind = NSProgressKindFile;
  progress.fileOperationKind = NSProgressFileOperationKindDownloading;
  progress.fileURL = destination_url;

  // Don't publish a pause/resume handler. The only users of `NSProgress` are
  // outside of Chromium, and none currently implement pausing published
  // progresses. Because there is no way to test pausing, do not implement or
  // ship it.
  progress.pausable = NO;

  CrDownloadCanceller* __strong canceller =
      [[CrDownloadCanceller alloc] initWithDownloadItem:download];

  // Do publish a cancellation handler. In icon view, the Finder provides a
  // little (X) button on the icon, and using it will cause this callback.
  // Only capture the ObjC canceller weakly because NSProgress may be held by
  // the system after DownloadItem is destroyed.
  __weak CrDownloadCanceller* weak_canceller = canceller;
  progress.cancellable = YES;
  progress.cancellationHandler = ^{
    dispatch_async(dispatch_get_main_queue(), ^{
      [weak_canceller cancel];
    });
  };

  [progress publish];

  download->SetUserData(&kCrNSProgressUserDataKey,
                        std::make_unique<CrNSProgressUserData>(
                            progress, canceller, destination_path));

  return static_cast<CrNSProgressUserData*>(
      download->GetUserData(&kCrNSProgressUserDataKey));
}

void UpdateNSProgress(download::DownloadItem* download) {
  CrNSProgressUserData* progress_data = CreateOrGetNSProgress(download);

  NSProgress* progress = progress_data->progress();
  progress.totalUnitCount = download->GetTotalBytes();
  progress.completedUnitCount = download->GetReceivedBytes();
  progress.throughput = @(download->CurrentSpeed());

  base::TimeDelta time_remaining;
  NSNumber* ns_time_remaining = nil;
  if (download->TimeRemaining(&time_remaining))
    ns_time_remaining = @(time_remaining.InSeconds());
  progress.estimatedTimeRemaining = ns_time_remaining;

  base::FilePath download_path = download->GetFullPath();
  if (progress_data->target() != download_path) {
    progress_data->setTarget(download_path);
    NSURL* download_url = base::apple::FilePathToNSURL(download_path);
    progress.fileURL = download_url;
  }
}

void DestroyNSProgress(download::DownloadItem* download) {
  download->RemoveUserData(&kCrNSProgressUserDataKey);
}

}  // namespace

void DownloadStatusUpdater::UpdateAppIconDownloadProgress(
    download::DownloadItem* download) {
  // Always update overall progress in the Dock icon.

  float progress = 0;
  int download_count = 0;
  bool progress_known = GetProgress(&progress, &download_count);
  UpdateAppDockIcon(download_count, progress_known, progress);

  // Update `NSProgress`-based indicators. Only show progress:
  //   - if the download is IN_PROGRESS, and
  //   - it has not yet saved all the data, and
  //   - it hasn't been renamed to its final name.
  //
  // There's a race condition in macOS code where unpublishing an `NSProgress`
  // object for a file that was renamed will sometimes leave a progress
  // indicator visible in the Finder (https://crbug.com/40217637). Therefore, as
  // soon as `DownloadItem::AllDataSaved()` returns true, do the unpublish.
  // As an additional bug to avoid (http://crbug.com/40297082), never update the
  // data of an `NSProgress` after the file name has changed, as that can result
  // in the file being stuck in an in-progress state in the Dock.
  if (download->GetState() == download::DownloadItem::IN_PROGRESS &&
      !download->AllDataSaved() && !download->GetFullPath().empty() &&
      download->GetFullPath() != download->GetTargetFilePath()) {
    UpdateNSProgress(download);
  } else {
    DestroyNSProgress(download);
  }

  // Handle downloads that ended.
  if (download->GetState() != download::DownloadItem::IN_PROGRESS &&
      !download->GetTargetFilePath().empty()) {
    NSString* download_path =
        base::apple::FilePathToNSString(download->GetTargetFilePath());
    if (download->GetState() == download::DownloadItem::COMPLETE) {
      // Bounce the dock icon.
      [NSDistributedNotificationCenter.defaultCenter
          postNotificationName:@"com.apple.DownloadFileFinished"
                        object:download_path];
    }

    // Notify the Finder.
    [NSWorkspace.sharedWorkspace noteFileSystemChanged:download_path];
  }
}
