import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core";
import type { SubTimelineWaitOutcome } from "@hyperframes/engine";
import { flush, trackEvent } from "./client.js";
import { readConfig } from "./config.js";

// run_id is attached only when the orchestrator set HYPERFRAMES_RUN_ID — an
// absent property, never null/"" (PostHog treats those as real values).
function runIdField(runId: string | undefined): { run_id?: string } {
  return runId !== undefined ? { run_id: runId } : {};
}

export interface RenderObservabilityTelemetryPayload {
  /** Worst sub-composition timeline wait outcome across sessions. */
  subTimelineWait?: SubTimelineWaitOutcome;
  observabilityRenderJobId?: string;
  observabilityCompositionHash?: string;
  observabilityEventCount?: number;
  observabilityLastPhase?: string;
  observabilityLastStatus?: string;
  observabilityFailedPhase?: string;
  browserDiagnosticCount?: number;
  browserDiagnosticErrors?: number;
  browserDiagnosticPageErrors?: number;
  browserDiagnosticRequestFailed?: number;
  browserDiagnosticHttpErrors?: number;
  browserDiagnosticNavigationStarts?: number;
  browserDiagnosticNavigationFailures?: number;
  browserDiagnosticConsoleErrors?: number;
  browserDiagnosticConsoleWarnings?: number;
  captureMode?: string;
  captureForceScreenshot?: boolean;
  captureWorkerCount?: number;
  captureUseStreamingEncode?: boolean;
  captureUseLayeredComposite?: boolean;
  captureUsePageSideCompositing?: boolean;
  captureHasHdrContent?: boolean;
  captureBrowserGpuMode?: string;
  captureProtocolTimeoutMs?: number;
  capturePageNavigationTimeoutMs?: number;
  capturePlayerReadyTimeoutMs?: number;
  captureTransientRetries?: number;
  captureMemoryExhaustionDetected?: boolean;
  // Mirror of the DE inversion/router state on `RenderCaptureObservability` —
  // sourced from the live-mutated capture object rather than `perfSummary`,
  // so a hard failure (crash, OOM, timeout) that never reaches perfSummary
  // construction still reports which DE experiment cohort it was in. Mapped
  // to the SAME `de_*` event keys `trackRenderComplete` sets explicitly from
  // `perfSummary.drawElement`; the caller must spread this payload FIRST so
  // the more authoritative perfSummary value wins when both are present.
  captureDeWorkerInversion?: string;
  captureDePreInversionWorkers?: number;
  captureDeParallelRouter?: string;
  captureDePreRouterWorkers?: number;
  captureDeSelfVerifyFallback?: boolean;
  captureDeFallbackReason?: string;
  /** Non-DE parallel-streaming router outcome ("screenshot" | "beginframe" —
   * routed; "eligible_off" — would route but the kill switch is off). */
  captureParallelStream?: string;
  observabilityExtractVideoCount?: number;
  observabilityExtractedVideoCount?: number;
  observabilityExtractTotalFrames?: number;
  observabilityExtractMaxFramesPerVideo?: number;
  observabilityExtractAvgFramesPerVideo?: number;
  observabilityExtractVfrProbeMs?: number;
  observabilityExtractVfrPreflightMs?: number;
  observabilityExtractVfrPreflightCount?: number;
  observabilityExtractCacheHits?: number;
  observabilityExtractCacheMisses?: number;
  observabilityInitDurationMs?: number;
  observabilityInitTweenCount?: number;
}

function renderObservabilityEventProperties(props: RenderObservabilityTelemetryPayload) {
  return {
    sub_timeline_wait: props.subTimelineWait,
    observability_render_job_id: props.observabilityRenderJobId,
    observability_composition_hash: props.observabilityCompositionHash,
    observability_event_count: props.observabilityEventCount,
    observability_last_phase: props.observabilityLastPhase,
    observability_last_status: props.observabilityLastStatus,
    observability_failed_phase: props.observabilityFailedPhase,
    browser_diagnostic_count: props.browserDiagnosticCount,
    browser_diagnostic_errors: props.browserDiagnosticErrors,
    browser_diagnostic_page_errors: props.browserDiagnosticPageErrors,
    browser_diagnostic_request_failed: props.browserDiagnosticRequestFailed,
    browser_diagnostic_http_errors: props.browserDiagnosticHttpErrors,
    browser_diagnostic_navigation_starts: props.browserDiagnosticNavigationStarts,
    browser_diagnostic_navigation_failures: props.browserDiagnosticNavigationFailures,
    browser_diagnostic_console_errors: props.browserDiagnosticConsoleErrors,
    browser_diagnostic_console_warnings: props.browserDiagnosticConsoleWarnings,
    capture_mode: props.captureMode,
    capture_force_screenshot: props.captureForceScreenshot,
    capture_worker_count: props.captureWorkerCount,
    capture_use_streaming_encode: props.captureUseStreamingEncode,
    capture_use_layered_composite: props.captureUseLayeredComposite,
    capture_use_page_side_compositing: props.captureUsePageSideCompositing,
    capture_has_hdr_content: props.captureHasHdrContent,
    capture_browser_gpu_mode: props.captureBrowserGpuMode,
    capture_protocol_timeout_ms: props.captureProtocolTimeoutMs,
    capture_page_navigation_timeout_ms: props.capturePageNavigationTimeoutMs,
    capture_player_ready_timeout_ms: props.capturePlayerReadyTimeoutMs,
    capture_transient_retries: props.captureTransientRetries,
    capture_memory_exhaustion_detected: props.captureMemoryExhaustionDetected,
    de_worker_inversion: props.captureDeWorkerInversion,
    de_pre_inversion_workers: props.captureDePreInversionWorkers,
    de_parallel_router: props.captureDeParallelRouter,
    de_pre_router_workers: props.captureDePreRouterWorkers,
    de_self_verify_fallback: props.captureDeSelfVerifyFallback,
    de_fallback_reason: props.captureDeFallbackReason,
    capture_parallel_stream: props.captureParallelStream,
    observability_extract_video_count: props.observabilityExtractVideoCount,
    observability_extracted_video_count: props.observabilityExtractedVideoCount,
    observability_extract_total_frames: props.observabilityExtractTotalFrames,
    observability_extract_max_frames_per_video: props.observabilityExtractMaxFramesPerVideo,
    observability_extract_avg_frames_per_video: props.observabilityExtractAvgFramesPerVideo,
    observability_extract_vfr_probe_ms: props.observabilityExtractVfrProbeMs,
    observability_extract_vfr_preflight_ms: props.observabilityExtractVfrPreflightMs,
    observability_extract_vfr_preflight_count: props.observabilityExtractVfrPreflightCount,
    observability_extract_cache_hits: props.observabilityExtractCacheHits,
    observability_extract_cache_misses: props.observabilityExtractCacheMisses,
    observability_init_duration_ms: props.observabilityInitDurationMs,
    observability_init_tween_count: props.observabilityInitTweenCount,
  };
}

function redactTelemetryMessage(value: string): string {
  return redactTelemetryString(value);
}

export function trackCommand(command: string, runId?: string): void {
  trackEvent("cli_command", {
    command,
    ...runIdField(runId),
  });
}

export function trackRenderComplete(
  props: {
    durationMs: number;
    fps: number;
    quality: string;
    /** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
    authoringSkill?: string;
    workers?: number;
    docker: boolean;
    gpu: boolean;
    // Static-frame dedup outcome (opt-out HF_STATIC_DEDUP=false). Undefined on
    // render paths with no capture session.
    staticDedupEnabled?: boolean;
    staticDedupArmed?: boolean;
    staticDedupSkipReason?: string;
    staticDedupPredictedFrames?: number;
    staticDedupReusedFrames?: number;
    // BeginFrame no-damage reuse outcome (Linux/Docker lastFrameCache — the BF
    // counterpart of static dedup). Undefined outside beginframe capture mode.
    beginFrameNoDamageFrames?: number;
    beginFrameHasDamageFrames?: number;
    // drawElement fast-capture outcome (default-on release visibility).
    // Undefined on render paths with no capture session.
    deCaptureMode?: string;
    deCompileGate?: string;
    deClampReason?: string;
    deWorkerInversion?: string;
    dePreInversionWorkers?: number;
    deParallelRouter?: string;
    dePreRouterWorkers?: number;
    deGateReason?: string;
    deWorkerEncode?: boolean;
    deVerifyArmed?: number;
    deVerifyChecked?: number;
    deVerifyMinDb?: number;
    deVerifyInitMs?: number;
    deSelfVerifyFallback?: boolean;
    deFallbackReason?: string;
    deBlankSuspects?: number;
    deBlankDeterministicAccepts?: number;
    deBlankRecaptures?: number;
    deBoundaryFrames?: number;
    deNcprFallbacks?: number;
    // "cli" when triggered by `hyperframes render` (default), "studio" when
    // triggered by a studio preview-server render (POST /api/projects/:id/render).
    source?: "cli" | "studio";
    // Composition metadata
    compositionDurationMs?: number;
    compositionWidth?: number;
    compositionHeight?: number;
    totalFrames?: number;
    // Processing efficiency
    speedRatio?: number;
    captureAvgMs?: number;
    /** Warmup-robust per-frame capture median (basis for speedup estimates). */
    captureP50Ms?: number;
    /** <video> element count (speedup segmentation: injection comps read lower). */
    videoCount?: number;
    capturePeakMs?: number;
    // Resource usage
    peakMemoryMb?: number;
    memoryFreeMb?: number;
    tmpPeakBytes?: number;
    // Per-stage timings (subset of RenderPerfSummary.stages)
    stageCompileMs?: number;
    stageVideoExtractMs?: number;
    stageAudioProcessMs?: number;
    stageCaptureMs?: number;
    stageCaptureSetupMs?: number;
    stageCaptureFrameMs?: number;
    stageEncodeMs?: number;
    stageAssembleMs?: number;
    // Video-extraction breakdown (from RenderPerfSummary.videoExtractBreakdown)
    extractResolveMs?: number;
    extractHdrProbeMs?: number;
    extractHdrPreflightMs?: number;
    extractHdrPreflightCount?: number;
    extractVfrProbeMs?: number;
    extractVfrPreflightMs?: number;
    extractVfrPreflightCount?: number;
    extractPhase3Ms?: number;
    extractCacheHits?: number;
    extractCacheMisses?: number;
    // Attribute this event to a specific user (e.g. the browser user who
    // triggered a studio render); defaults to the install anonymousId.
    distinctId?: string;
  } & RenderObservabilityTelemetryPayload,
): void {
  trackEvent(
    "render_complete",
    {
      // Spread first: explicit de_* keys below (sourced from the more
      // authoritative perfSummary.drawElement, always present on this
      // success path) must win over the observability-capture fallback
      // this shares with trackRenderError's failure path.
      ...renderObservabilityEventProperties(props),
      duration_ms: props.durationMs,
      fps: props.fps,
      quality: props.quality,
      authoring_skill: props.authoringSkill,
      workers: props.workers,
      docker: props.docker,
      gpu: props.gpu,
      static_dedup_enabled: props.staticDedupEnabled,
      static_dedup_armed: props.staticDedupArmed,
      static_dedup_skip_reason: props.staticDedupSkipReason,
      static_dedup_predicted_frames: props.staticDedupPredictedFrames,
      static_dedup_reused_frames: props.staticDedupReusedFrames,
      begin_frame_no_damage_frames: props.beginFrameNoDamageFrames,
      begin_frame_has_damage_frames: props.beginFrameHasDamageFrames,
      de_capture_mode: props.deCaptureMode,
      de_compile_gate: props.deCompileGate,
      de_clamp_reason: props.deClampReason,
      de_worker_inversion: props.deWorkerInversion,
      de_pre_inversion_workers: props.dePreInversionWorkers,
      de_parallel_router: props.deParallelRouter,
      de_pre_router_workers: props.dePreRouterWorkers,
      de_gate_reason: props.deGateReason,
      de_worker_encode: props.deWorkerEncode,
      de_verify_armed: props.deVerifyArmed,
      de_verify_checked: props.deVerifyChecked,
      de_verify_min_db: props.deVerifyMinDb,
      de_verify_init_ms: props.deVerifyInitMs,
      de_self_verify_fallback: props.deSelfVerifyFallback,
      de_fallback_reason: props.deFallbackReason,
      de_blank_suspects: props.deBlankSuspects,
      de_blank_deterministic_accepts: props.deBlankDeterministicAccepts,
      de_blank_recaptures: props.deBlankRecaptures,
      de_boundary_frames: props.deBoundaryFrames,
      de_ncpr_fallbacks: props.deNcprFallbacks,
      source: props.source ?? "cli",
      composition_duration_ms: props.compositionDurationMs,
      composition_width: props.compositionWidth,
      composition_height: props.compositionHeight,
      total_frames: props.totalFrames,
      speed_ratio: props.speedRatio,
      capture_avg_ms: props.captureAvgMs,
      capture_p50_ms: props.captureP50Ms,
      video_count: props.videoCount,
      capture_peak_ms: props.capturePeakMs,
      peak_memory_mb: props.peakMemoryMb,
      memory_free_mb: props.memoryFreeMb,
      tmp_peak_bytes: props.tmpPeakBytes,
      stage_compile_ms: props.stageCompileMs,
      stage_video_extract_ms: props.stageVideoExtractMs,
      stage_audio_process_ms: props.stageAudioProcessMs,
      stage_capture_ms: props.stageCaptureMs,
      stage_capture_setup_ms: props.stageCaptureSetupMs,
      stage_capture_frame_ms: props.stageCaptureFrameMs,
      stage_encode_ms: props.stageEncodeMs,
      stage_assemble_ms: props.stageAssembleMs,
      extract_resolve_ms: props.extractResolveMs,
      extract_hdr_probe_ms: props.extractHdrProbeMs,
      extract_hdr_preflight_ms: props.extractHdrPreflightMs,
      extract_hdr_preflight_count: props.extractHdrPreflightCount,
      extract_vfr_probe_ms: props.extractVfrProbeMs,
      extract_vfr_preflight_ms: props.extractVfrPreflightMs,
      extract_vfr_preflight_count: props.extractVfrPreflightCount,
      extract_phase3_ms: props.extractPhase3Ms,
      extract_cache_hits: props.extractCacheHits,
      extract_cache_misses: props.extractCacheMisses,
    },
    props.distinctId,
  );
  // Send immediately instead of waiting for the exit-time flush. The render
  // command's normal teardown (agent-pipe EPIPE → process.exit(0), or an
  // explicit process.exit) kills the lazy beforeExit flush mid-flight, which
  // is why only ~10-15% of successful renders ever produced a render_complete
  // — and the survivors skewed toward users with low RTT to PostHog. The
  // process is alive and idle here; if it still dies mid-request, the queue
  // keeps the event for the exit-time flushSync() fallback.
  void flush();
}

export function trackRenderError(
  props: {
    fps: number;
    quality: string;
    /** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
    authoringSkill?: string;
    docker: boolean;
    workers?: number;
    gpu?: boolean;
    source?: "cli" | "studio";
    failedStage?: string;
    errorMessage?: string;
    elapsedMs?: number;
    peakMemoryMb?: number;
    memoryFreeMb?: number;
    // Attribute this event to a specific user (e.g. the browser user who
    // triggered a studio render); defaults to the install anonymousId.
    distinctId?: string;
  } & RenderObservabilityTelemetryPayload,
): void {
  trackEvent(
    "render_error",
    {
      fps: props.fps,
      quality: props.quality,
      authoring_skill: props.authoringSkill,
      docker: props.docker,
      workers: props.workers,
      gpu: props.gpu,
      source: props.source ?? "cli",
      failed_stage: props.failedStage,
      error_message: props.errorMessage ? redactTelemetryMessage(props.errorMessage) : undefined,
      elapsed_ms: props.elapsedMs,
      peak_memory_mb: props.peakMemoryMb,
      memory_free_mb: props.memoryFreeMb,
      ...renderObservabilityEventProperties(props),
    },
    props.distinctId,
  );
  // Same rationale as trackRenderComplete: error paths process.exit(1) before
  // the lazy flush can win its race — send now, exit-time fallback covers the rest.
  void flush();
}

export function trackRenderObservation(props: {
  source?: "cli" | "studio";
  renderJobId?: string;
  phase?: string;
  status?: string;
  compositionHash?: string;
  elapsedMs?: number;
  durationMs?: number;
  message?: string;
  workerCount?: number;
  forceScreenshot?: boolean;
  useStreamingEncode?: boolean;
  useLayeredComposite?: boolean;
  usePageSideCompositing?: boolean;
  hasHdrContent?: boolean;
  captureMode?: string;
  captureOperation?: string;
  framesCompleted?: number;
  totalFrames?: number;
  heartbeatIndex?: number;
  stageElapsedMs?: number;
  videoCount?: number;
  extractedVideoCount?: number;
  totalFramesExtracted?: number;
  maxFramesPerVideo?: number;
  avgFramesPerExtractedVideo?: number;
  vfrPreflightCount?: number;
  vfrPreflightMs?: number;
  cacheHits?: number;
  cacheMisses?: number;
}): void {
  trackEvent("render_observation", {
    source: props.source ?? "cli",
    render_job_id: props.renderJobId,
    phase: props.phase,
    status: props.status,
    composition_hash: props.compositionHash,
    elapsed_ms: props.elapsedMs,
    duration_ms: props.durationMs,
    message: props.message ? redactTelemetryMessage(props.message) : undefined,
    worker_count: props.workerCount,
    force_screenshot: props.forceScreenshot,
    use_streaming_encode: props.useStreamingEncode,
    use_layered_composite: props.useLayeredComposite,
    use_page_side_compositing: props.usePageSideCompositing,
    has_hdr_content: props.hasHdrContent,
    capture_mode: props.captureMode,
    capture_operation: props.captureOperation,
    frames_completed: props.framesCompleted,
    total_frames: props.totalFrames,
    heartbeat_index: props.heartbeatIndex,
    stage_elapsed_ms: props.stageElapsedMs,
    video_count: props.videoCount,
    extracted_video_count: props.extractedVideoCount,
    total_frames_extracted: props.totalFramesExtracted,
    max_frames_per_video: props.maxFramesPerVideo,
    avg_frames_per_extracted_video: props.avgFramesPerExtractedVideo,
    vfr_preflight_count: props.vfrPreflightCount,
    vfr_preflight_ms: props.vfrPreflightMs,
    extract_cache_hits: props.cacheHits,
    extract_cache_misses: props.cacheMisses,
  });
}

export function trackInitTemplate(templateId: string, props?: { tailwind?: boolean }): void {
  trackEvent("init_template", { template: templateId, tailwind: props?.tailwind });
}

export function trackBrowserInstall(): void {
  trackEvent("browser_install", {});
}

// Sign-in lifecycle. The CLI tracks command and render lifecycles but never
// authentication, so `auth login` outcomes are invisible on the observability
// dashboards — a completed sign-in, a browser flow the user abandoned, and a
// rejected key all look identical (i.e. absent). These three events close that
// gap so the sign-in funnel is measurable like the render funnel already is.
// `method` is "oauth" (the default browser PKCE flow) or "api_key". No token,
// key, identity, email, or free text is ever attached — only the method and a
// low-cardinality outcome/reason.
//
// The three trackers accept an optional `distinctId`, forwarded to trackEvent
// exactly like trackRenderComplete/trackRenderError already do. It is unused
// today (events attribute to the install's anonymousId), but pre-plumbing it
// makes attributing a completed sign-in to a resolved identity later a one-line
// change at the callsite rather than a signature sweep.
export type AuthLoginMethod = "oauth" | "api_key";
export type AuthLoginFailureReason =
  | "flow_error" // OAuth authorization/exchange threw a real error
  | "flow_timeout" // OAuth callback wait elapsed (user closed the tab / walked away)
  | "no_credential" // flow reported success but nothing was persisted
  | "rejected" // backend rejected the supplied API key (401)
  | "invalid_input" // key was empty, header-unsafe, or too short
  | "aborted"; // prompt cancelled, or no key arrived on stdin before timeout

export function trackAuthLoginStarted(method: AuthLoginMethod, distinctId?: string): void {
  trackEvent("auth_login_started", { method }, distinctId);
}

export function trackAuthLoginCompleted(method: AuthLoginMethod, distinctId?: string): void {
  trackEvent("auth_login_completed", { method }, distinctId);
}

export function trackAuthLoginFailed(
  method: AuthLoginMethod,
  reason: AuthLoginFailureReason,
  distinctId?: string,
): void {
  trackEvent("auth_login_failed", { method, reason }, distinctId);
}

// Associate this install with the signed-in HeyGen account after a completed
// sign-in. Emits a PostHog `$identify` alias whose `$anon_distinct_id` is the
// install's anonymousId, so events recorded before sign-in stitch to the same
// person instead of stranding as a separate anonymous profile. Routed through
// trackEvent so it shares the opt-out gate and flush path — a no-op when
// telemetry is disabled. `distinctId` is the account email (else username);
// see the privacy notice in showTelemetryNotice and docs/packages/cli.mdx.
export function identifyUser(distinctId: string): void {
  if (!distinctId) return;
  trackEvent("$identify", { $anon_distinct_id: readConfig().anonymousId }, distinctId);
}

// A render was rejected by the output-resolution/alpha/HDR pre-flight (P1-3)
// before any browser/ffmpeg work. Counts the "caught early" saves on dashboard
// 1783183, distinct from deep render failures. `kind` is the low-cardinality
// `OutputResolutionIssueKind` (aspect-mismatch / alpha-incompatible / etc.),
// typed to the union so the metric can never carry free text.
export function trackRenderPreflightRejected(props: { kind: OutputResolutionIssueKind }): void {
  trackEvent("render_preflight_rejected", { kind: props.kind });
}

export function trackCliError(props: {
  error_name: string;
  error_message: string;
  stack_trace?: string;
  command?: string;
  kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
}): void {
  trackEvent("cli_error", {
    error_name: props.error_name,
    // Redact before truncating — CLI messages and stack traces carry absolute
    // install paths (/Users/...), cache dirs, and user-supplied args. Same
    // redaction the render_* events already apply.
    error_message: redactTelemetryMessage(props.error_message).slice(0, 1000),
    stack_trace: props.stack_trace
      ? redactTelemetryMessage(props.stack_trace).slice(0, 2000)
      : undefined,
    command: props.command,
    kind: props.kind,
  });
}

/**
 * One figma import outcome (asset/tokens/component). Carries capability mix,
 * dedup effectiveness, and fidelity-degradation counts — never fileKeys,
 * node ids, names, or descriptions.
 */
export function trackFigmaImport(props: {
  phase: "asset" | "tokens" | "component";
  durationMs: number;
  reused?: boolean;
  tokensMode?: "variables" | "styles";
  entryCount?: number;
  unresolvedBindings?: number;
  rasterizedNodes?: number;
  rasterizeFailures?: number;
}): void {
  trackEvent("figma_import", {
    phase: props.phase,
    duration_ms: props.durationMs,
    ...(props.reused !== undefined ? { reused: props.reused } : {}),
    ...(props.tokensMode !== undefined ? { tokens_mode: props.tokensMode } : {}),
    ...(props.entryCount !== undefined ? { entry_count: props.entryCount } : {}),
    ...(props.unresolvedBindings !== undefined
      ? { unresolved_bindings: props.unresolvedBindings }
      : {}),
    ...(props.rasterizedNodes !== undefined ? { rasterized_nodes: props.rasterizedNodes } : {}),
    ...(props.rasterizeFailures !== undefined
      ? { rasterize_failures: props.rasterizeFailures }
      : {}),
  });
}

// Report why a command failed before it exits non-zero. cli_command_result
// records the failure but not the reason; this fills that gap via cli_error so
// command failures are diagnosable. Enqueues synchronously — the process `exit`
// handler flushes it. Drop this into any command's failure path.
export function trackCommandFailure(command: string, err: unknown): void {
  const error = err instanceof Error ? err : new Error(String(err));
  trackCliError({
    error_name: error.name,
    error_message: error.message,
    stack_trace: error.stack,
    command,
    kind: "command_error",
  });
}

// Whisper being absent/uninstallable is an environment prerequisite gap, not a
// command crash — track it on its own low-severity metric instead of cli_error
// so the command-failure budget reflects real bugs. `optional` records whether
// the caller (init / skill pipeline) treated captions as skippable.
export function trackTranscribeUnavailable(props: { optional: boolean }): void {
  trackEvent("transcribe_unavailable", { optional: props.optional });
}

// grade-compare / compare stand up headless Chrome and render up to 16 cells.
// Cell count, truncation-cap hits, and whether the render-ready timeout fired
// are the signals needed before safely lifting the cap. Low-cardinality only.
export function trackCompareSheet(props: {
  command: "grade-compare" | "compare";
  cells: number;
  truncated: boolean;
  total: number;
  renderReadyTimedOut: boolean;
}): void {
  trackEvent("media_use_compare", {
    command: props.command,
    cells: props.cells,
    truncated: props.truncated,
    total: props.total,
    render_ready_timed_out: props.renderReadyTimedOut,
  });
}

// A skills install was skipped because a required prerequisite binary is
// absent from PATH (e.g. git on a fresh Windows box). Best-effort callers
// (init) skip cleanly rather than crash, so the skip is otherwise invisible;
// this surfaces the rare environments that hit it. `reason` is a low-cardinality
// binary tag (e.g. "git_missing"), never a path or free text.
export function trackSkillsInstallSkipped(props: { reason: string }): void {
  trackEvent("cli skill install skipped", { reason: props.reason });
}

export function trackRenderFeedback(props: {
  rating: number;
  renderDurationMs?: number;
  comment?: string;
  doctorSummary?: string;
}): void {
  trackEvent("survey sent", {
    $survey_id: "render_satisfaction",
    $survey_response: props.rating,
    ...(props.comment ? { $survey_response_2: props.comment } : {}),
    ...(props.renderDurationMs !== undefined ? { render_duration_ms: props.renderDurationMs } : {}),
    ...(props.doctorSummary ? { doctor_summary: props.doctorSummary } : {}),
  });
}

export function trackCommandResult(props: {
  command: string;
  success: boolean;
  exitCode: number;
  durationMs: number;
  runId?: string;
}): void {
  trackEvent("cli_command_result", {
    command: props.command,
    success: props.success,
    exit_code: props.exitCode,
    duration_ms: props.durationMs,
    ...runIdField(props.runId),
  });
}

export function trackCheckReport(props: {
  contrastGate: boolean;
  motionGate: boolean;
  captionZoneGate: boolean;
  frameCheckGate: boolean;
  snapshotsGate: boolean;
  lintErrors: number;
  lintWarnings: number;
  runtimeErrors: number;
  runtimeWarnings: number;
  layoutErrors: number;
  layoutWarnings: number;
  motionErrors: number;
  motionWarnings: number;
  contrastErrors: number;
  contrastWarnings: number;
  launchSettleMs: number;
  seekLoopMs: number;
  contrastMs: number;
  gridPoints: number;
  contrastPoints: number;
  ok: boolean;
  exitCode: number;
  runId?: string;
}): void {
  trackEvent("check_report", {
    gate_contrast: props.contrastGate,
    gate_motion: props.motionGate,
    gate_caption_zone: props.captionZoneGate,
    gate_frame_check: props.frameCheckGate,
    gate_snapshots: props.snapshotsGate,
    lint_errors: props.lintErrors,
    lint_warnings: props.lintWarnings,
    runtime_errors: props.runtimeErrors,
    runtime_warnings: props.runtimeWarnings,
    layout_errors: props.layoutErrors,
    layout_warnings: props.layoutWarnings,
    motion_errors: props.motionErrors,
    motion_warnings: props.motionWarnings,
    contrast_errors: props.contrastErrors,
    contrast_warnings: props.contrastWarnings,
    launch_settle_ms: props.launchSettleMs,
    seek_loop_ms: props.seekLoopMs,
    contrast_ms: props.contrastMs,
    grid_points: props.gridPoints,
    contrast_points: props.contrastPoints,
    ok: props.ok,
    exit_code: props.exitCode,
    ...runIdField(props.runId),
  });
}
