"use strict";

// Mobile RASP (Runtime Application Self-Protection) detection engine for
// React Native / Cordova / Ionic hybrid apps.
//
// Two signal sources, merged into one scored threat report:
//
//   1. JS-observable checks — run inside the app's JS engine (Hermes,
//      JSC, or V8). These are cheap and need no native module, but they
//      are WEAK and evadable: a determined attacker with a native hook
//      can hide most of them. They exist to raise the cost of casual
//      tampering and to catch unsophisticated Frida/console attaches.
//
//   2. Native-probe results — supplied by a host-wired native module
//      (the bundled Android Kotlin / iOS Swift stubs wrap freeRASP).
//      These are the STRONG signals: root/jailbreak, Magisk, unc0ver,
//      checkra1n, Dopamine, emulator, Frida/Xposed/Substrate hooks,
//      app-signature tampering. Pure JS cannot read the Android/iOS
//      filesystem or process maps, so anything authoritative must come
//      from here.
//
// The engine is pure-data: `evaluate(env)` takes an environment object
// (so it is fully unit-testable without a device) and returns a report.
// No globals are touched until you call `attach()`.
//
// Honesty note: JS-only detection is defence-in-depth, not a guarantee.
// The product claim is "mobile-class RASP signals routed to your SIEM
// with configurable response", NOT "unbreakable". The native probe is
// where the real assurance lives; the JS layer is the tripwire.
//
// Public API
// ----------
//   const rasp = require("jso-protector-reactnative/rasp");
//   const report = rasp.evaluate({
//       globalObj: global,                 // the JS global to inspect
//       nativeResult: <freeRASP-shaped>,   // optional native probe output
//       isDev: __DEV__,                    // RN dev flag
//       now: () => Date.now(),             // injectable clock for timing probe
//   });
//   // report.verdict: "clean" | "suspicious" | "compromised"
//   // report.score:   number
//   // report.signals: [{ id, source, weight, detail }]

const SCHEMA_VERSION = 1;

// Verdict thresholds on the summed signal weight.
const SUSPICIOUS_AT = 30;
const COMPROMISED_AT = 60;

// Signal catalog. weight = contribution to the score. Native signals
// carry more weight than JS-observable ones because they are harder to
// forge from inside the sandbox.
const SIGNAL_WEIGHTS = {
    // JS-observable (weak, evadable)
    "js.frida-global":            25,
    "js.tostring-tamper":         20,
    "js.suspicious-global":       15,
    "js.dev-flag-in-release":     20,
    "js.timing-anomaly":          15,
    // Native-probe (strong, authoritative)
    "native.rooted":              60,
    "native.jailbroken":          60,
    "native.hooks-detected":      60,
    "native.signature-tampered":  55,
    "native.emulator":            30,
    "native.debuggable":          25,
    "native.unofficial-store":    20,
    "native.named-tool":          50,
};

// Known names freeRASP (and similar) surface for root/jailbreak/hook
// tooling. Used to label native.named-tool signals; matching is done
// by the native layer, we just carry the label through.
const KNOWN_TOOLS = [
    "su", "Magisk", "unc0ver", "checkra1n", "check1rain", "Dopamine",
    "Frida", "Shadow", "Xposed", "Substrate", "Cydia", "Sileo", "Zygisk",
];

function weightOf(id) {
    return Object.prototype.hasOwnProperty.call(SIGNAL_WEIGHTS, id) ? SIGNAL_WEIGHTS[id] : 10;
}

// ---- JS-observable checks --------------------------------------------------

function checkFridaGlobal(g, signals) {
    if (!g || typeof g !== "object") return;
    // Frida's Gadget/Gum injects recognizable globals into the JS
    // runtime in some configurations. None of these legitimately exist
    // in a stock React Native global.
    const suspects = ["frida", "Frida", "gum", "Gum", "Gadget", "Interceptor", "Stalker", "Java", "ObjC"];
    for (const name of suspects) {
        if (Object.prototype.hasOwnProperty.call(g, name) && g[name] != null) {
            signals.push({ id: "js.frida-global", source: "js", weight: weightOf("js.frida-global"), detail: "global." + name + " present" });
            return; // one is enough; don't stack
        }
    }
}

function checkToStringTamper(g, signals) {
    // Frida and other hook frameworks frequently replace
    // Function.prototype.toString so that hooked functions still report
    // "[native code]". A side effect: toString.toString() itself may no
    // longer be native. This is a heuristic, not proof.
    try {
        const fnToString = (g && g.Function && g.Function.prototype && g.Function.prototype.toString) || Function.prototype.toString;
        const meta = Function.prototype.toString.call(fnToString);
        if (typeof meta === "string" && meta.indexOf("[native code]") < 0) {
            signals.push({ id: "js.tostring-tamper", source: "js", weight: weightOf("js.tostring-tamper"), detail: "Function.prototype.toString is not native" });
        }
    } catch (e) {
        // A throw here is itself anomalous — toString should not throw.
        signals.push({ id: "js.tostring-tamper", source: "js", weight: weightOf("js.tostring-tamper"), detail: "toString probe threw: " + (e && e.message) });
    }
}

function checkSuspiciousGlobals(g, signals) {
    if (!g || typeof g !== "object") return;
    // Properties whose names match common instrumentation namespaces.
    let hit = null;
    try {
        const keys = Object.getOwnPropertyNames(g);
        for (const k of keys) {
            if (/^(__frida|_gum|__xposed|__substrate|__cycript)/i.test(k)) { hit = k; break; }
        }
    } catch (e) { /* getOwnPropertyNames can throw on exotic proxies */ }
    if (hit) signals.push({ id: "js.suspicious-global", source: "js", weight: weightOf("js.suspicious-global"), detail: "suspicious global '" + hit + "'" });
}

function checkDevFlag(isDev, isReleaseBuild, signals) {
    // __DEV__ true inside a build the host declares as release means
    // either a misconfigured build or a re-packaged debug bundle.
    if (isReleaseBuild && isDev === true) {
        signals.push({ id: "js.dev-flag-in-release", source: "js", weight: weightOf("js.dev-flag-in-release"), detail: "__DEV__ is true in a release build" });
    }
}

function checkTimingAnomaly(now, signals, opts) {
    // A tight arithmetic loop that should complete well under the
    // budget. Heavy instrumentation (single-stepping, traced syscalls)
    // inflates this. Injectable clock so tests are deterministic.
    if (typeof now !== "function") return;
    const budgetMs = (opts && typeof opts.timingBudgetMs === "number") ? opts.timingBudgetMs : 35;
    let start, end;
    try {
        start = now();
        let acc = 0;
        for (let i = 0; i < 200000; i++) acc += (i ^ (i << 1)) & 0x7f;
        end = now();
        // Defeat dead-code elimination.
        if (acc === -1) signals.push({ id: "__never", source: "js", weight: 0, detail: "" });
    } catch (e) { return; }
    const elapsed = end - start;
    if (elapsed > budgetMs) {
        signals.push({ id: "js.timing-anomaly", source: "js", weight: weightOf("js.timing-anomaly"), detail: "loop took " + Math.round(elapsed) + "ms (budget " + budgetMs + "ms)" });
    }
}

// ---- native-probe merge ----------------------------------------------------
//
// Expected native result shape (freeRASP-compatible subset). All fields
// optional; absent = not checked / unknown.
//   {
//     isRooted:        bool,   // Android
//     isJailbroken:    bool,   // iOS
//     isDebuggable:    bool,
//     isEmulator:      bool,
//     hooksDetected:   bool,   // Frida/Xposed/Substrate/etc.
//     signatureTampered: bool, // app signing cert mismatch
//     unofficialStore: bool,   // installed outside Play/App Store
//     detectedTools:   ["Magisk", "Frida", ...],
//   }

function mergeNativeResult(nr, signals) {
    if (!nr || typeof nr !== "object") return;
    if (nr.isRooted === true)          signals.push({ id: "native.rooted",             source: "native", weight: weightOf("native.rooted"),             detail: "device is rooted" });
    if (nr.isJailbroken === true)      signals.push({ id: "native.jailbroken",         source: "native", weight: weightOf("native.jailbroken"),         detail: "device is jailbroken" });
    if (nr.hooksDetected === true)     signals.push({ id: "native.hooks-detected",     source: "native", weight: weightOf("native.hooks-detected"),     detail: "runtime hooking framework detected" });
    if (nr.signatureTampered === true) signals.push({ id: "native.signature-tampered", source: "native", weight: weightOf("native.signature-tampered"), detail: "app signature does not match" });
    if (nr.isEmulator === true)        signals.push({ id: "native.emulator",           source: "native", weight: weightOf("native.emulator"),           detail: "running on an emulator/simulator" });
    if (nr.isDebuggable === true)      signals.push({ id: "native.debuggable",         source: "native", weight: weightOf("native.debuggable"),         detail: "app is debuggable" });
    if (nr.unofficialStore === true)   signals.push({ id: "native.unofficial-store",   source: "native", weight: weightOf("native.unofficial-store"),   detail: "installed from an unofficial source" });
    if (Array.isArray(nr.detectedTools)) {
        for (const tool of nr.detectedTools) {
            const label = KNOWN_TOOLS.indexOf(tool) >= 0 ? tool : String(tool);
            signals.push({ id: "native.named-tool", source: "native", weight: weightOf("native.named-tool"), detail: "detected tool: " + label, tool: label });
        }
    }
}

// ---- public evaluate -------------------------------------------------------

function evaluate(env) {
    env = env || {};
    const signals = [];

    const g = env.globalObj || (typeof globalThis !== "undefined" ? globalThis : null);
    const isReleaseBuild = env.isReleaseBuild !== false; // default: treat as release (strict)

    // JS-observable layer
    checkFridaGlobal(g, signals);
    checkToStringTamper(g, signals);
    checkSuspiciousGlobals(g, signals);
    checkDevFlag(env.isDev, isReleaseBuild, signals);
    checkTimingAnomaly(env.now, signals, env);

    // Native layer
    mergeNativeResult(env.nativeResult, signals);

    // Drop the dead-code-elimination guard sentinel if present.
    const real = signals.filter(function (s) { return s.id !== "__never"; });

    let score = 0;
    for (const s of real) score += s.weight;

    let verdict = "clean";
    if (score >= COMPROMISED_AT) verdict = "compromised";
    else if (score >= SUSPICIOUS_AT) verdict = "suspicious";

    return {
        v: SCHEMA_VERSION,
        kind: "mobile-rasp",
        verdict: verdict,
        score: score,
        thresholds: { suspicious: SUSPICIOUS_AT, compromised: COMPROMISED_AT },
        signals: real,
        platform: (env.nativeResult && env.nativeResult.platform) || env.platform || null,
        nativeProbePresent: !!env.nativeResult,
        observedAt: env.observedAt || null,
    };
}

module.exports = {
    SCHEMA_VERSION: SCHEMA_VERSION,
    SUSPICIOUS_AT: SUSPICIOUS_AT,
    COMPROMISED_AT: COMPROMISED_AT,
    SIGNAL_WEIGHTS: Object.assign({}, SIGNAL_WEIGHTS),
    KNOWN_TOOLS: KNOWN_TOOLS.slice(),
    evaluate: evaluate,
    // exposed for targeted tests:
    _checkFridaGlobal: checkFridaGlobal,
    _checkToStringTamper: checkToStringTamper,
    _mergeNativeResult: mergeNativeResult,
};
