import AnalyticsDatabaseService from "./AnalyticsDatabaseService";
import MetricBaselineHourly from "../../Models/AnalyticsModels/MetricBaselineHourly";
import logger from "../Utils/Logger";
import ObjectID from "../../Types/ObjectID";
/**
 * Read-side service for the `MetricBaselineHourly` MV target table.
 *
 * The table itself is declared by the {@link MetricBaselineHourly}
 * model and created either by app startup auto-create or by the
 * `AddMetricBaselineHourlyMV` data migration (whichever runs first —
 * both are idempotent). Rows are populated by the attached MV; this
 * service only reads.
 *
 * Callers use this service through:
 *   - `getBaseline(...)`     — single (metric, hour-of-week) cell.
 *   - `getCoverage(...)`     — total samples & oldest day for a metric.
 *   - `getBandSeries(...)`   — chart band data over a time window.
 *   - `computeHourOfWeek()`  — pure helper kept here so the eval-time
 *                              and MV-write-time formulas stay aligned.
 */
export class MetricBaselineService extends AnalyticsDatabaseService {
    constructor(clickhouseDatabase) {
        super({ modelType: MetricBaselineHourly, database: clickhouseDatabase });
    }
    /**
     * Fetch the rolling-window baseline for one (metric, [entity],
     * hour-of-week) cell. Aggregates the AggregateFunction states across
     * the last `windowDays` days. Returns null when no data exists.
     *
     * `primaryEntityId` is optional: when omitted, baselines are
     * aggregated across every primaryEntityId that ingested the metric in
     * the window. Useful for monitor criteria whose query isn't scoped to
     * a single telemetry service.
     */
    async getBaseline(input) {
        const windowDays = Math.min(input.windowDays || MetricBaselineService.DEFAULT_WINDOW_DAYS, MetricBaselineService.MAX_WINDOW_DAYS);
        const minSamples = input.minSamples || MetricBaselineService.DEFAULT_MIN_SAMPLES;
        const projectIdStr = this.escapeString(input.projectId instanceof ObjectID
            ? input.projectId.toString()
            : input.projectId);
        const metricNameStr = this.escapeString(input.metricName);
        const hour = Math.max(0, Math.min(167, Math.floor(input.hourOfWeek)));
        const primaryEntityIdClause = input.primaryEntityId
            ? `AND primaryEntityId = '${this.escapeString(input.primaryEntityId instanceof ObjectID
                ? input.primaryEntityId.toString()
                : input.primaryEntityId)}'`
            : "";
        const sql = `
      SELECT
        countMerge(sampleCountState)                AS sampleCount,
        avgMerge(meanState)                         AS mean,
        stddevPopMerge(stddevState)                 AS stddev,
        quantileBFloat16Merge(0.5)(medianState)     AS median,
        quantileBFloat16Merge(0.95)(p95State)       AS p95,
        minMerge(minObsState)                       AS minObserved,
        maxMerge(maxObsState)                       AS maxObserved
      FROM MetricBaselineHourly
      WHERE projectId = '${projectIdStr}'
        AND name = '${metricNameStr}'
        ${primaryEntityIdClause}
        AND hourOfWeek = ${hour}
        AND day >= today() - INTERVAL ${windowDays} DAY
    `;
        const resultSet = (await this.executeQuery(sql));
        const parsed = await resultSet.json();
        const row = parsed.data[0];
        if (!row) {
            return null;
        }
        const sampleCount = this.toNumber(row["sampleCount"]);
        if (!Number.isFinite(sampleCount) || sampleCount === 0) {
            return null;
        }
        const summary = {
            sampleCount,
            mean: this.toNumber(row["mean"]),
            stddev: this.toNumber(row["stddev"]),
            median: this.toNumber(row["median"]),
            p95: this.toNumber(row["p95"]),
            minObserved: this.toNumber(row["minObserved"]),
            maxObserved: this.toNumber(row["maxObserved"]),
            isReliable: sampleCount >= minSamples,
            windowDays,
            hourOfWeek: hour,
        };
        logger.debug("MetricBaselineService.getBaseline", {
            projectId: projectIdStr,
            metricName: metricNameStr,
            hourOfWeek: hour,
            sampleCount,
            isReliable: summary.isReliable,
        });
        return summary;
    }
    /**
     * Coverage probe: how much baseline data we have for a (metric,
     * entity) pair. Used by the form UI to show "Learning — N days of
     * data, baseline ready in M days".
     */
    async getCoverage(input) {
        const projectIdStr = this.escapeString(input.projectId instanceof ObjectID
            ? input.projectId.toString()
            : input.projectId);
        const metricNameStr = this.escapeString(input.metricName);
        const primaryEntityIdClause = input.primaryEntityId
            ? `AND primaryEntityId = '${this.escapeString(input.primaryEntityId instanceof ObjectID
                ? input.primaryEntityId.toString()
                : input.primaryEntityId)}'`
            : "";
        const sql = `
      SELECT
        countMerge(sampleCountState) AS totalSamples,
        toString(min(day))           AS oldestDay
      FROM MetricBaselineHourly
      WHERE projectId = '${projectIdStr}'
        AND name = '${metricNameStr}'
        ${primaryEntityIdClause}
        AND day >= today() - INTERVAL ${MetricBaselineService.MAX_WINDOW_DAYS} DAY
    `;
        const resultSet = (await this.executeQuery(sql));
        const parsed = await resultSet.json();
        const row = parsed.data[0];
        if (!row) {
            return { totalSamples: 0, oldestDay: null };
        }
        const totalSamples = this.toNumber(row["totalSamples"]);
        const oldestDayParsed = new Date(row["oldestDay"] || "");
        const oldestDay = isNaN(oldestDayParsed.getTime())
            ? null
            : oldestDayParsed;
        return { totalSamples, oldestDay };
    }
    /**
     * Build a series of (time, mean, expectedHigh, expectedLow) points
     * spanning [startTime, endTime] in `intervalMinutes` steps. Each
     * point's expected band is the same-hour-of-week baseline computed
     * from the rolling window. Powers the shaded "expected range" band
     * on metric explorer charts.
     */
    async getBandSeries(input) {
        const windowDays = Math.min(input.windowDays || MetricBaselineService.DEFAULT_WINDOW_DAYS, MetricBaselineService.MAX_WINDOW_DAYS);
        const minSamples = input.minSamples || MetricBaselineService.DEFAULT_MIN_SAMPLES;
        const projectIdStr = this.escapeString(input.projectId instanceof ObjectID
            ? input.projectId.toString()
            : input.projectId);
        const metricNameStr = this.escapeString(input.metricName);
        const primaryEntityIdClause = input.primaryEntityId
            ? `AND primaryEntityId = '${this.escapeString(input.primaryEntityId instanceof ObjectID
                ? input.primaryEntityId.toString()
                : input.primaryEntityId)}'`
            : "";
        const sql = `
      SELECT
        hourOfWeek,
        countMerge(sampleCountState) AS sampleCount,
        avgMerge(meanState)          AS mean,
        stddevPopMerge(stddevState)  AS stddev
      FROM MetricBaselineHourly
      WHERE projectId = '${projectIdStr}'
        AND name = '${metricNameStr}'
        ${primaryEntityIdClause}
        AND day >= today() - INTERVAL ${windowDays} DAY
      GROUP BY hourOfWeek
    `;
        const resultSet = (await this.executeQuery(sql));
        const parsed = await resultSet.json();
        const byHour = new Map();
        for (const row of parsed.data) {
            const hour = this.toNumber(row.hourOfWeek);
            const samples = this.toNumber(row.sampleCount);
            if (samples < minSamples) {
                continue;
            }
            byHour.set(hour, {
                mean: this.toNumber(row.mean),
                stddev: this.toNumber(row.stddev),
            });
        }
        const out = [];
        const stepMs = Math.max(1, input.intervalMinutes) * 60000;
        const startMs = input.startTime.getTime();
        const endMs = input.endTime.getTime();
        for (let t = startMs; t <= endMs; t += stepMs) {
            const point = new Date(t);
            const hour = MetricBaselineService.computeHourOfWeek(point);
            const baseline = byHour.get(hour);
            if (!baseline) {
                continue;
            }
            const half = input.sigmaCount * baseline.stddev;
            out.push({
                time: point,
                mean: baseline.mean,
                expectedHigh: baseline.mean + half,
                expectedLow: baseline.mean - half,
            });
        }
        return out;
    }
    /**
     * Compute hour-of-week 0..167 for a Date the same way the MV does:
     * `(toDayOfWeek(time, 1) - 1) * 24 + toHour(time)` with ISO week
     * numbering (Monday = 1, Sunday = 7). Pure function — kept here so
     * the eval-time anomaly check and the band query agree.
     */
    static computeHourOfWeek(date) {
        // JS getDay(): Sun=0..Sat=6. Convert to ISO 1..7 (Mon..Sun).
        const jsDow = date.getDay();
        const isoDow = jsDow === 0 ? 7 : jsDow;
        const hour = date.getHours();
        return (isoDow - 1) * 24 + hour;
    }
    /**
     * Map sensitivity level to sigma multiplier. Tighter sensitivity =
     * lower sigma threshold = more alerts.
     */
    static sigmaForSensitivity(sensitivity) {
        switch (sensitivity) {
            case "Low":
                return 4;
            case "High":
                return 2;
            case "Medium":
            default:
                return 3;
        }
    }
    toNumber(v) {
        if (typeof v === "number") {
            return v;
        }
        const n = Number(v);
        return Number.isFinite(n) ? n : 0;
    }
    escapeString(v) {
        // ClickHouse string-literal escape for backslash and single quote.
        return v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
    }
}
/**
 * Default minimum samples per (hour-of-week) cell to call a baseline
 * reliable. Below this, callers treat the baseline as cold-start.
 */
MetricBaselineService.DEFAULT_MIN_SAMPLES = 5;
/**
 * Default rolling-window length in days when the criterion does not
 * specify one. 14 days is the smallest window that gives ≥ 2 samples
 * per (hour-of-week) bucket for typical projects, which combined with
 * the `min_samples` reliability gate yields baselines that don't fire
 * on every weekly traffic cycle.
 */
MetricBaselineService.DEFAULT_WINDOW_DAYS = 14;
/**
 * Hard upper bound on the rolling window. Locked to the MV target
 * table's TTL — querying further back would just hit empty space
 * because the rows are already gone. Bump this in lockstep with the
 * `MetricBaselineHourly` model's `ttlExpression` and any matching
 * ALTER-TTL migration; otherwise the read-side will silently truncate
 * caller intent.
 */
MetricBaselineService.MAX_WINDOW_DAYS = 90;
/**
 * Window options exposed by the criterion form. Constrained to a
 * small set so the UI stays a dropdown and so we don't have to TTL
 * the table for every conceivable value. Adding a longer option
 * requires bumping `MAX_WINDOW_DAYS` and the table TTL together.
 */
MetricBaselineService.WINDOW_DAYS_OPTIONS = [
    14, 28, 60, 90,
];
export default new MetricBaselineService();
//# sourceMappingURL=MetricBaselineService.js.map