// Weekly platform-compliance + multi-tenant audit rules.
// Card: card_923709f59ecb0c1cd66bc786
//
// Two dimensions, per Hank 2026-06-07 20:32 TW:
//   A. 用戶平台規範遵循 — feedback_platform_user_rule_compliance: every
//      repo/feature/script/template/doc must work for ALL worldwide users;
//      no single-tenant designs, no carveouts.
//   B. 多用戶 / 多實體角度 — features must reason about N>1 users per
//      device and N>1 entities per user (the test device has 2 entities but
//      production accounts vary; UI / API / DB queries that assume N=1 leak).
//
// Each rule is a pure JSON object so the auditor walks them deterministically.
// New rules: add to the array; bump `id` (kebab-case slug, unique).

'use strict';

/**
 * @typedef {Object} AuditRule
 * @property {string} id                kebab-case unique slug
 * @property {('compliance'|'multi_tenant')} dimension
 * @property {('P0'|'P1'|'P2'|'P3')} severity
 * @property {string} title             one-liner
 * @property {string} rationale         why this is a violation
 * @property {RegExp} pattern           matches the offending text
 * @property {(filePath: string, match: string) => boolean} [filePathFilter]
 *           optional: skip files that don't match. Returns true to KEEP.
 * @property {string[]} [allowFiles]    substrings — file paths containing any
 *                                       of these are ALWAYS exempt (tests, etc.)
 */

const TEST_PATH_HINTS = Object.freeze([
    '/tests/', '/test/', '__tests__', '.test.js', '.spec.js',
    '/fixtures/', '/migrations/', // migrations may legitimately reference example IDs
]);
const HANK_HEX_DEVICE = '480def4c-2183-4d8e-afd0-b131ae89adcc';
const HANK_HEX_REGEX = new RegExp(HANK_HEX_DEVICE, 'g');

/** @type {AuditRule[]} */
const RULES = [
    // ── Dimension A: platform compliance ───────────────────────────────
    {
        id: 'hardcoded-hank-device-id',
        dimension: 'compliance',
        severity: 'P0',
        title: 'Hank\'s deviceId UUID hardcoded in source',
        rationale: 'EClawbot must work for any user. Hardcoding the test device UUID into shipped code makes the feature single-tenant.',
        pattern: HANK_HEX_REGEX,
        allowFiles: TEST_PATH_HINTS.slice(),
    },
    {
        id: 'hardcoded-users-hank-path',
        dimension: 'compliance',
        severity: 'P1',
        title: 'Absolute path under /Users/hank/ in shipped code',
        rationale: 'Paths under one developer\'s home directory will not resolve on production or another contributor\'s laptop.',
        pattern: /\/Users\/hank\//,
        allowFiles: ['/scripts/dev', '/.local/', '/docs/'],
    },
    {
        id: 'hardcoded-entity-id-comparison',
        dimension: 'compliance',
        severity: 'P1',
        title: 'Equality check against hardcoded entityId 1-5',
        rationale: 'EClawbot entity slots are user-configurable. `entityId === 2` (etc.) outside a routing seam single-tenants the feature.',
        pattern: /\bentityId\s*===\s*[1-5]\b/,
        allowFiles: TEST_PATH_HINTS.concat(['/agent-improvement/', '/eclaw-bridge/']),
    },
    {
        id: 'hardcoded-public-code-literal',
        dimension: 'compliance',
        severity: 'P2',
        title: '6-char publicCode literal hardcoded',
        rationale: 'publicCodes are assigned per user; literals like `tbwb9e` or `3xa3h4` in shipped code identify specific user bots.',
        // EXACTLY 6 lowercase a-z/0-9 between quote delimiters, requiring BOTH a
        // letter and a digit — that is the publicCode shape (e.g. tbwb9e, 3xa3h4).
        // Lookaheads keep us from false-firing on '401', '256kb', plain words, or
        // long hashes (the earlier `[a-z0-9]*\d[a-z0-9]*` matched all of those).
        pattern: /['"`](?=[a-z0-9]{6}['"`])(?=[a-z0-9]*[a-z])(?=[a-z0-9]*\d)[a-z0-9]{6}['"`]/,
        filePathFilter: (p) => /\.(js|ts)$/.test(p) && !/\.test\./.test(p),
        allowFiles: TEST_PATH_HINTS.concat(['/migrations/', '/api-docs', 'api_refs']),
    },
    {
        id: 'hank-specific-conditional',
        dimension: 'compliance',
        severity: 'P0',
        title: 'Conditional gated on Hank\'s specific deviceId or email',
        rationale: 'Features that fork on `if (deviceId === HANK_*)` are explicitly single-tenant.',
        pattern: /if\s*\(\s*deviceId\s*===?\s*['"`][a-f0-9-]{8,}/,
        allowFiles: TEST_PATH_HINTS.slice(),
    },
    {
        id: 'migration-without-if-not-exists',
        dimension: 'compliance',
        severity: 'P2',
        title: 'Migration ADD COLUMN without IF NOT EXISTS',
        rationale: 'Re-running a migration on a fresh prod database should be safe. ADD COLUMN without IF NOT EXISTS crashes on replay.',
        pattern: /ALTER\s+TABLE\s+\w+\s+ADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS)/i,
        filePathFilter: (p) => p.endsWith('.up.sql'),
    },
    {
        id: 'zh-only-string-no-i18n-key',
        dimension: 'compliance',
        severity: 'P3',
        title: 'Chinese-only literal in alert() / confirm() / throw',
        rationale: 'Worldwide-user platform: every user-visible string must be either i18n-keyed or English. zh-only strings in alerts strand non-CJK users.',
        pattern: /(alert|confirm|throw\s+new\s+Error)\s*\(\s*['"`][一-鿿]/,
        filePathFilter: (p) => /\.(js|html)$/.test(p),
        allowFiles: TEST_PATH_HINTS.concat(['/i18n', '/docs/', 'memory/']),
    },

    // ── Dimension B: multi-tenant perspective ──────────────────────────
    {
        id: 'db-query-missing-entity-id-filter',
        dimension: 'multi_tenant',
        severity: 'P2',
        title: 'DB SELECT/UPDATE on entity-scoped table missing entity_id filter',
        rationale: 'Tables with entity_id columns (counters, episodes, drawer state) need WHERE filters on both device_id AND entity_id; otherwise rows from one entity leak into another\'s view.',
        pattern: /WHERE\s+device_id\s*=\s*\$\d+\s*$/im,
        filePathFilter: (p) => /\.(js|sql)$/.test(p),
        allowFiles: TEST_PATH_HINTS.concat(['/devices', '/auth', '/migrations/']),
    },
    {
        id: 'broadcast-without-recipient-list',
        dimension: 'multi_tenant',
        severity: 'P1',
        title: 'speakTo:[] without senderHint or explicit broadcast flag',
        rationale: 'Empty speakTo arrays + missing broadcast flag silently fan out to all entities on the device. PR #2960 / U64 incident.',
        pattern: /speakTo:\s*\[\s*\]/,
        filePathFilter: (p) => /\.(js)$/.test(p) && !/\.test\./.test(p),
        allowFiles: TEST_PATH_HINTS.slice(),
    },
    {
        id: 'cron-once-per-device-assumption',
        dimension: 'multi_tenant',
        severity: 'P2',
        title: 'Cron job iterates devices via SELECT DISTINCT but does not iterate entities',
        rationale: 'A cron that runs once per device but skips per-entity work treats N=2 entities as a single agent. Counter/episode/drawer crons must iterate entities.',
        pattern: /SELECT\s+DISTINCT\s+device_id\s+FROM/i,
        filePathFilter: (p) => /(cron|sweeper|background)/.test(p),
    },
    {
        id: 'ui-assumes-two-bot-slots',
        dimension: 'multi_tenant',
        severity: 'P2',
        title: 'UI hardcodes 2 / 5 bot slots',
        rationale: 'Test device has 2 slots, prod accounts have up to 5. Loops with `i < 2` / array literals `[#1, #2]` strand bot 3-5.',
        pattern: /for\s*\(\s*(?:let|const|var)?\s*\w+\s*=\s*[01]\s*;\s*\w+\s*<\s*[25]\s*;.*botSlots?/,
        filePathFilter: (p) => /\.(js|html)$/.test(p),
        allowFiles: TEST_PATH_HINTS.slice(),
    },
    {
        id: 'counter-increment-not-per-entity',
        dimension: 'multi_tenant',
        severity: 'P1',
        title: 'incrementCounter / addCounter call without entityId argument',
        rationale: 'Per-entity counters (no_reply axes, error counters) need entityId; calls without it bucket into entity_id=0 and the dashboard collapses entities into one row.',
        pattern: /incrementCounter\s*\(\s*[a-zA-Z_$][^,)]*\)/,
        filePathFilter: (p) => /\.(js)$/.test(p) && !/\.test\./.test(p),
        allowFiles: TEST_PATH_HINTS.slice(),
    },
];

const DIMENSIONS = Object.freeze(['compliance', 'multi_tenant']);
const SEVERITIES = Object.freeze(['P0', 'P1', 'P2', 'P3']);

function isFileExempt(filePath, rule) {
    if (!filePath) return false;
    // Normalize a leading slash so `/tests/` style hints match repo-relative
    // paths that START with `tests/` (the runner emits paths without a leading
    // slash). Without this, `tests/foo.js` slips past every `/tests/` hint.
    const norm = filePath.startsWith('/') ? filePath : '/' + filePath;
    const allow = rule.allowFiles || [];
    for (const hint of allow) {
        if (norm.includes(hint)) return true;
    }
    if (typeof rule.filePathFilter === 'function') {
        if (!rule.filePathFilter(filePath, '')) return true;
    }
    return false;
}

/**
 * Scan one chunk of text against all rules; returns flat findings list.
 * @param {string} filePath
 * @param {string} text
 * @returns {{ruleId:string, dimension:string, severity:string, title:string, rationale:string, lineNo:number, excerpt:string}[]}
 */
function scanText(filePath, text) {
    if (typeof text !== 'string' || text.length === 0) return [];
    const lines = text.split('\n');
    const findings = [];
    for (const rule of RULES) {
        if (isFileExempt(filePath, rule)) continue;
        const re = new RegExp(rule.pattern.source, rule.pattern.flags.includes('g') ? rule.pattern.flags : rule.pattern.flags + 'g');
        for (let lineNo = 0; lineNo < lines.length; lineNo++) {
            const line = lines[lineNo];
            re.lastIndex = 0;
            const m = re.exec(line);
            if (m) {
                findings.push({
                    ruleId: rule.id,
                    dimension: rule.dimension,
                    severity: rule.severity,
                    title: rule.title,
                    rationale: rule.rationale,
                    filePath,
                    lineNo: lineNo + 1,
                    excerpt: line.trim().slice(0, 160),
                });
            }
        }
    }
    return findings;
}

module.exports = {
    RULES,
    DIMENSIONS,
    SEVERITIES,
    isFileExempt,
    scanText,
};
