// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License, Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2024 Datadog, Inc.

import { CodeRegion } from "ext:ddsa_lib/region";
import { TaintFlow } from "ext:ddsa_lib/flow/graph";
import { TreeSitterNode } from "ext:ddsa_lib/ts_node";

/**
 * A static analysis violation to be reported to the Rust static analysis kernel.
 */
export class Violation {
    /**
     * @param {string} message
     * @param {...*} locationArgs. A variadic parameter that is used to transparently convert supported values to
     * a {@link CodeRegion} (or array of code regions).
     * # Supported arguments:
     * * {@link TreeSitterNode}: The entire span of a CST node.
     * * `number`, `number`, `number`, `number`: A start line, start column, end line, and end column.
     * * {@link Position}, {@link Position}: A start position and end position.
     * * {@link TaintFlow}: A flow generated by taint analysis.
     *
     * @throws Throws if the `locationArgs` are not passed in according to the patterns above.
     *
     * @constructor
     */
    constructor(message, ...locationArgs) {
        /**
         * A human-friendly message describing the nature of the violation.
         * @type {string}
         * @readonly
         */
        this.message = message;
        /**
         * A list of `Fix` associated with this Violation.
         * @type {Array<Fix> | undefined}
         */
        this.fixes = undefined;
        /**
         * The base `CodeRegion` for the violation.
         * @type {CodeRegion}
         */
        this.baseRegion = undefined;
        /**
         * An optional array of `CodeRegion` (ordered from start to finish) if the violation has a tainted variable flow.
         * The first region in this array will always be the same as {@link Violation.baseRegion}.
         * @type {Array<CodeRegion> | undefined}
         */
        this.taintFlowRegions = undefined;

        if (locationArgs.length > 0) {
            const location = inferRegionVariadic(locationArgs);
            if (Array.isArray(location)) {
                this.taintFlowRegions = (/** @type {Array<CodeRegion>} */ location);
                this.baseRegion = this.taintFlowRegions[0];
            } else {
                this.baseRegion = (/** @type {CodeRegion} */ location);
            }
        }
    }

    /**
     * @param {Fix} fix
     * @returns {Violation}
     * Adds a fix to this violation and returns `this`.
     */
    addFix(fix) {
        if (this.fixes === undefined) {
            this.fixes = [];
        }
        this.fixes.push(fix);
        return this;
    }

    /**
     * Creates a new `Violation`.
     * @param {string} message
     * @param {...*} locationArgs. See {@link Violation#constructor} for documentation.
     *
     * @returns {Violation}
     *
     * @remarks
     * This is a convenience function to allow creation of a `Violation` without using the class constructor.
     * It is functionally equivalent to calling `new Violation(...)`.
     */
    static new(message, ...locationArgs) {
        return new Violation(message, ...locationArgs);
    }
}

/**
 * Infers a {@link CodeRegion} from a list of arguments. See {@link Violation#constructor} for documentation.
 * @param {Array<*>} locationArgs
 *
 * @throws Throws if a conversion cannot be made.
 * @returns {@link Array<CodeRegion> | CodeRegion}
 *
 * @private
 */
function inferRegionVariadic(locationArgs) {
    if (!Array.isArray(locationArgs)) {
        throw new Error("locationArgs must be an array");
    }
    switch (locationArgs.length) {
        case 1: {
            if (locationArgs[0] instanceof TreeSitterNode) {
                const /** @type {TreeSitterNode} */ tsNode = locationArgs[0];
                return new CodeRegion(tsNode.start.line, tsNode.start.col, tsNode.end.line, tsNode.end.col);
            } else if (locationArgs[0] instanceof TaintFlow) {
                const /** @type {TaintFlow} */ taintFlow = locationArgs[0];
                const converted = [];
                for (const tsNode of taintFlow) {
                    converted.push(inferRegionVariadic([tsNode]));
                }
                return converted;
            } else if (locationArgs[0] instanceof CodeRegion) {
                return locationArgs[0];
            } else if (Array.isArray(locationArgs[0])) {
                // The only array accepted is a `TaintFlow`, so an array here is an error.
                // (Detect array specifically to provide nicer error message)
                throw new Error("provided argument should not be an array -- map the array before passing arguments to this function");
            }
            break;
        }
        case 2: {
            if (isPosition(locationArgs[0]) && isPosition(locationArgs[1])) {
                const /** @type {Position} */ start = locationArgs[0];
                const /** @type {Position} */ end = locationArgs[1];
                validateStartEnd(start, end);
                return new CodeRegion(start.line, start.col, end.line, end.col);
            }
            break;
        }
        case 4: {
            let allIntegers = true;
            for (let i = 0; i < locationArgs.length; i++) {
                allIntegers = allIntegers && Number.isInteger(locationArgs[i]);
            }
            if (allIntegers) {
                const /** @type {Position} */ start = { line: locationArgs[0], col: locationArgs[1] };
                const /** @type {Position} */ end = { line: locationArgs[2], col: locationArgs[3] };
                validateStartEnd(start, end);
                return new CodeRegion(start.line, start.col, end.line, end.col);
            }
        }
    }
    throw new Error("provided arguments could not be converted to CodeRegion");
}

/**
 * Returns `true` if the provided object has the exact shape: `{line: <integer>, col: <integer>}`.
 * @param {Object} obj
 * @returns {boolean}
 */
function isPosition(obj) {
    if (obj === null || typeof obj !== "object") {
        return false;
    }
    if (Object.keys(obj).length !== 2) {
        return false;
    }
    return (Number.isInteger(obj.line) && Number.isInteger(obj.col));
}

/**
 * Validates that the start position either occurs before the end position or is equivalent.
 * @param {Position} start
 * @param {Position} end
 * @throws Throws if the above invariant doesn't hold.
 */
function validateStartEnd(start, end) {
    if (
        !isPosition(start) ||
        !isPosition(end) ||
        (start.line > end.line) ||
        ((start.line === end.line) && (start.col > end.col))
    ) {
        throw new Error("start position should be before the end position");
    }
}
