#include "util/sandbox.h"

#include <QFileDialog>
#include <QFileInfo>
#include <QObject>
#include <QtDebug>
#include <QtGlobal>

#include "util/mac.h"

#ifdef __APPLE__
#include <CoreFoundation/CoreFoundation.h>
#include <CoreServices/CoreServices.h>
#endif

#ifdef Q_OS_MACOS
#include <Security/SecCode.h>
#include <Security/SecRequirement.h>
#endif

const bool sDebug = false;

#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
QRecursiveMutex Sandbox::s_mutex;
#else
QMutex Sandbox::s_mutex(QMutex::Recursive);
#endif
bool Sandbox::s_bInSandbox = false;
QSharedPointer<ConfigObject<ConfigValue>> Sandbox::s_pSandboxPermissions;
QHash<QString, SecurityTokenWeakPointer> Sandbox::s_activeTokens;

// static
void Sandbox::checkSandboxed() {
#ifdef Q_OS_IOS
    // iOS apps are always sandboxed
    s_bInSandbox = true;
#elif defined(Q_OS_MACOS)
    SecCodeRef secCodeSelf;
    if (SecCodeCopySelf(kSecCSDefaultFlags, &secCodeSelf) == errSecSuccess) {
        SecRequirementRef sandboxReq;
        CFStringRef entitlement = CFSTR("entitlement [\"com.apple.security.app-sandbox\"]");
        if (SecRequirementCreateWithString(entitlement, kSecCSDefaultFlags,
                                           &sandboxReq) == errSecSuccess) {
            if (SecCodeCheckValidity(secCodeSelf, kSecCSDefaultFlags,
                                     sandboxReq) == errSecSuccess) {
                s_bInSandbox = true;
            }
            CFRelease(sandboxReq);
        }
        CFRelease(secCodeSelf);
    }
#endif
}

void Sandbox::setPermissionsFilePath(const QString& permissionsFile) {
    const auto locker = lockMutex(&s_mutex);
    s_pSandboxPermissions = QSharedPointer<ConfigObject<ConfigValue>>(
            new ConfigObject<ConfigValue>(permissionsFile));
}

// static
void Sandbox::shutdown() {
    const auto locker = lockMutex(&s_mutex);
    QSharedPointer<ConfigObject<ConfigValue>> pSandboxPermissions = s_pSandboxPermissions;
    s_pSandboxPermissions.clear();
    if (pSandboxPermissions) {
        pSandboxPermissions->save();
    }
}

//static
bool Sandbox::createSecurityToken(mixxx::FileInfo* pFileInfo) {
    VERIFY_OR_DEBUG_ASSERT(pFileInfo) {
        return false;
    }
    const auto canonicalLocation = pFileInfo->resolveCanonicalLocation();
    return createSecurityToken(canonicalLocation, pFileInfo->isDir());
}

//static
bool Sandbox::canAccess(mixxx::FileInfo* pFileInfo) {
    VERIFY_OR_DEBUG_ASSERT(pFileInfo) {
        return false;
    }
    // NOTE: The token must be assigned to a variable, otherwise it will be
    // invalidated immediately (causing `isReadable` to fail).
    auto token = openSecurityToken(pFileInfo, true);
    return pFileInfo->isReadable();
}

//static
bool Sandbox::canAccessDir(const QDir& dir) {
    // NOTE: The token must be assigned to a variable, otherwise it will be
    // invalidated immediately (causing `isReadable` to fail).
    auto token = openSecurityTokenForDir(dir, true);
    return QFileInfo(dir.canonicalPath()).isReadable();
}

// static
bool Sandbox::askForAccess(mixxx::FileInfo* pFileInfo) {
    // We always want read/write access because we wouldn't want to have to
    // re-ask for access in the future if we need to write.
    if (sDebug) {
        qDebug() << "Sandbox: Requesting user access to" << pFileInfo;
    }
    if (!enabled()) {
        // Pretend we have access.
        return true;
    }

    if (!pFileInfo->exists()) {
        // We cannot grant access to a not existing file
        return false;
    }

    if (canAccess(pFileInfo)) {
        return true;
    }

    const QString location = pFileInfo->location();
    const QString fileName = pFileInfo->fileName();
    const QString title = QObject::tr("Mixxx Needs Access to: %1").arg(fileName);
    QMessageBox::information(nullptr,
            title,
            QObject::tr(
                    "Your permission is required to access "
                    "the following location:"
                    "\n\n%1\n\n"
                    "After clicking OK, you will see a file picker. "
                    "Please select '%2' to proceed or click Cancel if "
                    "you don't want to grant Mixxx access and abort "
                    "this action.")
                    .arg(location, fileName));

    mixxx::FileInfo resultInfo;
    while (true) {
        QString result;
        if (pFileInfo->isFile()) {
            result = QFileDialog::getOpenFileName(nullptr, title, location);
        } else if (pFileInfo->isDir()) {
            result = QFileDialog::getExistingDirectory(nullptr, title, location);
        }

        if (result.isNull()) {
            if (sDebug) {
                qDebug() << "Sandbox: User rejected access to" << location;
            }
            return false;
        }

        if (sDebug) {
            qDebug() << "Sandbox: User selected" << result;
        }
        resultInfo = mixxx::FileInfo(result);
        if (resultInfo == *pFileInfo) {
            break;
        }

        if (sDebug) {
            qDebug() << "User selected the wrong file.";
        }
        QMessageBox::information(
                nullptr, title, QObject::tr("You selected the wrong file. To grant Mixxx access, "
                                            "please select the file '%1'. If you do not want to "
                                            "continue, press Cancel.")
                                        .arg(fileName));
    }
    return createSecurityToken(&resultInfo);
}

// static
ConfigKey Sandbox::keyForCanonicalPath(const QString& canonicalPath) {
    return ConfigKey("[OSXBookmark]",
                     QString(canonicalPath.toLocal8Bit().toBase64()));
}

// static
bool Sandbox::createSecurityToken(const QString& canonicalPath,
                                  bool isDirectory) {
    if (sDebug) {
        qDebug() << "createSecurityToken" << canonicalPath << isDirectory;
    }
    if (!enabled()) {
        return false;
    }
    const auto locker = lockMutex(&s_mutex);
    if (s_pSandboxPermissions == nullptr) {
        return false;
    }

#ifdef __APPLE__
    CFURLRef url = CFURLCreateWithFileSystemPath(
            kCFAllocatorDefault, QStringToCFString(canonicalPath),
            kCFURLPOSIXPathStyle, isDirectory);
    if (url) {
        CFErrorRef error = NULL;
#ifdef Q_OS_IOS
        // https://bugreports.qt.io/browse/QTBUG-67522
        CFURLBookmarkCreationOptions options = kCFURLBookmarkCreationSuitableForBookmarkFile;
#else
        CFURLBookmarkCreationOptions options = kCFURLBookmarkCreationWithSecurityScope;
#endif
        CFDataRef bookmark = CFURLCreateBookmarkData(
                kCFAllocatorDefault, url, options, nil, nil, &error);
        CFRelease(url);
        if (bookmark) {
            QByteArray bookmarkBA = QByteArray(
                    reinterpret_cast<const char*>(CFDataGetBytePtr(bookmark)),
                    CFDataGetLength(bookmark));

            QString bookmarkBase64 = QString(bookmarkBA.toBase64());

            s_pSandboxPermissions->set(keyForCanonicalPath(canonicalPath),
                                       bookmarkBase64);
            CFRelease(bookmark);
            return true;
        } else {
            if (sDebug) {
                qDebug() << "Failed to create security-scoped bookmark for" << canonicalPath;
                if (error != NULL) {
                    qDebug() << "Error:" << CFStringToQString(CFErrorCopyDescription(error));
                }
            }
        }
    } else {
        if (sDebug) {
            qDebug() << "Failed to create security-scoped bookmark URL for" << canonicalPath;
        }
    }
#endif
    return false;
}

// static
SecurityTokenPointer Sandbox::openSecurityToken(mixxx::FileInfo* pFileInfo, bool create) {
    VERIFY_OR_DEBUG_ASSERT(pFileInfo) {
        return nullptr;
    }
    const auto canonicalLocation = pFileInfo->resolveCanonicalLocation();
    if (canonicalLocation.isEmpty()) {
        return nullptr;
    }

    if (pFileInfo->isDir()) {
        return openSecurityTokenForDir(QDir(canonicalLocation), create);
    }

    if (sDebug) {
        qDebug() << "openSecurityToken for file" << canonicalLocation << create;
    }

    if (!enabled()) {
        return nullptr;
    }

    const auto locker = lockMutex(&s_mutex);
    if (!s_pSandboxPermissions) {
        return nullptr;
    }

    QHash<QString, SecurityTokenWeakPointer>::iterator it = s_activeTokens
                                                                    .find(canonicalLocation);
    if (it != s_activeTokens.end()) {
        SecurityTokenPointer pToken(it.value());
        if (pToken) {
            if (sDebug) {
                qDebug() << "openSecurityToken mixxx::FileInfo" << canonicalLocation
                         << "using cached token for" << pToken->m_path;
            }
            return pToken;
        }
    }

    // First, check for a bookmark of the key itself.
    ConfigKey key = keyForCanonicalPath(canonicalLocation);
    if (s_pSandboxPermissions->exists(key)) {
        return openTokenFromBookmark(
                canonicalLocation,
                s_pSandboxPermissions->getValueString(key));
    }

    // Next, try to open a bookmark for an existing directory but don't create a
    // bookmark.
    const auto parentDir = QDir(pFileInfo->canonicalLocationPath());
    SecurityTokenPointer pDirToken = openSecurityTokenForDir(parentDir, false);
    if (!pDirToken.isNull()) {
        return pDirToken;
    }

    if (!create) {
        return nullptr;
    }

    // Otherwise, try to create a token.
    bool created = createSecurityToken(pFileInfo);
    if (!created) {
        return nullptr;
    }

    return openTokenFromBookmark(
            canonicalLocation,
            s_pSandboxPermissions->getValueString(key));
}

// static
SecurityTokenPointer Sandbox::openSecurityTokenForDir(const QDir& dir, bool create) {
    QDir walkDir = dir;
    QString walkDirCanonicalPath = walkDir.canonicalPath();
    if (walkDirCanonicalPath.isEmpty()) {
        // Canonical path does not exist
        return SecurityTokenPointer();
    }
    if (sDebug) {
        qDebug() << "openSecurityToken for dir" << walkDirCanonicalPath << create;
    }

    if (!enabled()) {
        return nullptr;
    }

    const auto locker = lockMutex(&s_mutex);
    if (!s_pSandboxPermissions) {
        return nullptr;
    }

    while (!walkDirCanonicalPath.isEmpty()) {
        // Look for a valid token in the cache.
        QHash<QString, SecurityTokenWeakPointer>::iterator it = s_activeTokens
                .find(walkDirCanonicalPath);
        if (it != s_activeTokens.end()) {
            SecurityTokenPointer pToken(it.value());
            if (pToken) {
                if (sDebug) {
                    qDebug() << "openSecurityToken QDir" << walkDirCanonicalPath
                             << "using cached token for" << pToken->m_path;
                }
                return pToken;
            }
        }

        // Next, check if the key exists in the config.
        ConfigKey key = keyForCanonicalPath(walkDirCanonicalPath);
        if (s_pSandboxPermissions->exists(key)) {
            SecurityTokenPointer pToken = openTokenFromBookmark(
                    dir.canonicalPath(),
                    s_pSandboxPermissions->getValueString(key));
            if (pToken) {
                return pToken;
            }
        }

        // Go one step higher and repeat.
        if (!walkDir.cdUp()) {
            // There's nothing higher. Bail.
            break;
        }
        walkDirCanonicalPath = walkDir.canonicalPath();
    }

    // Last chance: Try to create a token for this directory.
    if (create && createSecurityToken(dir.canonicalPath(), true)) {
        ConfigKey key = keyForCanonicalPath(dir.canonicalPath());
        return openTokenFromBookmark(
                dir.canonicalPath(),
                s_pSandboxPermissions->getValueString(key));
    }
    return nullptr;
}

SecurityTokenPointer Sandbox::openTokenFromBookmark(const QString& canonicalPath,
                                                    const QString& bookmarkBase64) {
#ifdef __APPLE__
    QByteArray bookmarkBA = QByteArray::fromBase64(bookmarkBase64.toLatin1());
    if (!bookmarkBA.isEmpty()) {
        CFDataRef bookmarkData = CFDataCreate(
                kCFAllocatorDefault, reinterpret_cast<const UInt8*>(bookmarkBA.constData()),
                bookmarkBA.length());
        Boolean stale;
        CFErrorRef error = NULL;
#ifdef Q_OS_IOS
        CFURLBookmarkResolutionOptions options = 0;
#else
        CFURLBookmarkResolutionOptions options = kCFURLBookmarkResolutionWithSecurityScope;
#endif
        CFURLRef url = CFURLCreateByResolvingBookmarkData(
                kCFAllocatorDefault, bookmarkData, options, NULL, NULL, &stale, &error);
        if (error != NULL) {
            if (sDebug) {
                qDebug() << "Error creating URL from bookmark data:"
                         << CFStringToQString(CFErrorCopyDescription(error));
            }
        }
        CFRelease(bookmarkData);
        if (url != NULL) {
            if (!CFURLStartAccessingSecurityScopedResource(url)) {
                if (sDebug) {
                    qDebug() << "CFURLStartAccessingSecurityScopedResource failed for"
                             << canonicalPath;
                }
            } else {
                SecurityTokenPointer pToken = SecurityTokenPointer(
                    new SandboxSecurityToken(canonicalPath, url));
                s_activeTokens[canonicalPath] = pToken;
                return pToken;
            }
        } else {
            if (sDebug) {
                qDebug() << "Cannot resolve security-scoped bookmark for" << canonicalPath;
            }
        }
    }
#else
    Q_UNUSED(canonicalPath);
    Q_UNUSED(bookmarkBase64);
#endif

    return nullptr;
}

#ifdef Q_OS_MACOS
QString Sandbox::migrateOldSettings() {
    // QStandardPaths::DataLocation returns a different location depending on whether the build
    // is signed (and therefore sandboxed with the hardened runtime), so use the absolute path
    // that the sandbox uses regardless of whether this build is actually sandboxed.
    // Otherwise, developers would need to run with --settingsPath every time or symlink
    // to use the same settings directory with signed and unsigned builds.

    // QDir::homePath returns a path inside the sandbox when running sandboxed
    QString homePath = QLatin1String("/Users/") + qgetenv("USER");
    if (qEnvironmentVariableIsEmpty("USER") || qgetenv("USER").contains("/")) {
        qCritical() << "Cannot find home directory (USER environment variable invalid)";
        return QString();
    }

    QDir homeDir(homePath);
    if (!homeDir.exists()) {
        qCritical() << "Home directory does not exist" << homePath;
        return QString();
    }

    // The parent of the sandboxed path needs to be created before the legacySettingsPath
    // can be moved there. This is not necessary when running in a sandbox because macOS
    // automatically creates it.
    QString sandboxedParentPath = homePath +
            QLatin1String(
                    "/Library/Containers/org.mixxx.mixxx/Data/Library/"
                    "Application Support");
    QString sandboxedPath = sandboxedParentPath + QLatin1String("/Mixxx");
    QDir sandboxedDir(sandboxedPath);

    QString legacySettingsPath = homePath + QLatin1String("/Library/Application Support/Mixxx");
    // The user has no settings from Mixxx < 2.3.0, so there is no migration to do.
    if (!QDir(legacySettingsPath).exists()) {
        return sandboxedPath;
    }

    // The user already has settings in the sandboxed path, so there is no migration to do.
    if (sandboxedDir.exists() && !sandboxedDir.isEmpty()) {
        return sandboxedPath;
    }

    // Sandbox::askForAccess cannot be used here because it depends on settings being
    // initialized. There is no need to store the bookmark anyway because this is a
    // one time process.
    QString title = QObject::tr("Upgrading old Mixxx settings");
    QMessageBox::information(nullptr,
            title,
            QObject::tr(
                    "Due to macOS sandboxing, Mixxx needs your permission "
                    "to access your music library and settings from Mixxx "
                    "versions before 2.3.0. After clicking OK, you will see a "
                    "file selection dialog. "
                    "\n\n"
                    "To allow Mixxx to use your old library and settings, "
                    "click the Open button in the file selection dialog. "
                    "Mixxx will then move your old settings into the sandbox. "
                    "This only needs to be done once."
                    "\n\n"
                    "If you do not want to grant Mixxx access, click Cancel "
                    "on the file picker. Mixxx will create a new music library "
                    "and use default settings."));

    QString result = QFileDialog::getExistingDirectory(
            nullptr,
            title,
            legacySettingsPath);
    if (result != legacySettingsPath) {
        qInfo() << "Sandbox::migrateOldSettings: User declined to migrate old settings from"
                << legacySettingsPath << "User selected" << result;
        return sandboxedPath;
    }

    CFURLRef url = CFURLCreateWithFileSystemPath(
            kCFAllocatorDefault, QStringToCFString(legacySettingsPath), kCFURLPOSIXPathStyle, true);
    if (url) {
        CFErrorRef error = NULL;
        if (s_bInSandbox) {
            // Request permissions to the old unsandboxed sandboxed settings path
            // and move the directory into the sandbox
            CFDataRef bookmark = CFURLCreateBookmarkData(
                    kCFAllocatorDefault,
                    url,
                    kCFURLBookmarkCreationWithSecurityScope,
                    nil,
                    nil,
                    &error);
            CFRelease(url);
            if (bookmark) {
                QFile oldSettings(legacySettingsPath);
                if (oldSettings.rename(sandboxedPath)) {
                    qInfo() << "Sandbox::migrateOldSettings: Successfully "
                               "migrated old settings from"
                            << legacySettingsPath << "to new path" << sandboxedPath;
                } else {
                    qWarning() << "Sandbox::migrateOldSettings: Failed to migrate "
                                  "old settings from"
                               << legacySettingsPath
                               << "to new path" << sandboxedPath;
                }
                CFRelease(bookmark);
            } else {
                qWarning() << "Sandbox::migrateOldSettings: Failed to access old "
                              "settings path"
                           << legacySettingsPath
                           << "Cannot migrate to new path" << sandboxedPath;
            }
        } else {
            // Move old unsandboxed settings directory into the sandbox

            // Ensure the parent directory of the destination path exists, otherwise
            // moving to the new path will fail.
            QDir sandboxedParentDir(sandboxedParentPath);
            if (!sandboxedParentDir.exists()) {
                if (!sandboxedParentDir.mkpath(sandboxedParentPath)) {
                    qWarning() << "Could not create sandboxed application data directory"
                               << sandboxedParentPath;
                }
            }

            QFile oldSettings(legacySettingsPath);
            if (oldSettings.rename(sandboxedPath)) {
                qInfo() << "Sandbox::migrateOldSettings: Successfully "
                           "migrated old settings from"
                        << legacySettingsPath << "to new path"
                        << sandboxedPath;
            } else {
                qWarning() << "Sandbox::migrateOldSettings: Failed to migrate old settings from"
                           << legacySettingsPath << "to new path" << sandboxedPath;
            }
        }
    }
    return sandboxedPath;
}
#endif

#ifdef __APPLE__
SandboxSecurityToken::SandboxSecurityToken(const QString& path, CFURLRef url)
        : m_path(path),
          m_url(url) {
    if (m_url) {
        if (sDebug) {
            qDebug() << "SandboxSecurityToken successfully opened for" << path;
        }
    }
}
#endif

SandboxSecurityToken::~SandboxSecurityToken() {
#ifdef __APPLE__
    if (sDebug) {
        qDebug() << "~SandboxSecurityToken" << m_path;
    }
    if (m_url) {
        CFURLStopAccessingSecurityScopedResource(m_url);
        CFRelease(m_url);
        m_url = 0;
    }
#endif
}
