/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=8 sts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

#include "mozilla/widget/filedialog/WinFileDialogCommands.h"

#include <type_traits>
#include <shobjidl.h>
#include <shtypes.h>
#include <winerror.h>
#include "WinUtils.h"
#include "mozilla/Logging.h"
#include "mozilla/RefPtr.h"
#include "mozilla/WinHeaderOnlyUtils.h"
#include "mozilla/ipc/LaunchError.h"
#include "mozilla/ipc/ProtocolUtils.h"
#include "mozilla/ipc/UtilityProcessManager.h"
#include "mozilla/mscom/ApartmentRegion.h"
#include "nsThreadUtils.h"

namespace mozilla::widget::filedialog {

const char* Error::KindName(Error::Kind kind) {
  switch (kind) {
    case LocalError:
      return "LocalError";
    case RemoteError:
      return "RemoteError";
    case IPCError:
      return "IPCError";
    default:
      MOZ_ASSERT(false);
      return "<bad value>";
  }
}

// Location from LaunchError. "text" is not guaranteed to be in VALID_STRINGS.
// There is no significance to the value of `value`, except that it's not less
// than Location::VALID_STRINGS_COUNT.
constexpr Error::Location::Location(mozilla::ipc::LaunchError const& err)
    : text(err.FunctionName()), value(0x867'5309) {}

/*static*/ Error Error::From(const mozilla::ipc::LaunchError& err) {
  return Error{.kind = Kind::LocalError,
               .where = Location(err),
               .why = static_cast<uint32_t>(err.ErrorCode())};
}

// Visitor to apply commands to the dialog.
struct Applicator {
  IFileDialog* dialog = nullptr;

  HRESULT Visit(Command const& c) {
    switch (c.type()) {
      default:
      case Command::T__None:
        return E_INVALIDARG;

      case Command::TSetOptions:
        return Apply(c.get_SetOptions());
      case Command::TSetTitle:
        return Apply(c.get_SetTitle());
      case Command::TSetOkButtonLabel:
        return Apply(c.get_SetOkButtonLabel());
      case Command::TSetFolder:
        return Apply(c.get_SetFolder());
      case Command::TSetFileName:
        return Apply(c.get_SetFileName());
      case Command::TSetDefaultExtension:
        return Apply(c.get_SetDefaultExtension());
      case Command::TSetFileTypes:
        return Apply(c.get_SetFileTypes());
      case Command::TSetFileTypeIndex:
        return Apply(c.get_SetFileTypeIndex());
    }
  }

  HRESULT Apply(SetOptions const& c) { return dialog->SetOptions(c.options()); }
  HRESULT Apply(SetTitle const& c) { return dialog->SetTitle(c.title().get()); }
  HRESULT Apply(SetOkButtonLabel const& c) {
    return dialog->SetOkButtonLabel(c.label().get());
  }
  HRESULT Apply(SetFolder const& c) {
    RefPtr<IShellItem> folder;
    if (SUCCEEDED(SHCreateItemFromParsingName(
            c.path().get(), nullptr, IID_IShellItem, getter_AddRefs(folder)))) {
      return dialog->SetFolder(folder);
    }
    // graciously accept that the provided path may have been nonsense
    return S_OK;
  }
  HRESULT Apply(SetFileName const& c) {
    return dialog->SetFileName(c.filename().get());
  }
  HRESULT Apply(SetDefaultExtension const& c) {
    return dialog->SetDefaultExtension(c.extension().get());
  }
  HRESULT Apply(SetFileTypes const& c) {
    std::vector<COMDLG_FILTERSPEC> vec;
    for (auto const& filter : c.filterList()) {
      vec.push_back(
          {.pszName = filter.name().get(), .pszSpec = filter.spec().get()});
    }
    return dialog->SetFileTypes(vec.size(), vec.data());
  }
  HRESULT Apply(SetFileTypeIndex const& c) {
    return dialog->SetFileTypeIndex(c.index());
  }
};

namespace {
static HRESULT GetShellItemPath(IShellItem* aItem, nsString& aResultString) {
  NS_ENSURE_TRUE(aItem, E_INVALIDARG);

  mozilla::UniquePtr<wchar_t, CoTaskMemFreeDeleter> str;
  HRESULT const hr =
      aItem->GetDisplayName(SIGDN_FILESYSPATH, getter_Transfers(str));
  if (SUCCEEDED(hr)) {
    aResultString.Assign(str.get());
  }
  return hr;
}
}  // namespace

#define MOZ_ENSURE_HRESULT_OK(where, call_)                     \
  do {                                                          \
    HRESULT const _tmp_hr_ = (call_);                           \
    if (FAILED(_tmp_hr_)) {                                     \
      return mozilla::Err(MOZ_FD_LOCAL_ERROR(where, _tmp_hr_)); \
    }                                                           \
  } while (0)

mozilla::Result<RefPtr<IFileDialog>, Error> MakeFileDialog(
    FileDialogType type) {
  RefPtr<IFileDialog> dialog;

  CLSID const clsid = type == FileDialogType::Open ? CLSID_FileOpenDialog
                                                   : CLSID_FileSaveDialog;
  HRESULT const hr = CoCreateInstance(clsid, nullptr, CLSCTX_INPROC_SERVER,
                                      IID_IFileDialog, getter_AddRefs(dialog));
  // more properly: "CoCreateInstance(CLSID_...)", but this suffices
  MOZ_ENSURE_HRESULT_OK("MakeFileDialog", hr);

  return std::move(dialog);
}

mozilla::Result<Ok, Error> ApplyCommands(::IFileDialog* dialog,
                                         nsTArray<Command> const& commands) {
  Applicator applicator{.dialog = dialog};
  for (auto const& cmd : commands) {
    HRESULT const hr = applicator.Visit(cmd);
    MOZ_ENSURE_HRESULT_OK("ApplyCommands", hr);
  }
  return Ok{};
}

mozilla::Result<Results, Error> GetFileResults(::IFileDialog* dialog) {
  FILEOPENDIALOGOPTIONS fos;
  MOZ_ENSURE_HRESULT_OK("IFileDialog::GetOptions", dialog->GetOptions(&fos));

  using widget::WinUtils;

  // Extract which filter type the user selected
  UINT index;
  MOZ_ENSURE_HRESULT_OK("IFileDialog::GetFileTypeIndex",
                        dialog->GetFileTypeIndex(&index));

  // single selection
  if ((fos & FOS_ALLOWMULTISELECT) == 0) {
    RefPtr<IShellItem> item;
    MOZ_ENSURE_HRESULT_OK("IFileDialog::GetResult",
                          dialog->GetResult(getter_AddRefs(item)));
    if (!item) {
      return Err(MOZ_FD_LOCAL_ERROR("IFileDialog::GetResult: item", E_POINTER));
    }

    nsAutoString path;
    MOZ_ENSURE_HRESULT_OK("GetFileResults: GetShellItemPath (1)",
                          GetShellItemPath(item, path));

    return Results({path}, index);
  }

  // multiple selection
  RefPtr<IFileOpenDialog> openDlg;
  dialog->QueryInterface(IID_IFileOpenDialog, getter_AddRefs(openDlg));
  if (!openDlg) {
    MOZ_ASSERT(false, "a file-save dialog was given FOS_ALLOWMULTISELECT?");
    return Err(MOZ_FD_LOCAL_ERROR("Save + FOS_ALLOWMULTISELECT", E_UNEXPECTED));
  }

  RefPtr<IShellItemArray> items;
  MOZ_ENSURE_HRESULT_OK("IFileOpenDialog::GetResults",
                        openDlg->GetResults(getter_AddRefs(items)));
  if (!items) {
    return Err(
        MOZ_FD_LOCAL_ERROR("IFileOpenDialog::GetResults: items", E_POINTER));
  }

  nsTArray<nsString> paths;

  DWORD count = 0;
  MOZ_ENSURE_HRESULT_OK("IShellItemArray::GetCount", items->GetCount(&count));
  for (DWORD idx = 0; idx < count; idx++) {
    RefPtr<IShellItem> item;
    MOZ_ENSURE_HRESULT_OK("IShellItemArray::GetItemAt",
                          items->GetItemAt(idx, getter_AddRefs(item)));

    nsAutoString str;
    MOZ_ENSURE_HRESULT_OK("GetFileResults: GetShellItemPath (2)",
                          GetShellItemPath(item, str));

    paths.EmplaceBack(str);
  }

  return Results(std::move(paths), std::move(index));
}

mozilla::Result<nsString, Error> GetFolderResults(::IFileDialog* dialog) {
  RefPtr<IShellItem> item;
  MOZ_ENSURE_HRESULT_OK("IFileDialog::GetResult",
                        dialog->GetResult(getter_AddRefs(item)));

  if (!item) {
    // shouldn't happen -- probably a precondition failure on our part, but
    // might be due to misbehaving shell extensions?
    MOZ_ASSERT(false,
               "unexpected lack of item: was `Show`'s return value checked?");
    return Err(MOZ_FD_LOCAL_ERROR("IFileDialog::GetResult: item", E_POINTER));
  }

  // If the user chose a Win7 Library, resolve to the library's
  // default save folder.
  RefPtr<IShellLibrary> shellLib;
  RefPtr<IShellItem> folderPath;
  MOZ_ENSURE_HRESULT_OK(
      "CoCreateInstance(CLSID_ShellLibrary)",
      CoCreateInstance(CLSID_ShellLibrary, nullptr, CLSCTX_INPROC_SERVER,
                       IID_IShellLibrary, getter_AddRefs(shellLib)));

  if (shellLib && SUCCEEDED(shellLib->LoadLibraryFromItem(item, STGM_READ)) &&
      SUCCEEDED(shellLib->GetDefaultSaveFolder(DSFT_DETECT, IID_IShellItem,
                                               getter_AddRefs(folderPath)))) {
    item.swap(folderPath);
  }

  // get the folder's file system path
  nsAutoString str;
  MOZ_ENSURE_HRESULT_OK("GetShellItemPath", GetShellItemPath(item, str));
  return str;
}

#undef MOZ_ENSURE_HRESULT_OK

namespace detail {
void LogProcessingError(LogModule* aModule, ipc::IProtocol* aCaller,
                        ipc::HasResultCodes::Result aCode,
                        const char* aReason) {
  LogLevel const level = [&]() {
    switch (aCode) {
      case ipc::HasResultCodes::MsgProcessed:
        // Normal operation. (We probably never actually get this code.)
        return LogLevel::Verbose;

      case ipc::HasResultCodes::MsgDropped:
        return LogLevel::Verbose;

      default:
        return LogLevel::Error;
    }
  }();

  // Processing errors are sometimes unhelpfully formatted. We can't fix that
  // directly because the unhelpful formatting has made its way to telemetry
  // (table `telemetry.socorro_crash`, column `ipc_channel_error`) and is being
  // aggregated on. :(
  nsCString reason(aReason);
  if (reason.Last() == '\n') {
    reason.Truncate(reason.Length() - 1);
  }

  if (MOZ_LOG_TEST(aModule, level)) {
    const char* const side = [&]() {
      switch (aCaller->GetSide()) {
        case ipc::ParentSide:
          return "parent";
        case ipc::ChildSide:
          return "child";
        case ipc::UnknownSide:
          return "unknown side";
        default:
          return "<illegal value>";
      }
    }();

    const char* const errorStr = [&]() {
      switch (aCode) {
        case ipc::HasResultCodes::MsgProcessed:
          return "Processed";
        case ipc::HasResultCodes::MsgDropped:
          return "Dropped";
        case ipc::HasResultCodes::MsgNotKnown:
          return "NotKnown";
        case ipc::HasResultCodes::MsgNotAllowed:
          return "NotAllowed";
        case ipc::HasResultCodes::MsgPayloadError:
          return "PayloadError";
        case ipc::HasResultCodes::MsgProcessingError:
          return "ProcessingError";
        case ipc::HasResultCodes::MsgValueError:
          return "ValueError";
        default:
          return "<illegal error type>";
      }
    }();

    MOZ_LOG(aModule, level,
            ("%s [%s]: IPC error (%s): %s", aCaller->GetProtocolName(), side,
             errorStr, reason.get()));
  }

  if (level == LogLevel::Error) {
    // kill the child process...
    if (aCaller->GetSide() == ipc::ParentSide) {
      // ... which isn't us
      ipc::UtilityProcessManager::GetSingleton()->CleanShutdown(
          ipc::SandboxingKind::WINDOWS_FILE_DIALOG);
    } else {
      // ... which (presumably) is us
      CrashReporter::AutoRecordAnnotation(
          CrashReporter::Annotation::ipc_channel_error, reason);

      MOZ_CRASH("IPC error");
    }
  }
}

// Given a (synchronous) Action returning a Result<T, HRESULT>, perform that
// action on a new single-purpose "File Dialog" thread, with COM initialized as
// STA. (The thread will be destroyed afterwards.)
//
// Returns a Promise which will resolve to T (if the action returns Ok) or
// reject with an HRESULT (if the action either returns Err or couldn't be
// performed).
template <typename Res, typename Action, size_t N>
RefPtr<Promise<Res>> SpawnFileDialogThread(const char (&where)[N],
                                           Action action) {
  {
    using ActionRetT = std::invoke_result_t<Action>;
    using Info = detail::DestructureResult<ActionRetT>;

    MOZ_ASSERT_SAME_TYPE(
        typename Info::ErrorT, Error,
        "supplied Action must return Result<T, filedialog::Err>");
  }

  RefPtr<nsIThread> thread;
  {
    nsresult rv = NS_NewNamedThread("File Dialog", getter_AddRefs(thread),
                                    nullptr, {.isUiThread = true});
    if (NS_FAILED(rv)) {
      return Promise<Res>::CreateAndReject(
          MOZ_FD_LOCAL_ERROR("NS_NewNamedThread", (HRESULT)rv), where);
    }
  }
  // `thread` is single-purpose, and should not perform any additional work
  // after `action`. Shut it down after we've dispatched that.
  auto close_thread_ = MakeScopeExit([&]() {
    auto const res = thread->AsyncShutdown();
    static_assert(
        std::is_same_v<uint32_t, std::underlying_type_t<decltype(res)>>);
    if (NS_FAILED(res)) {
      MOZ_LOG(sLogFileDialog, LogLevel::Warning,
              ("thread->AsyncShutdown() failed: res=0x%08" PRIX32,
               static_cast<uint32_t>(res)));
    }
  });

  // our eventual return value
  RefPtr promise = MakeRefPtr<typename Promise<Res>::Private>(where);

  // alias to reduce indentation depth
  auto const dispatch = [&](auto closure) {
    return thread->DispatchToQueue(
        NS_NewRunnableFunction(where, std::move(closure)),
        mozilla::EventQueuePriority::Normal);
  };

  dispatch([thread, promise, where, action = std::move(action)]() {
    // Like essentially all COM UI components, the file dialog is STA: it must
    // be associated with a specific thread to create its HWNDs and receive
    // messages for them. If it's launched from a thread in the multithreaded
    // apartment (including via implicit MTA), COM will proxy out to the
    // process's main STA thread, and the file-dialog's modal loop will run
    // there.
    //
    // This of course would completely negate any point in using a separate
    // thread, since behind the scenes the dialog would still be running on the
    // process's main thread. In particular, under that arrangement, file
    // dialogs (and other nested modal loops, like those performed by
    // `SpinEventLoopUntil`) will resolve in strictly LIFO order, effectively
    // remaining suspended until all later modal loops resolve.
    //
    // To avoid this, we initialize COM as STA, so that it (rather than the main
    // STA thread) is the file dialog's "home" thread and the IFileDialog's home
    // apartment.

    mozilla::mscom::STARegion staRegion;
    if (!staRegion) {
      MOZ_LOG(sLogFileDialog, LogLevel::Error,
              ("COM init failed on file dialog thread: hr = %08lx",
               staRegion.GetHResult()));

      APTTYPE at;
      APTTYPEQUALIFIER atq;
      HRESULT const hr = ::CoGetApartmentType(&at, &atq);
      MOZ_LOG(sLogFileDialog, LogLevel::Error,
              ("  current COM apartment state: hr = %08lX, APTTYPE = "
               "%08X, APTTYPEQUALIFIER = %08X",
               hr, at, atq));

      // If this happens in the utility process, crash so we learn about it.
      // (TODO: replace this with a telemetry ping.)
      if (!XRE_IsParentProcess()) {
        // Preserve relevant data on the stack for later analysis.
        std::tuple volatile info{staRegion.GetHResult(), hr, at, atq};
        MOZ_CRASH("Could not initialize COM STA in utility process");
      }

      // If this happens in the parent process, don't crash; just fall back to a
      // nested modal loop. This isn't ideal, but it will probably still work
      // well enough for the common case, wherein no other modal loops are
      // active.
      //
      // (TODO: replace this with a telemetry ping, too.)
    }

    // Actually invoke the action and report the result.
    Result<Res, Error> val = action();
    if (val.isErr()) {
      promise->Reject(val.unwrapErr(), where);
    } else {
      promise->Resolve(val.unwrap(), where);
    }
  });

  return promise;
}

// For F returning `Result<T, E>`, yields the type `T`.
template <typename F, typename... Args>
using inner_result_of =
    typename detail::DestructureResult<std::invoke_result_t<F, Args...>>::OkT;

template <typename ExtractorF,
          typename RetT = inner_result_of<ExtractorF, IFileDialog*>>
auto SpawnPickerT(HWND parent, FileDialogType type, ExtractorF&& extractor,
                  nsTArray<Command> commands) -> RefPtr<Promise<Maybe<RetT>>> {
  using ActionRetT = Result<Maybe<RetT>, Error>;

  return detail::SpawnFileDialogThread<Maybe<RetT>>(
      __PRETTY_FUNCTION__, [=, commands = std::move(commands)]() -> ActionRetT {
        // On Win10, the picker doesn't support per-monitor DPI, so we create it
        // with our context set temporarily to system-dpi-aware.
        WinUtils::AutoSystemDpiAware dpiAwareness;

        RefPtr<IFileDialog> dialog = MOZ_TRY(MakeFileDialog(type));

        MOZ_TRY(ApplyCommands(dialog, commands));

        if (HRESULT const rv = dialog->Show(parent); FAILED(rv)) {
          if (rv == HRESULT_FROM_WIN32(ERROR_CANCELLED)) {
            return ActionRetT{Nothing()};
          }
          return mozilla::Err(MOZ_FD_LOCAL_ERROR("IFileDialog::Show", rv));
        }

        RetT res = MOZ_TRY(extractor(dialog.get()));

        return Some(res);
      });
}

}  // namespace detail

RefPtr<Promise<Maybe<Results>>> SpawnFilePicker(HWND parent,
                                                FileDialogType type,
                                                nsTArray<Command> commands) {
  return detail::SpawnPickerT(parent, type, GetFileResults,
                              std::move(commands));
}

RefPtr<Promise<Maybe<nsString>>> SpawnFolderPicker(HWND parent,
                                                   nsTArray<Command> commands) {
  return detail::SpawnPickerT(parent, FileDialogType::Open, GetFolderResults,
                              std::move(commands));
}

}  // namespace mozilla::widget::filedialog
