// CSP violation report normalization.
//
// Modern browsers send CSP reports in two shapes:
//
//   1. Legacy (`report-uri` directive, content-type `application/csp-report`):
//        { "csp-report": { "violated-directive": "...", "blocked-uri": "...", ... } }
//
//   2. Reporting API v1 (`report-to` directive, content-type `application/reports+json`):
//        [{ "type": "csp-violation", "age": 0, "url": "...", "user_agent": "...",
//           "body": { "effectiveDirective": "...", "blockedURL": "...", ... } }]
//
// Chrome + Edge have fully migrated to shape (2). Safari + older Firefox still
// send shape (1). We accept both and normalize to a single internal shape that
// the logger and docs can reason about.

export interface NormalizedCspReport {
  /** The resource the document was trying to load / script that was blocked. */
  blockedUri: string | null
  /** The directive that was violated (e.g. `script-src-elem`). */
  effectiveDirective: string | null
  /** The page that triggered the violation. */
  documentUri: string | null
  /** Original directive list from the Content-Security-Policy header. */
  originalPolicy: string | null
  /** The URL of the script that caused the violation, if known. */
  sourceFile: string | null
  /** Line/column of the offending code in `sourceFile`, if known. */
  lineNumber: number | null
  columnNumber: number | null
  /** `enforce` when the violation was blocked; `report` for report-only. */
  disposition: 'enforce' | 'report' | null
  /** Always one of these, or null if the browser omits them. */
  statusCode: number | null
  /** HTTP referrer at the time of the violation. */
  referrer: string | null
  /** Reporting API wrapper fields. Null for legacy reports. */
  reportAgeMs: number | null
  reportType: string | null
  /** Which payload shape this came from — useful for triage. */
  payloadShape: 'legacy' | 'reporting-api' | 'unknown'
}

const MAX_STRING_LENGTH = 2048
const MAX_URI_LENGTH = 4096

function clip(value: unknown, max: number): string | null {
  if (typeof value !== 'string') return null
  return value.length > max ? `${value.slice(0, max)}…[clipped]` : value
}

function asNumber(value: unknown): number | null {
  if (typeof value === 'number' && Number.isFinite(value)) return value
  return null
}

function normalizeLegacy(report: Record<string, unknown>): NormalizedCspReport {
  return {
    blockedUri: clip(report['blocked-uri'], MAX_URI_LENGTH),
    effectiveDirective:
      clip(report['effective-directive'], MAX_STRING_LENGTH) ??
      clip(report['violated-directive'], MAX_STRING_LENGTH),
    documentUri: clip(report['document-uri'], MAX_URI_LENGTH),
    originalPolicy: clip(report['original-policy'], MAX_STRING_LENGTH * 4),
    sourceFile: clip(report['source-file'], MAX_URI_LENGTH),
    lineNumber: asNumber(report['line-number']),
    columnNumber: asNumber(report['column-number']),
    disposition:
      report['disposition'] === 'enforce' || report['disposition'] === 'report'
        ? (report['disposition'] as 'enforce' | 'report')
        : null,
    statusCode: asNumber(report['status-code']),
    referrer: clip(report['referrer'], MAX_URI_LENGTH),
    reportAgeMs: null,
    reportType: null,
    payloadShape: 'legacy',
  }
}

function normalizeReportingApi(entry: Record<string, unknown>): NormalizedCspReport {
  const body = (entry['body'] ?? {}) as Record<string, unknown>
  return {
    blockedUri: clip(body['blockedURL'], MAX_URI_LENGTH),
    effectiveDirective: clip(body['effectiveDirective'], MAX_STRING_LENGTH),
    documentUri: clip(body['documentURL'] ?? entry['url'], MAX_URI_LENGTH),
    originalPolicy: clip(body['originalPolicy'], MAX_STRING_LENGTH * 4),
    sourceFile: clip(body['sourceFile'], MAX_URI_LENGTH),
    lineNumber: asNumber(body['lineNumber']),
    columnNumber: asNumber(body['columnNumber']),
    disposition:
      body['disposition'] === 'enforce' || body['disposition'] === 'report'
        ? (body['disposition'] as 'enforce' | 'report')
        : null,
    statusCode: asNumber(body['statusCode']),
    referrer: clip(body['referrer'], MAX_URI_LENGTH),
    reportAgeMs: asNumber(entry['age']),
    reportType: clip(entry['type'], MAX_STRING_LENGTH),
    payloadShape: 'reporting-api',
  }
}

/**
 * Parse a raw CSP report body into one or more normalized records.
 *
 * Returns `[]` if the payload is unrecognized or empty. The caller should log
 * the empty case at `debug` level, not `warn` — malformed CSP reports are
 * usually bot/crawler noise, not real application violations.
 */
export function normalizeCspReportBody(raw: unknown): NormalizedCspReport[] {
  if (raw === null || raw === undefined) return []

  // Reporting API: top-level array.
  if (Array.isArray(raw)) {
    return raw
      .filter((entry): entry is Record<string, unknown> =>
        typeof entry === 'object' && entry !== null,
      )
      .map((entry) => normalizeReportingApi(entry))
  }

  // Legacy: single object with a `csp-report` key.
  if (typeof raw === 'object') {
    const obj = raw as Record<string, unknown>
    const legacy = obj['csp-report']
    if (legacy && typeof legacy === 'object') {
      return [normalizeLegacy(legacy as Record<string, unknown>)]
    }
    // Some intermediaries flatten the legacy shape.
    if (
      typeof obj['violated-directive'] === 'string' ||
      typeof obj['effective-directive'] === 'string'
    ) {
      return [normalizeLegacy(obj)]
    }
  }

  return []
}

/**
 * Whether a normalized report is worth surfacing at warn level vs debug.
 * Browser extensions, translator tools, and bad inline clipboard
 * interactions routinely produce reports that look like violations but
 * are benign noise. Keep this list short and specific.
 */
export function isLowSignalReport(report: NormalizedCspReport): boolean {
  const blocked = report.blockedUri ?? ''
  const source = report.sourceFile ?? ''
  // Browser extension scripts — user-installed, not our problem.
  if (blocked.startsWith('chrome-extension://')) return true
  if (blocked.startsWith('moz-extension://')) return true
  if (blocked.startsWith('safari-extension://')) return true
  if (blocked.startsWith('edge-extension://')) return true
  if (source.startsWith('chrome-extension://')) return true
  if (source.startsWith('moz-extension://')) return true
  // Known harmless `about:blank` frames (e.g. bfcache probes).
  if (blocked === 'about' || blocked === 'inline') {
    // Keep real inline violations as signal. Only drop `about`.
    if (blocked === 'about') return true
  }
  return false
}
