import assert from 'node:assert';
import async from 'async';
import BoxError from '../boxerror.js';
import constants from '../constants.js';
import logger from '../logger.js';
import { Storage as GCS } from '@google-cloud/storage';
import path from 'node:path';
import safe from 'safetydance';
import _ from '../underscore.js';

const { log } = logger('storage/gcs');


function getBucket(config) {
    assert.strictEqual(typeof config, 'object');

    const gcsConfig = {
        projectId: config.projectId,

        credentials: {
            client_email: config.credentials.client_email,
            private_key: config.credentials.private_key
        }
    };

    const gcs = constants.TEST ? new globalThis.GCSMock(gcsConfig) : new GCS(gcsConfig);
    return gcs.bucket(config.bucket);
}

async function getAvailableSize(config) {
    assert.strictEqual(typeof config, 'object');

    return Number.POSITIVE_INFINITY;
}

async function getStatus(config) {
    assert.strictEqual(typeof config, 'object');

    const bucket = getBucket(config);
    const query = { prefix: path.join(config.prefix, 'snapshot'), autoPaginate: false, maxResults: 1 };
    const [listError] = await safe(bucket.getFiles(query));
    if (listError) return { state: 'inactive', message: `Failed to get files: ${listError.message}` };
    return { state: 'active', message: '' };
}

async function upload(config, limits, remotePath) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof limits, 'object');
    assert.strictEqual(typeof remotePath, 'string');

    const fullRemotePath = path.join(config.prefix, remotePath);

    log(`Uploading to ${fullRemotePath}`);

    return {
        createStream() { return getBucket(config).file(fullRemotePath).createWriteStream({ resumable: false }); },
        async finish() {}
    };
}

async function exists(config, remotePath) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof remotePath, 'string');

    const bucket = getBucket(config);
    const fullRemotePath = path.join(config.prefix, remotePath);

    if (!fullRemotePath.endsWith('/')) {
        const file = bucket.file(fullRemotePath);
        const [error] = await safe(file.getMetadata());
        if (error && error.code === 404) return false;
        if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error.message);

        return true;
    } else {
        const query = {
            prefix: fullRemotePath,
            maxResults: 1,
            autoPaginate: true
        };

        const [error, files] = await safe(bucket.getFiles(query));
        if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error.message);

        return files.length !== 0;
    }
}

async function download(config, remotePath) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof remotePath, 'string');

    const fullRemotePath = path.join(config.prefix, remotePath);
    log(`Download ${fullRemotePath} starting`);

    const file = getBucket(config).file(fullRemotePath);
    return file.createReadStream();
}

async function listDir(config, remotePath, batchSize, marker) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof remotePath, 'string');
    assert.strictEqual(typeof batchSize, 'number');
    assert(typeof marker !== 'undefined');

    const bucket = getBucket(config);
    const fullRemotePath = path.join(config.prefix, remotePath);

    const query = marker || { prefix: fullRemotePath + '/', autoPaginate: false, maxResults: batchSize };

    const [error, result] = await safe(bucket.getFiles(query));
    if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to get files: ${error.message}`);

    const [files, nextQuery] = result;
    if (files.length === 0) return { entries: [], marker: null }; // no more
    const entries = files.map(function (f) { return { path: path.relative(config.prefix, f.name) }; });
    return { entries, marker: nextQuery || null };
}

async function copyInternal(config, fullFromPath, fullToPath, progressCallback) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof fullFromPath, 'string');
    assert.strictEqual(typeof fullToPath, 'string');
    assert.strictEqual(typeof progressCallback, 'function');

    const [copyError] = await safe(getBucket(config).file(fullFromPath).copy(fullToPath));
    if (copyError) log('copyBackup: gcs copy error. %o', copyError);
    if (copyError && copyError.code === 404) throw new BoxError(BoxError.NOT_FOUND, 'Old backup not found');
    if (copyError) throw new BoxError(BoxError.EXTERNAL_ERROR, copyError.message);
}

async function copy(config, fromPath, toPath, progressCallback) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof fromPath, 'string');
    assert.strictEqual(typeof toPath, 'string');
    assert.strictEqual(typeof progressCallback, 'function');

    const fullFromPath = path.join(config.prefix, fromPath);
    const fullToPath = path.join(config.prefix, toPath);

    await copyInternal(config, fullFromPath, fullToPath, progressCallback);
}

async function copyDir(config, limits, fromPath, toPath, progressCallback) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof limits, 'object');
    assert.strictEqual(typeof fromPath, 'string');
    assert.strictEqual(typeof toPath, 'string');
    assert.strictEqual(typeof progressCallback, 'function');

    const batchSize = 1000;
    const concurrency = limits.copyConcurrency || 10;
    let total = 0;

    let marker = null;
    while (true) {
        const batch = await listDir(config, fromPath, batchSize, marker); // returned entries are relative to prefix
        if (batch.entries.length === 0) break;
        total += batch.entries.length;
        progressCallback({ message: `Copying ${batch.entries.length} files from ${batch.entries[0].path} to ${batch.entries[batch.entries.length-1].path}. total: ${total}` });
        await async.eachLimit(batch.entries, concurrency, async (entry) => {
            const fullFromPath = path.join(config.prefix, entry.path);
            const fullToPath = path.join(config.prefix, toPath, path.relative(fromPath, entry.path));
            await copyInternal(config, fullFromPath, fullToPath, progressCallback);
        });
        if (!batch.marker) break;
        marker = batch.marker;
    }
    progressCallback({ message: `Copied ${total} files` });
}

async function remove(config, remotePath) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof remotePath, 'string');

    const fullRemotePath = path.join(config.prefix, remotePath);
    const [error] = await safe(getBucket(config).file(fullRemotePath).delete());
    if (error) log('removeBackups: Unable to remove %s (%s). Not fatal.', fullRemotePath, error.message);
}

async function removeDir(config, limits, remotePathPrefix, progressCallback) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof limits, 'object');
    assert.strictEqual(typeof remotePathPrefix, 'string');
    assert.strictEqual(typeof progressCallback, 'function');

    const batchSize = 1000;
    const concurrency = limits.deleteConcurrency || 10; // https://googleapis.dev/nodejs/storage/latest/Bucket.html#deleteFiles
    let total = 0;

    let marker = null;
    while (true) {
        const batch = await listDir(config, remotePathPrefix, batchSize, marker); // adds 'prefix'
        if (batch.entries.length === 0) break;
        const entries = batch.entries;
        total += entries.length;
        progressCallback({ message: `Removing ${entries.length} files from ${entries[0].path} to ${entries[entries.length-1].path}. total: ${total}` });
        await async.eachLimit(entries, concurrency, async (entry) => await remove(config, entry.path)); // remove will add 'prefix'
        if (!batch.marker) break;
        marker = batch.marker;
    }
    progressCallback({ progress: `Deleted ${total} files` });
}

async function cleanup(config, progressCallback) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof progressCallback, 'function');
}

async function verifyConfig({ id, provider, config }) {
    assert.strictEqual(typeof id, 'string');
    assert.strictEqual(typeof provider, 'string');
    assert.strictEqual(typeof config, 'object');

    if (typeof config.projectId !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'projectId must be a string');
    if (!config.credentials || typeof config.credentials !== 'object') throw new BoxError(BoxError.BAD_FIELD, 'credentials must be an object');
    if (typeof config.credentials.client_email !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'credentials.client_email must be a string');
    if (typeof config.credentials.private_key !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'credentials.private_key must be a string');

    if (typeof config.bucket !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'bucket must be a string');
    if (typeof config.prefix !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'prefix must be a string');

    // attempt to upload and delete a file with new credentials
    const bucket = getBucket(config);

    const testFile = bucket.file(path.join(config.prefix, 'snapshot/cloudron-testfile'));
    const [saveError] = await safe(testFile.save('testfilecontents', { resumable: false }));
    if (saveError) { // with bad creds, it return status 500 and "Cannot call write after a stream was destroyed"
        log('testConfig: failed uploading cloudron-testfile. %o', saveError);
        if (saveError.code == 403 || saveError.code == 404) throw new BoxError(BoxError.BAD_FIELD, saveError.message);
        throw new BoxError(BoxError.EXTERNAL_ERROR, saveError.message);
    }
    log('testConfig: uploaded cloudron-testfile');

    const query = { prefix: path.join(config.prefix, 'snapshot'), autoPaginate: false, maxResults: 1 };
    const [listError] = await safe(bucket.getFiles(query));
    if (listError) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to get files: ${listError.message}`);

    const [delError] = await safe(bucket.file(path.join(config.prefix, 'snapshot/cloudron-testfile')).delete());
    if (delError) throw new BoxError(BoxError.EXTERNAL_ERROR, delError.message);
    log('testConfig: deleted cloudron-testfile');

    return _.pick(config, ['projectId', 'credentials', 'bucket', 'prefix']);
}

async function setup(config) {
    assert.strictEqual(typeof config, 'object');
}

async function teardown(config) {
    assert.strictEqual(typeof config, 'object');
}

function getLocationLabel(config) {
    assert.strictEqual(typeof config, 'object');

    return config.bucket + (config.prefix ? ` / ${config.prefix}` : '');
}

function removePrivateFields(config) {
    delete config.credentials.private_key;
    return config;
}

function injectPrivateFields(newConfig, currentConfig) {
    if (!Object.hasOwn(newConfig.credentials, 'private_key') && currentConfig.credentials) newConfig.credentials.private_key = currentConfig.credentials.private_key;
}

export default {
    getAvailableSize,
    getStatus,

    upload,
    exists,
    download,

    copy,
    copyDir,

    listDir,

    remove,
    removeDir,

    setup,
    teardown,
    cleanup,

    verifyConfig,
    getLocationLabel,
    removePrivateFields,
    injectPrivateFields,
};
