// Pure helper functions for VCL Compliance Reporting
// No database dependencies — all functions are stateless and testable in isolation.

/**
 * Truncates text to maxLen characters with an ellipsis.
 * Returns '' for null/undefined input.
 */
function truncateText(text, maxLen = 80) {
    if (text == null) return '';
    if (text.length <= maxLen) return text;
    return text.slice(0, maxLen) + '\u2026';
}

/**
 * Validates that a remediation plan does not exceed 2000 characters.
 * Null/undefined/empty values are considered valid (no plan documented).
 */
function validateRemediationPlan(text) {
    if (text == null || text === '') return { valid: true };
    if (text.length > 2000) return { valid: false, error: 'Remediation plan exceeds 2000 characters' };
    return { valid: true };
}

/**
 * Returns true only for strings parseable as real calendar dates.
 * Rejects null, undefined, empty string, and invalid dates like "2026-02-30".
 */
function isValidDateString(str) {
    if (str == null || str === '') return false;
    if (typeof str !== 'string') return false;

    // Expect YYYY-MM-DD format
    const match = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
    if (!match) return false;

    const year = parseInt(match[1], 10);
    const month = parseInt(match[2], 10);
    const day = parseInt(match[3], 10);

    // Month must be 1-12
    if (month < 1 || month > 12) return false;

    // Create date and verify components match (catches invalid days like Feb 30)
    const date = new Date(year, month - 1, day);
    return (
        date.getFullYear() === year &&
        date.getMonth() === month - 1 &&
        date.getDate() === day
    );
}

/**
 * Formats a decimal as a whole-number percentage string.
 * Returns '0%' for null, undefined, or NaN input.
 */
function formatPct(decimal) {
    if (decimal == null || isNaN(decimal)) return '0%';
    return Math.round(decimal * 100) + '%';
}

/**
 * Computes VCL summary statistics from an array of device objects.
 * Each item should have at least { is_compliant: boolean, in_scope: boolean }.
 */
function computeVCLStats(items, targetPct) {
    const total = items.length;
    const in_scope = items.filter(item => item.in_scope).length;
    const compliant = items.filter(item => item.is_compliant).length;
    const non_compliant = in_scope - compliant;
    const remediations_required = non_compliant;
    const compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0;

    return {
        total,
        in_scope,
        compliant,
        non_compliant,
        remediations_required,
        compliance_pct,
        target_pct: targetPct,
    };
}

/**
 * Partitions non-compliant items into "blocked" (no resolution_date) and
 * "in_progress" (resolution_date set). Returns counts and percentages.
 */
function categorizeNonCompliant(items) {
    const total = items.length;
    const blocked = items.filter(item => item.resolution_date == null);
    const in_progress = items.filter(item => item.resolution_date != null);

    return {
        blocked: {
            count: blocked.length,
            pct: total > 0 ? Math.round((blocked.length / total) * 100) : 0,
        },
        in_progress: {
            count: in_progress.length,
            pct: total > 0 ? Math.round((in_progress.length / total) * 100) : 0,
        },
    };
}

/**
 * Sorts verticals by non_compliant count in descending order.
 * Returns a new sorted array (does not mutate input).
 */
function rankHeavyHitters(verticalData) {
    return [...verticalData].sort((a, b) => b.non_compliant - a.non_compliant);
}

/**
 * Buckets non-compliant items by resolution_date month (YYYY-MM).
 * Items with null resolution_date are skipped.
 * Returns an object like { '2026-05': 3, '2026-06': 7 }.
 */
function computeForecastBurndown(items) {
    const buckets = {};
    for (const item of items) {
        if (item.resolution_date == null) continue;
        const dateStr = typeof item.resolution_date === 'string'
            ? item.resolution_date
            : item.resolution_date.toISOString().slice(0, 10);
        const month = dateStr.slice(0, 7); // YYYY-MM
        buckets[month] = (buckets[month] || 0) + 1;
    }
    return buckets;
}

/**
 * Matches uploaded rows to existing hostnames.
 * Returns { matched: [...], unmatched: [...] }.
 */
function matchByHostname(uploadedRows, existingHostnames) {
    const matched = [];
    const unmatched = [];
    for (const row of uploadedRows) {
        if (existingHostnames.has(row.hostname)) {
            matched.push(row);
        } else {
            unmatched.push(row);
        }
    }
    return { matched, unmatched };
}

/**
 * Compares uploaded row values against current DB values.
 * currentData is a Map of hostname -> { resolution_date, remediation_plan, notes }.
 * Returns array of { hostname, status: 'changed'|'unchanged', fields: { fieldName: { old, new } } }.
 */
function computeBulkDiff(matchedRows, currentData) {
    const results = [];
    const COMPARE_FIELDS = ['resolution_date', 'remediation_plan', 'notes'];

    for (const row of matchedRows) {
        const current = currentData.get(row.hostname) || {};
        const fields = {};
        let hasChange = false;

        for (const field of COMPARE_FIELDS) {
            if (field in row) {
                const oldVal = current[field] != null ? current[field] : null;
                const newVal = row[field] != null ? row[field] : null;
                if (oldVal !== newVal) {
                    fields[field] = { old: oldVal, new: newVal };
                    hasChange = true;
                }
            }
        }

        results.push({
            hostname: row.hostname,
            status: hasChange ? 'changed' : 'unchanged',
            fields,
        });
    }

    return results;
}

/**
 * Maps column header strings to known field names (case-insensitive).
 * Returns a mapping object like { hostname: 0, resolution_date: 3 } where values are column indices.
 */
function mapColumnHeaders(headers) {
    const mapping = {};
    const KNOWN_MAPPINGS = {
        hostname: 'hostname',
        'resolution date': 'resolution_date',
        resolution_date: 'resolution_date',
        'remediation plan': 'remediation_plan',
        remediation_plan: 'remediation_plan',
        notes: 'notes',
    };

    for (let i = 0; i < headers.length; i++) {
        const normalized = headers[i].trim().toLowerCase();
        if (KNOWN_MAPPINGS[normalized]) {
            mapping[KNOWN_MAPPINGS[normalized]] = i;
        }
    }

    return mapping;
}

/**
 * Extracts vertical code and report date from a filename.
 * Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
 * The vertical is everything before the trailing _YYYY_MM_DD portion.
 *
 * Examples:
 *   NTS_AEO_2026_05_11.xlsx    → { vertical: 'NTS_AEO', date: '2026-05-11' }
 *   SDIT_CISO_2026_05_11.xlsx  → { vertical: 'SDIT_CISO', date: '2026-05-11' }
 *   SR_2026_05_11.xlsx         → { vertical: 'SR', date: '2026-05-11' }
 *   AllOthers_2026_05_11.xlsx  → { vertical: 'AllOthers', date: '2026-05-11' }
 *
 * Returns null if the filename does not match the expected pattern.
 */
function parseVerticalFilename(filename) {
    // Strip .xlsx extension (case-insensitive)
    const stem = filename.replace(/\.xlsx$/i, '');
    // Match: everything up to the last _YYYY_MM_DD
    const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
    if (!match) return null;

    const vertical = match[1];
    const date = `${match[2]}-${match[3]}-${match[4]}`;

    // Validate the date portion is a real date
    if (!isValidDateString(date)) return null;

    return { vertical, date };
}

/**
 * Computes per-vertical burndown forecast from non-compliant items.
 * Returns breakdown of items with/without resolution dates and monthly projections.
 */
function computeVerticalBurndown(items) {
    const total = items.length;
    const withDates = items.filter(i => i.resolution_date != null);
    const blockers = items.filter(i => i.resolution_date == null);

    // Bucket by month
    const monthly = {};
    for (const item of withDates) {
        const dateStr = typeof item.resolution_date === 'string'
            ? item.resolution_date
            : item.resolution_date.toISOString().slice(0, 10);
        const month = dateStr.slice(0, 7); // YYYY-MM
        monthly[month] = (monthly[month] || 0) + 1;
    }

    // Cumulative projection — how many remain after each month
    let remaining = total;
    const projection = {};
    for (const month of Object.keys(monthly).sort()) {
        remaining -= monthly[month];
        projection[month] = { remediated: monthly[month], remaining };
    }

    // Projected clear date — first month where remaining hits 0 (excluding blockers)
    let projectedClearDate = null;
    if (blockers.length === 0 && Object.keys(projection).length > 0) {
        const sortedMonths = Object.keys(projection).sort();
        projectedClearDate = sortedMonths[sortedMonths.length - 1];
    }

    return {
        total,
        blockers: blockers.length,
        with_dates: withDates.length,
        monthly,
        projection,
        projected_clear_date: projectedClearDate,
    };
}

/**
 * Deduplicates devices by hostname, keeping the earliest non-null resolution_date.
 * A device appearing in multiple metrics counts once.
 *
 * @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} items
 * @returns {Array<{ hostname: string, resolution_date: string|null, vertical: string }>}
 */
function deduplicateByHostname(items) {
    const map = {};
    for (const item of items) {
        const key = item.hostname;
        if (!map[key]) {
            map[key] = { hostname: item.hostname, resolution_date: item.resolution_date || null, vertical: item.vertical };
        } else {
            // Keep the earliest non-null resolution_date
            const existing = map[key];
            if (item.resolution_date != null) {
                if (existing.resolution_date == null || item.resolution_date < existing.resolution_date) {
                    existing.resolution_date = item.resolution_date;
                }
            }
        }
    }
    return Object.values(map);
}

/**
 * Computes aggregated burndown from a deduplicated array of device objects.
 * Each device has { hostname, resolution_date, vertical }.
 *
 * @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} devices
 * @returns {{
 *   total: number,
 *   blockers: number,
 *   with_dates: number,
 *   monthly: Object<string, number>,
 *   projection: Object<string, { remediated: number, remaining: number }>,
 *   projected_clear_date: string|null,
 *   by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }>
 * }}
 */
function computeAggregatedBurndown(devices) {
    const total = devices.length;
    const withDates = devices.filter(d => d.resolution_date != null);
    const blockerDevices = devices.filter(d => d.resolution_date == null);
    const blockers = blockerDevices.length;
    const with_dates = withDates.length;

    // Bucket by month (YYYY-MM)
    const monthly = {};
    for (const device of withDates) {
        const dateStr = typeof device.resolution_date === 'string'
            ? device.resolution_date
            : device.resolution_date.toISOString().slice(0, 10);
        const month = dateStr.slice(0, 7);
        monthly[month] = (monthly[month] || 0) + 1;
    }

    // Sort monthly keys chronologically
    const sortedMonths = Object.keys(monthly).sort();
    const sortedMonthly = {};
    for (const m of sortedMonths) {
        sortedMonthly[m] = monthly[m];
    }

    // Cumulative projection
    let remaining = total;
    const projection = {};
    for (const month of sortedMonths) {
        remaining -= sortedMonthly[month];
        projection[month] = { remediated: sortedMonthly[month], remaining };
    }

    // Projected clear date
    let projected_clear_date = null;
    if (blockers === 0 && sortedMonths.length > 0) {
        projected_clear_date = sortedMonths[sortedMonths.length - 1];
    }

    // Per-vertical breakdown
    const verticalMap = {};
    for (const device of devices) {
        const v = device.vertical;
        if (!verticalMap[v]) {
            verticalMap[v] = { vertical: v, total: 0, blockers: 0, with_dates: 0 };
        }
        verticalMap[v].total++;
        if (device.resolution_date == null) {
            verticalMap[v].blockers++;
        } else {
            verticalMap[v].with_dates++;
        }
    }

    // Sort descending by total, filter out zero-total entries
    const by_vertical = Object.values(verticalMap)
        .filter(v => v.total > 0)
        .sort((a, b) => b.total - a.total);

    return {
        total,
        blockers,
        with_dates,
        monthly: sortedMonthly,
        projection,
        projected_clear_date,
        by_vertical,
    };
}

/**
 * Computes per-metric forecast burndown from device records and historical snapshots.
 *
 * Pure function — no side effects, no database access. Suitable for property-based testing.
 *
 * @param {Array<{hostname: string, resolution_date: string|null}>} currentDevices
 *   Active non-compliant devices for the metric
 * @param {number} totalAssets
 *   Total device count in scope for this metric (from snapshot or summary)
 * @param {Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>} historicalSnapshots
 *   Pre-computed historical data points (up to 4 months)
 * @returns {{
 *   historical: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
 *   forecast: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
 *   current_snapshot: {total_assets: number, non_compliant: number, compliant: number, compliance_pct: number, blockers: number, with_dates: number}
 * }}
 */
function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots) {
    // Compute compliance_pct helper
    function calcCompliancePct(total, nc) {
        if (total === 0) return 0;
        return Math.round(((total - nc) / total) * 1000) / 10;
    }

    // Historical — pass through as-is
    const historical = (historicalSnapshots || []).map(snap => ({
        month: snap.month,
        total_assets: snap.total_assets,
        non_compliant: snap.non_compliant,
        compliance_pct: snap.compliance_pct,
    }));

    // Requirement 3.7: empty currentDevices → empty forecast, zeroed snapshot except total_assets
    if (!currentDevices || currentDevices.length === 0) {
        return {
            historical,
            forecast: [],
            current_snapshot: {
                total_assets: totalAssets,
                non_compliant: 0,
                compliant: 0,
                compliance_pct: 0,
                blockers: 0,
                with_dates: 0,
            },
        };
    }

    const nonCompliant = currentDevices.length;

    // Partition devices into blockers (no resolution_date) and with_dates
    const blockers = currentDevices.filter(d => d.resolution_date == null).length;
    const withDates = nonCompliant - blockers;

    // Current snapshot
    const compliant = totalAssets - nonCompliant;
    const currentCompliancePct = calcCompliancePct(totalAssets, nonCompliant);

    const current_snapshot = {
        total_assets: totalAssets,
        non_compliant: nonCompliant,
        compliant: compliant,
        compliance_pct: currentCompliancePct,
        blockers: blockers,
        with_dates: withDates,
    };

    // If no devices have resolution dates, return empty forecast
    if (withDates === 0) {
        return { historical, forecast: [], current_snapshot };
    }

    // Determine current month (YYYY-MM)
    const now = new Date();
    const currentYear = now.getFullYear();
    const currentMonth = now.getMonth(); // 0-indexed

    function formatMonth(year, month) {
        return `${year}-${String(month + 1).padStart(2, '0')}`;
    }

    const currentMonthStr = formatMonth(currentYear, currentMonth);

    // Bucket devices with resolution dates by their resolution month
    // Past-due dates (month before current month) are treated as remediated in current month
    const buckets = {};
    for (const device of currentDevices) {
        if (device.resolution_date == null) continue;
        // Handle both Date objects (from PostgreSQL) and YYYY-MM-DD strings
        const dateStr = device.resolution_date instanceof Date
            ? device.resolution_date.toISOString().slice(0, 7)
            : String(device.resolution_date).slice(0, 7);
        const resMonth = dateStr; // YYYY-MM
        if (resMonth < currentMonthStr) {
            // Past-due: treat as remediated in current month
            buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1;
        } else {
            buckets[resMonth] = (buckets[resMonth] || 0) + 1;
        }
    }

    // Generate forecast months starting from NEXT month, up to 12 months max
    const forecast = [];
    let remainingNonCompliant = nonCompliant;

    // Account for devices remediated in the current month (past-due dates bucketed here)
    if (buckets[currentMonthStr]) {
        remainingNonCompliant -= buckets[currentMonthStr];
    }

    for (let i = 1; i <= 12; i++) {
        const forecastYear = currentYear + Math.floor((currentMonth + i) / 12);
        const forecastMonth = (currentMonth + i) % 12;
        const monthStr = formatMonth(forecastYear, forecastMonth);

        // Decrement by devices remediated in this month
        if (buckets[monthStr]) {
            remainingNonCompliant -= buckets[monthStr];
        }

        const pct = calcCompliancePct(totalAssets, remainingNonCompliant);

        forecast.push({
            month: monthStr,
            total_assets: totalAssets,
            non_compliant: remainingNonCompliant,
            compliance_pct: pct,
        });

        // Terminate early if all dated devices are remediated (only blockers remain)
        if (remainingNonCompliant <= blockers) {
            break;
        }
    }

    return { historical, forecast, current_snapshot };
}

module.exports = {
    truncateText,
    validateRemediationPlan,
    isValidDateString,
    formatPct,
    computeVCLStats,
    categorizeNonCompliant,
    rankHeavyHitters,
    computeForecastBurndown,
    matchByHostname,
    computeBulkDiff,
    mapColumnHeaders,
    parseVerticalFilename,
    computeVerticalBurndown,
    deduplicateByHostname,
    computeAggregatedBurndown,
    computeMetricForecastBurndown,
};
