const axios = require("axios").default;
const { RobloxFile } = require("rbxm-parser");
const noblox = require("noblox.js");

// https://create.roblox.com/docs/reference/engine/enums/AssetType
const AssetType = {
    Image: 1,
    TShirt: 2,
    Audio: 3,
    Mesh: 4,
    Lua: 5,
    Hat: 8,
    Place: 9,
    Model: 10,
    Shirt: 11,
    Pants: 12,
    Decal: 13,
    Head: 17,
    Face: 18,
    Gear: 19,
    Badge: 21,
    Animation: 24,
    GamePass: 34,
    Plugin: 38,
    MeshPart: 40
};

async function getAssetInfo(assetId) {
    const jar = noblox.options.jar;
    const xcsrf = await noblox.getGeneralToken(jar);

    const res = await noblox.http(`https://apis.roblox.com/user/cloud/v2/creator-store-products/PRODUCT_NAMESPACE_CREATOR_MARKETPLACE_ASSET-PRODUCT_TYPE_MODEL-${assetId}`, {
        method: "GET",
        resolveWithFullResponse: true,
        jar: jar,
        headers: {
            "X-CSRF-TOKEN": xcsrf,
            "Content-Type": "application/json"
        }
    });

    if (res.statusCode < 200 || res.statusCode >= 300) {
        return {
            status: res.statusCode,
            isModel: false
        };
    }

    const assetInfo = JSON.parse(res.body);
    const quantity = assetInfo.purchasePrice.quantity;
    const isFree = quantity.significand === 0 && quantity.exponent === 0;
    const forSale = assetInfo.published && assetInfo.purchasable;

    return {
        status: res.statusCode,
        isModel: true,
        id: assetInfo.modelAssetId,
        creatorId: +assetInfo.userSeller,
        isFree: isFree,
        forSale: forSale
    };
}

async function buyModel(modelId) {
    const reqJson = {
        expectedPrice: {currencyCode: "USD", quantity: {significand: 0, exponent: 0}},
        productKey: {
            productNamespace: "PRODUCT_NAMESPACE_CREATOR_MARKETPLACE_ASSET",
            productTargetId: `${modelId}`,
            productType: "PRODUCT_TYPE_MODEL"
        }
    };

    const jar = noblox.options.jar;
    const xcsrf = await noblox.getGeneralToken(jar);

    const res = await noblox.http("https://apis.roblox.com/marketplace-fiat-service/v1/product/purchase", {
        method: "POST",
        resolveWithFullResponse: true,
        jar: jar,
        headers: {
            "X-CSRF-TOKEN": xcsrf,
            "Content-Type": "application/json"
        },
        body: JSON.stringify(reqJson)
    });

    const resJson = JSON.parse(res.body);
    // Return true if purchased, false otherwise
    return res.statusCode >= 200 && res.statusCode < 300 && resJson.purchaseTransactionStatus === "PURCHASE_TRANSACTION_STATUS_SUCCESS";
}

const SubmissionColumnsString = "map_id,unix_timestamp,user_id,username,display_name,creator\n";

const SubmissionColumn = {
    ModelID: 0,
    UnixTimestamp: 1,
    UserID: 2,
    Username: 3,
    DisplayName: 4,
    Creator: 5
};

function getSubmissionLine(line) {
    const modelId = Number(line[SubmissionColumn.ModelID]);
    const timestamp = Number(line[SubmissionColumn.UnixTimestamp]);
    let userId = "";
    let username = "";
    let displayName = "";
    let creator = "";
    
    // Backwards compatibility
    if (line.length > 2) {
        userId = Number(line[SubmissionColumn.UserID]);
        username = line[SubmissionColumn.Username];
    }

    if (line.length > 4) {
        displayName = line[SubmissionColumn.DisplayName];
        creator = line[SubmissionColumn.Creator];
    }

    return {
        modelId: modelId,
        timestamp: timestamp,
        userId: userId,
        username: username,
        displayName: displayName,
        creator: creator
    };
}

function safeCsvFormat(cell) {
    // https://stackoverflow.com/questions/46637955/write-a-string-containing-commas-and-double-quotes-to-csv
    if (cell.replace(/ /g, '').match(/[\s,"]/)) {
        return '"' + cell.replace(/"/g, '""') + '"';
    }
    return cell;
}

function createSubmissionLine(modelId, timestamp, userId, username, displayName, creator) {
    return `${modelId},${timestamp},${userId},${safeCsvFormat(username)},${safeCsvFormat(displayName)},${safeCsvFormat(creator)}\n`;
}

/**
 * @param {RobloxModel} model
 */
function getMapStringValues(model) {
    if (model.Roots.length < 1) {
        return undefined;
    }
    
    const root = model.Roots[0];
    const values = root.FindChildrenOfClass("StringValue", (child) => child.Name === "DisplayName" || child.Name === "Creator");
    if (values.length !== 2) {
        return undefined;
    }
    
    let name, creator;
    if (values[0].Name === "DisplayName") {
        name = values[0].Value;
        creator = values[1].Value;
    }
    else {
        name = values[1].Value;
        creator = values[0].Value;
    }
    
    return {
        displayName: name,
        creator: creator
    };
}

function capitalize(str) {
    if (!str) return "";
    return str[0].toUpperCase() + str.slice(1);
}

async function validateMapAsset(assetId, game) {
    let modelDomRes;
    try {
        modelDomRes = await axios.get("https://assetdelivery.roblox.com/v1/asset/", { 
            params: { id: assetId },
            responseEncoding: "binary", 
            responseType: "arraybuffer"
        });
    } 
    catch {
        // Roblox
        return { 
            valid: true,
            displayName: "",
            creator: ""
        };
    }

    const model = RobloxFile.ReadFromBuffer(modelDomRes.data);
    if (!model) {
        // For whatever reason we couldn't parse the model, so we'll just skip doing validation
        return { 
            valid: true,
            displayName: "",
            creator: ""
        };
    }
    
    const errors = [];

    if (model.Roots.length > 1) {
        errors.push("Your map has more than one root part, it must have a single `Model` as the root.");
        return {
            valid: false,
            errors: errors
        };
    }

    const root = model.Roots[0];
    if (!root.IsA("Model")) {
        errors.push(`The root part of your model is a \`${root.ClassName}\`, it needs to be a \`Model\` instead.`);
    }
    else {
        const prefix = (game === "deathrun") ? "dr" : game;
        if (!root.Name.startsWith(prefix + "_")) {
            errors.push(`Your root model's name is \`${root.Name}\`, its name must start with \`${prefix}_\`.`);
        }
        if (!/^[a-z0-9_]*$/.test(root.Name)) {
            errors.push(`Your root model's name is \`${root.Name}\` which contains invalid characters. It must only contain lowercase alphanumeric characters separated by underscores.`);
        }
    }

    const values = getMapStringValues(model);
    if (!values) {
        errors.push("Your map is missing a `StringValue` named `Creator` and/or a `StringValue` named `DisplayName`. You must add both to your map and they must be parented directly to the root model. These are used to set the map's creator and name in-game.");
    }
    else {
        if (!values.creator) {
            errors.push("Your map's `Creator` `StringValue` does not have a `Value`.");
        }
        if (!values.displayName) {
            errors.push("Your map's `DisplayName` `StringValue` does not have a `Value`.");
        }
        else {
            const checkName = capitalize(values.displayName);
            if (values.displayName !== checkName) {
                errors.push(`Your map's \`DisplayName\` must be capitalized. You may change it from \`${values.displayName}\` to \`${checkName}\`.`);
            }
        }
    }

    const mapStarts = root.FindDescendantsOfClass("BasePart", (part) => part.Name === "MapStart");
    if (mapStarts.length !== 1) {
        errors.push("Your map must have exactly one part named `MapStart`.");
    }
    
    const mapFinishes = root.FindDescendantsOfClass("BasePart", (part) => part.Name === "MapFinish");
    if (mapFinishes.length < 1) {
        errors.push("Your map must have at least one part named `MapFinish`.");
    }
    
    const mapParts = Array.from(mapStarts);
    mapParts.push(...mapFinishes);
    for (const part of mapParts) {
        if (part.CanCollide) {
            errors.push("The `MapStart` and `MapFinish` parts in your map must have the `CanCollide` property disabled.");
            break;
        }
    }

    const allSpawns = root.FindDescendantsOfClass("BasePart", (part) => part.Name.startsWith("Spawn") && !isNaN(part.Name.slice(5)));
    const spawnNameSet = new Set();
    const duplicateSpawns = new Set();
    for (const spawn of allSpawns) {
        const name = spawn.Name;
        if (spawnNameSet.has(name)) {
            duplicateSpawns.add(name);
        }
        else {
            spawnNameSet.add(name);
        }
    }

    if (!spawnNameSet.has("Spawn1")) {
        errors.push("Your map must have a part named `Spawn1`.");
    }

    const numDups = duplicateSpawns.size;
    if (numDups > 0) {
        const sortedSpawns = Array.from(duplicateSpawns);
        sortedSpawns.sort();
        const firstFive = sortedSpawns.slice(0, 5).map((name) => `\`${name}\``);
        if (numDups > 1 && numDups <= 5) {
            firstFive[firstFive.length - 1] = "and " + firstFive[firstFive.length - 1];
        }
        let msg = numDups === 1 ? "Your map has a duplicate `Spawn` part: " : "Your map has duplicate `Spawn` parts: ";
        msg += firstFive.join(", ");
        if (numDups > 5) {
            msg += `, and ${numDups - 5} more`;
        }
        msg += ". There can only be one instance of each `Spawn` part.";
        errors.push(msg);
    }

    // Why does ModuleScript not inherit from Script, and/or why does BaseScript not have a Source property?
    const scripts = root.FindDescendantsOfClass("Script");
    scripts.push(...root.FindDescendantsOfClass("ModuleScript"));

    const sourceSet = new Set();
    let numDuplicateScripts = 0;
    let hasIllegalKeywords = false;
    for (const script of scripts) {
        const source = script.Source;
        if (!hasIllegalKeywords && sourceHasIllegalKeywords(source)) {
            hasIllegalKeywords = true;
        }
        if (sourceSet.has(source)) {
            ++numDuplicateScripts;
        }
        else {
            sourceSet.add(source);
        }
    }

    if (hasIllegalKeywords) {
        errors.push("Your map has a `Script` that contains the keyword `getfenv` or `require`. You must remove these.");
    }

    if (numDuplicateScripts > 50) {
        errors.push("Your map has over 50 duplicate `Script`s. You must consolidate your scripts to less than 50 duplicates.");
    }

    if (errors.length > 0) {
        return {
            valid: false,
            errors: errors
        };
    }

    return { 
        valid: true,
        displayName: values.displayName,
        creator: values.creator
    };
}

function sourceHasIllegalKeywords(source) {
    return source && (source.includes("getfenv") || source.includes("require"));
}

function getValidationMessage(validation, game, errorOnFail) {
    if (validation.valid) {
        return `✅  Your map is valid! (game: ${game})`;
    }

    let msg = `${errorOnFail ? "🚫" : "⚠️"}  **Your map has problems.** (game: ${game})`;
    for (const error of validation.errors) {
        msg += `\n* ${error}`;
    }
    return msg;
}

module.exports = { AssetType, getAssetInfo, buyModel, SubmissionColumn, SubmissionColumnsString, getSubmissionLine, createSubmissionLine, validateMapAsset, getValidationMessage, safeCsvFormat };