import { Agent } from "node:https";
import type { ObservabilityConnector } from "./interface.js";
import type {
  SourceConfig,
  SourceAuth,
  ConnectorHealth,
  ServiceInfo,
  MetricInfo,
  MetricQuery,
  MetricResult,
  MetricGroup,
  MetricDefinition,
  DataPoint,
  Trend,
  SignalType,
} from "../types.js";
import { buildTlsAgent } from "./tls.js";

// Each synthetic metric maps to a list of candidate (seriesName, query)
// pairs ordered by preference. At query time the connector probes which
// series actually exist in the backend (via /api/v1/label/__name__/values)
// and uses the first match. This lets the same MCP work for prom-client
// apps and node_exporter hosts without per-source configuration.
//
// {{selector}} is replaced at query time with the discovered label=value
// pair (e.g. job="my-svc"); the connector probes job → service → app →
// service_name. {{service}} (literal value) stays supported for user
// overrides.
type MetricCandidate = {
  seriesName: string;
  query: string;             // single aggregated series
  groupedQuery?: string;     // {{groupBy}} placeholder; used when caller passes groupBy
};

const PROMETHEUS_METRIC_CANDIDATES: Record<string, MetricCandidate[]> = {
  cpu: [
    {
      seriesName: "process_cpu_seconds_total",
      // rate() preserves all labels — already broken down per-instance.
      query: 'rate(process_cpu_seconds_total{ {{selector}} }[1m]) * 100',
    },
    {
      seriesName: "node_cpu_seconds_total",
      query: '100 - avg(rate(node_cpu_seconds_total{ {{selector}}, mode="idle" }[1m])) * 100',
      groupedQuery: '100 - avg by({{groupBy}}) (rate(node_cpu_seconds_total{ {{selector}}, mode="idle" }[1m])) * 100',
    },
  ],
  memory: [
    {
      seriesName: "process_resident_memory_bytes",
      query: 'process_resident_memory_bytes{ {{selector}} }',
    },
    {
      seriesName: "node_memory_MemTotal_bytes",
      query: '(node_memory_MemTotal_bytes{ {{selector}} } - node_memory_MemAvailable_bytes{ {{selector}} })',
    },
  ],
  request_rate: [
    {
      seriesName: "http_requests_total",
      query: 'sum(rate(http_requests_total{ {{selector}} }[1m]))',
      groupedQuery: 'sum by({{groupBy}}) (rate(http_requests_total{ {{selector}} }[1m]))',
    },
  ],
  error_rate: [
    {
      seriesName: "http_requests_total",
      query: 'sum(rate(http_requests_total{ {{selector}}, status=~"5.." }[1m]))',
      groupedQuery: 'sum by({{groupBy}}) (rate(http_requests_total{ {{selector}}, status=~"5.." }[1m]))',
    },
  ],
  latency_p99: [
    {
      seriesName: "http_request_duration_seconds_bucket",
      query: 'histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{ {{selector}} }[1m])) by (le))',
      groupedQuery: 'histogram_quantile(0.99, sum by(le, {{groupBy}}) (rate(http_request_duration_seconds_bucket{ {{selector}} }[1m])))',
    },
  ],
  latency_p50: [
    {
      seriesName: "http_request_duration_seconds_bucket",
      query: 'histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{ {{selector}} }[1m])) by (le))',
      groupedQuery: 'histogram_quantile(0.50, sum by(le, {{groupBy}}) (rate(http_request_duration_seconds_bucket{ {{selector}} }[1m])))',
    },
  ],
  latency_avg: [
    {
      seriesName: "http_request_duration_seconds_sum",
      query: 'sum(rate(http_request_duration_seconds_sum{ {{selector}} }[1m])) / sum(rate(http_request_duration_seconds_count{ {{selector}} }[1m]))',
      groupedQuery: 'sum by({{groupBy}}) (rate(http_request_duration_seconds_sum{ {{selector}} }[1m])) / sum by({{groupBy}}) (rate(http_request_duration_seconds_count{ {{selector}} }[1m]))',
    },
  ],
};

// Common breakdown labels probed for the auto-hint when no groupBy is set.
const HINT_BREAKDOWN_LABELS = ["instance", "pod"];

const DEFAULT_METRIC_META: Record<string, { unit: string; description: string }> = {
  cpu:          { unit: "percent", description: "CPU usage % (auto: prom-client process_cpu_seconds_total or node_exporter node_cpu_seconds_total)" },
  memory:       { unit: "bytes",   description: "Resident memory bytes (auto: prom-client process_resident_memory_bytes or node_memory used)" },
  request_rate: { unit: "req/s",   description: "HTTP request rate (http_requests_total)" },
  error_rate:   { unit: "req/s",   description: "HTTP 5xx error rate (http_requests_total filtered by status)" },
  latency_p99:  { unit: "seconds", description: "99th percentile latency (http_request_duration_seconds_bucket)" },
  latency_p50:  { unit: "seconds", description: "50th percentile latency (http_request_duration_seconds_bucket)" },
  latency_avg:  { unit: "seconds", description: "Average request latency (sum/count ratio)" },
};

const DEFAULT_PROMETHEUS_METRICS: MetricDefinition[] = Object.keys(PROMETHEUS_METRIC_CANDIDATES).map((name) => ({
  name,
  query: PROMETHEUS_METRIC_CANDIDATES[name][0].query,
  unit: DEFAULT_METRIC_META[name].unit,
  description: DEFAULT_METRIC_META[name].description,
}));

const DEFAULT_SERVICE_LABELS = ["job", "service", "app", "service_name"];
const LABEL_CACHE_TTL_MS = 60_000;

export class PrometheusConnector implements ObservabilityConnector {
  readonly type = "prometheus";
  readonly signalType: SignalType = "metrics";
  name = "";
  private baseUrl = "";
  private auth?: SourceAuth;
  private tlsAgent?: Agent;
  private metrics: MetricDefinition[] = [];
  private serviceLabels: string[] = DEFAULT_SERVICE_LABELS;
  private labelValuesCache = new Map<string, { values: string[]; expiresAt: number }>();
  private candidateCache = new Map<string, { candidate: MetricCandidate; expiresAt: number }>();
  private userOverrides = new Set<string>();

  async connect(config: SourceConfig): Promise<void> {
    this.name = config.name;
    this.baseUrl = config.url.replace(/\/$/, "");
    this.auth = config.auth;
    this.tlsAgent = buildTlsAgent(config);
    // Source-level overrides merge with defaults by name, so users can pin
    // a single metric (e.g. cpu) to a custom query without re-listing the
    // rest. Overridden metrics skip candidate probing — the user-supplied
    // query is used verbatim.
    this.userOverrides = new Set((config.metrics || []).map((m) => m.name));
    const overrides = new Map((config.metrics || []).map((m) => [m.name, m]));
    this.metrics = DEFAULT_PROMETHEUS_METRICS.map((d) => overrides.get(d.name) || d);
    for (const [name, m] of overrides) {
      if (!DEFAULT_PROMETHEUS_METRICS.some((d) => d.name === name)) {
        this.metrics.push(m);
      }
    }
    const envLabels = process.env.PROMETHEUS_SERVICE_LABELS;
    if (envLabels) {
      this.serviceLabels = envLabels.split(",").map((s) => s.trim()).filter(Boolean);
    }
  }

  getDefaultMetrics(): MetricDefinition[] {
    return DEFAULT_PROMETHEUS_METRICS;
  }

  getMetrics(): MetricDefinition[] {
    return this.metrics;
  }

  setMetrics(metrics: MetricDefinition[]) {
    this.metrics = metrics;
  }

  async healthCheck(): Promise<ConnectorHealth> {
    const start = Date.now();
    try {
      // Use the query API instead of /-/ready: works on both OSS Prometheus
      // and managed offerings (Grafana Cloud / Mimir, AWS Managed Prometheus,
      // Chronosphere) which do not expose the operational health endpoint.
      // 'up' is a synthetic metric guaranteed to exist on any Prometheus.
      const res = await fetch(
        `${this.baseUrl}/api/v1/query?query=up`,
        this.fetchOptions()
      );
      return {
        status: res.ok ? "up" : "down",
        latencyMs: Date.now() - start,
        message: res.ok ? "Prometheus is ready" : `HTTP ${res.status}`,
      };
    } catch (err) {
      return { status: "down", latencyMs: Date.now() - start, message: String(err) };
    }
  }

  async disconnect(): Promise<void> {}

  async listServices(): Promise<ServiceInfo[]> {
    // Prefer /api/v1/targets — gives full label detail incl. service/job/address.
    // Managed Prometheus (Mimir, AMP, Chronosphere) returns 404 on this path
    // because targets are an operational concept of the OSS scraper. Fall back
    // to /api/v1/label/job/values, which is the canonical query-time source
    // for service names and is supported everywhere.
    try {
      const data = await this.apiGet<{ data: { activeTargets: PromTarget[] } }>(
        "/api/v1/targets"
      );
      const targets = data?.data?.activeTargets || [];
      if (targets.length === 0) {
        return await this.listServicesFromJobLabel();
      }
      const services = new Map<string, ServiceInfo>();
      for (const t of targets) {
        const name =
          t.labels?.service || t.labels?.job || t.discoveredLabels?.__address__ || "unknown";
        if (!services.has(name)) {
          services.set(name, {
            name,
            source: this.name,
            signalType: "metrics",
            labels: t.labels,
          });
        }
      }
      return Array.from(services.values());
    } catch (err) {
      const msg = String(err);
      if (msg.includes("404")) {
        return await this.listServicesFromJobLabel();
      }
      throw err;
    }
  }

  private async listServicesFromJobLabel(): Promise<ServiceInfo[]> {
    const data = await this.apiGet<{ data: string[] }>("/api/v1/label/job/values");
    const jobs = data?.data || [];
    return jobs.map((name) => ({
      name,
      source: this.name,
      signalType: "metrics" as const,
    }));
  }

  async listAvailableMetrics(_service: string): Promise<MetricInfo[]> {
    const data = await this.apiGet<{ data: Record<string, MetadataEntry[]> }>(
      "/api/v1/metadata"
    );
    if (!data?.data) return [];

    const metrics: MetricInfo[] = [];
    for (const [name, entries] of Object.entries(data.data)) {
      const entry = entries[0];
      if (entry) {
        metrics.push({ name, type: entry.type, help: entry.help, unit: entry.unit || undefined });
      }
    }
    return metrics;
  }

  async queryMetrics(params: MetricQuery): Promise<MetricResult> {
    // Raw passthrough: the caller supplied verbatim PromQL (capability-gated
    // at the tool layer). Skip the curated catalog/selector machinery and the
    // breakdown-hint probe; run the query as-is over query_range.
    if (params.rawQuery) {
      return this.queryRaw(params.rawQuery, params.duration, params.step);
    }
    const { promql, label, candidate } = await this.buildQuery(params.service, params.metric, params.groupBy, params.labels);
    const { start, end, step } = this.parseTimeRange(params.duration, params.step);

    const data = await this.apiGet<{ data: PromQueryRangeResult }>(
      `/api/v1/query_range?query=${encodeURIComponent(promql)}&start=${start}&end=${end}&step=${step}`
    );

    const seriesList = data?.data?.result || [];

    // Build groups from each returned series, keyed either by the explicit
    // groupBy label (when grouped) or by a synthesized name from any
    // remaining labels (for naturally per-instance queries like cpu/memory
    // prom-client). Empty when nothing came back.
    const groups: MetricGroup[] = [];
    for (const series of seriesList) {
      const seriesValues: DataPoint[] = [];
      const rawValues: number[] = [];
      for (const [ts, val] of series.values || []) {
        const numVal = parseFloat(val as string);
        if (!isNaN(numVal)) {
          seriesValues.push({ timestamp: new Date(ts * 1000).toISOString(), value: numVal });
          rawValues.push(numVal);
        }
      }
      groups.push({
        key: this.groupKey(series.metric || {}, params.groupBy),
        values: seriesValues,
        summary: this.computeSummary(rawValues),
      });
    }

    // Top-level values/summary always reflect the first series (back-compat:
    // single-aggregated queries always return one row, so this is unchanged).
    const top = groups[0] || { values: [], summary: this.computeSummary([]) };

    const result: MetricResult = {
      source: this.name,
      service: params.service,
      metric: params.metric,
      unit: this.getUnit(params.metric),
      values: top.values,
      summary: top.summary,
      resolvedSeries: promql,
      resolvedLabel: label,
    };

    if (!result.summary) {
      result.note = `No data: no '${params.metric}' series matched "${params.service}" in this window. ` +
        "The service may expose logs only, or the metric name/label didn't match. Absent ≠ zero — summary is null rather than all-zeros.";
    }

    if (params.groupBy && groups.length > 1) {
      result.groupBy = params.groupBy;
      result.groups = groups;
    } else if (!params.groupBy && candidate) {
      // Probe common breakdown labels and hint when more than one distinct
      // value exists for this service. Helps the model ask the right
      // follow-up instead of silently looking at an aggregated number.
      const escaped = params.service.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
      for (const breakdownLabel of HINT_BREAKDOWN_LABELS) {
        const distinct = await this.getDistinctLabelValues(
          candidate.seriesName,
          label,
          escaped,
          breakdownLabel
        );
        if (distinct.length > 1) {
          result.hint = `${distinct.length} distinct ${breakdownLabel}s exist for this service. Pass groupBy="${breakdownLabel}" to break the result down.`;
          break;
        }
      }
    }

    return result;
  }

  // Raw PromQL passthrough — used by queryMetrics when params.rawQuery is set.
  // Returns the same MetricResult shape: one group per returned series, the
  // top-level values/summary mirroring the first series. service/metric are
  // reported as "(raw)" since the curated catalog doesn't apply.
  private async queryRaw(rawQuery: string, duration: string, step?: string): Promise<MetricResult> {
    const { start, end, step: resolvedStep } = this.parseTimeRange(duration, step);
    const data = await this.apiGet<{ data: PromQueryRangeResult }>(
      `/api/v1/query_range?query=${encodeURIComponent(rawQuery)}&start=${start}&end=${end}&step=${resolvedStep}`
    );
    const seriesList = data?.data?.result || [];
    const groups: MetricGroup[] = [];
    for (const series of seriesList) {
      const seriesValues: DataPoint[] = [];
      const rawValues: number[] = [];
      for (const [ts, val] of series.values || []) {
        const numVal = parseFloat(val as string);
        if (!isNaN(numVal)) {
          seriesValues.push({ timestamp: new Date(ts * 1000).toISOString(), value: numVal });
          rawValues.push(numVal);
        }
      }
      groups.push({
        key: this.groupKey(series.metric || {}),
        values: seriesValues,
        summary: this.computeSummary(rawValues),
      });
    }
    const top = groups[0] || { values: [], summary: this.computeSummary([]) };
    const result: MetricResult = {
      source: this.name,
      service: "(raw)",
      metric: "(raw)",
      unit: "",
      values: top.values,
      summary: top.summary,
      resolvedSeries: rawQuery,
      resolvedLabel: "",
    };
    if (!result.summary) {
      result.note = "No data: the query returned no series in this window. Absent ≠ zero — summary is null rather than all-zeros.";
    }
    if (groups.length > 1) result.groups = groups;
    return result;
  }

  // --- Private helpers ---

  private async buildQuery(
    service: string,
    metric: string,
    groupBy?: string,
    labels?: Record<string, string>
  ): Promise<{ promql: string; label: string; candidate: MetricCandidate | null }> {
    // Resolve the service-filter label first. Candidate probing uses this
    // label to scope existence checks per-service rather than per-source.
    const escaped = service.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
    let label = "job";
    let template: string;
    let candidate: MetricCandidate | null = null;

    if (!this.userOverrides.has(metric) && PROMETHEUS_METRIC_CANDIDATES[metric]) {
      label = await this.resolveServiceLabel(service);
      candidate = await this.pickMetricCandidate(metric, label, escaped);
      const fallback = PROMETHEUS_METRIC_CANDIDATES[metric][0];
      const chosen = candidate || fallback;
      template = (groupBy && chosen.groupedQuery) ? chosen.groupedQuery : chosen.query;
    } else {
      const def = this.metrics.find((m) => m.name === metric);
      template = def?.query || `${metric}{ {{selector}} }`;
    }

    // Extra exact-match label filters (caller-supplied), AND'd into every
    // {{selector}} occurrence. Label names are constrained to the safe
    // Prometheus identifier set (the tool layer already validated them, but
    // we re-constrain here so the connector is safe in isolation); values are
    // escaped for the surrounding PromQL double-quoted string, same as the
    // service value above.
    let extraMatchers = "";
    if (labels) {
      const parts: string[] = [];
      for (const [k, v] of Object.entries(labels)) {
        if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k)) continue;
        // Escape backslash first, then quote, then control chars — matches
        // the Loki escapeLogQLValue sibling so a value with a newline yields
        // a valid PromQL string literal rather than a 400 parse error.
        const ev = v
          .replace(/\\/g, "\\\\")
          .replace(/"/g, '\\"')
          .replace(/\n/g, "\\n")
          .replace(/\r/g, "\\r")
          .replace(/\t/g, "\\t");
        parts.push(`${k}="${ev}"`);
      }
      if (parts.length) extraMatchers = ", " + parts.join(", ");
    }

    let promql = template;
    if (template.includes("{{selector}}")) {
      // Resolve label here for non-candidate paths that haven't done it yet.
      if (label === "job" && !PROMETHEUS_METRIC_CANDIDATES[metric]) {
        label = await this.resolveServiceLabel(service);
      }
      const selector = `${label}="${escaped}"${extraMatchers}`;
      promql = promql.replace(/\{\{selector\}\}/g, selector);
    }
    if (groupBy && template.includes("{{groupBy}}")) {
      // groupBy is a label name (caller-supplied). Constrain to the same
      // safe character set we use for service names so it can't break out
      // of the by(...) clause.
      const safe = groupBy.replace(/[^a-zA-Z0-9_]/g, "");
      promql = promql.replace(/\{\{groupBy\}\}/g, safe);
    }
    promql = promql.replace(/\{\{service\}\}/g, escaped);
    return { promql, label, candidate };
  }

  private groupKey(metric: Record<string, string>, groupBy?: string): string {
    if (groupBy && metric[groupBy] !== undefined) return metric[groupBy];
    // No explicit groupBy: synthesize a key from instance/pod/node if any,
    // else from all labels. Useful for naturally-per-series queries like
    // process_resident_memory_bytes (no aggregator dropping labels).
    for (const probe of HINT_BREAKDOWN_LABELS) {
      if (metric[probe]) return metric[probe];
    }
    const entries = Object.entries(metric);
    if (entries.length === 0) return "default";
    return entries.map(([k, v]) => `${k}=${v}`).join(",");
  }

  private async getDistinctLabelValues(
    seriesName: string,
    label: string,
    escapedService: string,
    breakdownLabel: string
  ): Promise<string[]> {
    try {
      const matchExpr = `${seriesName}{${label}="${escapedService}"}`;
      const url = `/api/v1/label/${encodeURIComponent(breakdownLabel)}/values?match[]=${encodeURIComponent(matchExpr)}`;
      const data = await this.apiGet<{ data: string[] }>(url);
      return data?.data || [];
    } catch {
      return [];
    }
  }

  private async pickMetricCandidate(
    metric: string,
    label: string,
    escapedService: string
  ): Promise<MetricCandidate | null> {
    const candidates = PROMETHEUS_METRIC_CANDIDATES[metric];
    if (!candidates || candidates.length === 0) return null;
    if (candidates.length === 1) return candidates[0];

    // Per-service cache: a source can have BOTH process_* and node_* series
    // present (e.g. an apps stack alongside node_exporter), so probing has
    // to check whether THIS service has the series, not whether the source
    // has it anywhere.
    const cacheKey = `${metric}|${label}|${escapedService}`;
    const cached = this.candidateCache.get(cacheKey);
    if (cached && cached.expiresAt > Date.now()) return cached.candidate;

    for (const c of candidates) {
      if (await this.seriesExistsForService(c.seriesName, label, escapedService)) {
        this.candidateCache.set(cacheKey, {
          candidate: c,
          expiresAt: Date.now() + LABEL_CACHE_TTL_MS,
        });
        return c;
      }
    }
    // Nothing found — return first candidate as best-effort. Cache the
    // negative outcome so we don't probe again for 60s.
    const fallback = candidates[0];
    this.candidateCache.set(cacheKey, {
      candidate: fallback,
      expiresAt: Date.now() + LABEL_CACHE_TTL_MS,
    });
    return fallback;
  }

  private async seriesExistsForService(
    seriesName: string,
    label: string,
    escapedService: string
  ): Promise<boolean> {
    try {
      const matchExpr = `${seriesName}{${label}="${escapedService}"}`;
      const url = `/api/v1/series?match[]=${encodeURIComponent(matchExpr)}`;
      const data = await this.apiGet<{ data: Array<unknown> }>(url);
      return Array.isArray(data?.data) && data.data.length > 0;
    } catch {
      return false;
    }
  }

  private async resolveServiceLabel(service: string): Promise<string> {
    for (const label of this.serviceLabels) {
      const values = await this.getLabelValues(label);
      if (values.includes(service)) return label;
    }
    return this.serviceLabels[0] || "job";
  }

  private async getLabelValues(label: string): Promise<string[]> {
    const cached = this.labelValuesCache.get(label);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.values;
    }
    try {
      const data = await this.apiGet<{ data: string[] }>(
        `/api/v1/label/${encodeURIComponent(label)}/values`
      );
      const values = data?.data || [];
      this.labelValuesCache.set(label, { values, expiresAt: Date.now() + LABEL_CACHE_TTL_MS });
      return values;
    } catch {
      this.labelValuesCache.set(label, { values: [], expiresAt: Date.now() + LABEL_CACHE_TTL_MS });
      return [];
    }
  }

  private getUnit(metric: string): string {
    const def = this.metrics.find((m) => m.name === metric);
    if (def) return def.unit;
    return "";
  }

  private parseTimeRange(duration: string, step?: string) {
    const now = Math.floor(Date.now() / 1000);
    const match = duration.match(/^(\d+)([mhd])$/);
    if (!match) throw new Error(`Invalid duration: ${duration}`);
    const value = parseInt(match[1]);
    const unit = match[2];
    const seconds = unit === "m" ? value * 60 : unit === "h" ? value * 3600 : value * 86400;
    const autoStep = Math.max(Math.floor(seconds / 100), 5);
    return { start: now - seconds, end: now, step: step || `${autoStep}s` };
  }

  private computeSummary(values: number[]): MetricResult["summary"] {
    if (values.length === 0) {
      // No data points → no-data, NOT a confident all-zeros reading. Coercing
      // an empty series to {current:0,trend:"stable"} is indistinguishable
      // from a service genuinely idling at 0 (issue #462).
      return null;
    }
    const current = values[values.length - 1];
    const average = values.reduce((a, b) => a + b, 0) / values.length;
    const min = Math.min(...values);
    const max = Math.max(...values);
    return { current, average, min, max, trend: this.computeTrend(values) };
  }

  private computeTrend(values: number[]): Trend {
    if (values.length < 4) return "stable";
    const mid = Math.floor(values.length / 2);
    const avgFirst = values.slice(0, mid).reduce((a, b) => a + b, 0) / mid;
    const avgSecond = values.slice(mid).reduce((a, b) => a + b, 0) / (values.length - mid);
    const changePercent = ((avgSecond - avgFirst) / (avgFirst || 1)) * 100;
    if (changePercent > 10) return "rising";
    if (changePercent < -10) return "falling";
    return "stable";
  }

  private buildAuthHeaders(): Record<string, string> {
    if (!this.auth || this.auth.type === "none") return {};
    if (this.auth.type === "bearer" && this.auth.token) {
      return { Authorization: `Bearer ${this.auth.token}` };
    }
    if (this.auth.type === "basic" && this.auth.username) {
      const encoded = Buffer.from(`${this.auth.username}:${this.auth.password || ""}`).toString("base64");
      return { Authorization: `Basic ${encoded}` };
    }
    return {};
  }

  private fetchOptions(): RequestInit {
    const opts: RequestInit = { headers: this.buildAuthHeaders() };
    if (this.tlsAgent) {
      // @ts-expect-error Node.js extension for native fetch
      opts.dispatcher = this.tlsAgent;
    }
    return opts;
  }

  private async apiGet<T>(path: string, timeoutMs = 10000): Promise<T> {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const res = await fetch(`${this.baseUrl}${path}`, {
        ...this.fetchOptions(),
        signal: controller.signal,
      });
      if (!res.ok) throw new Error(`Prometheus API error: ${res.status} ${res.statusText}`);
      return res.json() as Promise<T>;
    } catch (err) {
      if (err instanceof DOMException && err.name === "AbortError") {
        throw new Error(`Prometheus query timed out after ${timeoutMs}ms`);
      }
      throw err;
    } finally {
      clearTimeout(timer);
    }
  }
}

// Prometheus API types
interface PromTarget {
  labels: Record<string, string>;
  discoveredLabels: Record<string, string>;
  health: string;
}

interface MetadataEntry {
  type: string;
  help: string;
  unit: string;
}

interface PromQueryRangeResult {
  resultType: string;
  result: Array<{
    metric: Record<string, string>;
    values: Array<[number, string]>;
  }>;
}
