/**
 * Regulation to Control API client.
 *
 * Zero dependencies — uses the platform `fetch`, so it runs in Node 18+, Deno,
 * Bun and Cloudflare Workers without a bundler argument.
 *
 * NOT the browser: these endpoints need an API key and deliberately do not
 * support CORS. A key in front-end JavaScript is a published key.
 *
 * ```ts
 * const client = new RegulationControl()                  // reads REGULATION_CONTROL_API_KEY
 * const client = new RegulationControl({ apiKey: 'sp_live_…' })
 * ```
 *
 * Get a free key:
 * ```
 * curl -X POST https://controlgraph-api.com/v1/keys \
 *   -H 'content-type: application/json' -d '{"email":"you@example.com"}'
 * ```
 *
 * WHAT THIS IS: arithmetic and graph mapping over inputs you supply — the
 * obligations you have read out of a regulatory change, and your own control
 * inventory. It does not read, interpret or apply law, and it is NOT legal
 * advice.
 *
 * Any penalty amount is an INTEGER number of minor units (cents). A fractional
 * amount is rejected by the API rather than rounded.
 */

const env = (name: string): string | undefined => (globalThis as any).process?.env?.[name]

/**
 * Override with REGULATION_CONTROL_BASE_URL, or pass `baseUrl`. The generated
 * API reference at /docs.html always carries the origin this deployment is on.
 */
export const DEFAULT_BASE_URL = env('REGULATION_CONTROL_BASE_URL') ?? 'https://controlgraph-api.com'

// --- catalogue codes -------------------------------------------------------
// Branch on these, never on the prose. GET /v1/obligation-types serves the
// same codes with their meanings, the preparation lead time per obligation
// type, and the exact priority weights.

export type ObligationType =
  | 'disclosure' | 'recordkeeping' | 'reporting' | 'notification' | 'consent'
  | 'data_protection' | 'access_control' | 'monitoring' | 'risk_assessment'
  | 'governance' | 'training' | 'third_party_oversight' | 'prohibition'

export type EvidenceKind =
  | 'policy_document' | 'control_test_result' | 'system_configuration' | 'audit_log'
  | 'training_record' | 'attestation' | 'risk_assessment' | 'third_party_report'
  | 'notification_record' | 'retention_schedule'

/**
 * `new_rule` and `amendment` set an evidence cut-off at `publishedAt`:
 * evidence gathered before a requirement existed cannot demonstrate it.
 * `guidance` and `enforcement_action` set none. A repeal is not modelled — it
 * creates no obligation; send the surviving obligations as an amendment.
 */
export type ChangeType = 'new_rule' | 'amendment' | 'guidance' | 'enforcement_action'

export type EnforcementLevel = 'penalty' | 'supervisory' | 'guidance'
export type ControlStatus = 'implemented' | 'planned' | 'in_remediation' | 'retired'

/** `gap` is a control to build; `weak` is a control to fix; `covered` is done. */
export type CoverageStatus = 'covered' | 'weak' | 'gap'

export type FindingCode =
  | 'no_control_mapped' | 'control_not_implemented' | 'control_retired'
  | 'control_untested' | 'control_stale' | 'control_unowned'
  | 'evidence_missing' | 'evidence_undated' | 'evidence_predates_change'
  | 'deadline_passed' | 'prepare_by_passed'

export type DeadlineKind = 'prepare_by' | 'effective' | 'transition_end'
export type DeadlineStatus = 'upcoming' | 'due_soon' | 'passed'
export type PriorityBand = 'critical' | 'high' | 'medium' | 'low'

// --- inputs ----------------------------------------------------------------

export interface EvidenceArtifact {
  kind: EvidenceKind
  artifactId?: string
  /** Undated evidence dates nothing and comes back as `evidence_undated`. */
  collectedAt?: string
}

export interface ControlInput {
  controlId: string
  name: string
  /** A control with no owner is reported — nobody will action it. */
  owner?: string
  status: ControlStatus
  /**
   * Scope tags. A control matches an obligation when its tags contain EVERY
   * tag the obligation requires. Trimmed, lower-cased, de-duplicated and
   * sorted by the API.
   */
  tags: string[]
  /** Missing is `control_untested`. */
  lastTestedAt?: string
  /** Defaults to 365. Older than this is `control_stale`. */
  testFrequencyDays?: number
  evidence?: EvidenceArtifact[]
}

export interface ObligationInput {
  obligationId: string
  type: ObligationType
  /** In your own words. Echoed, never parsed or interpreted. */
  text: string
  /** Required and non-empty: an obligation with no tags could never match. */
  appliesTo: string[]
  /** Overrides the obligation type's default evidence kinds. */
  requiresEvidence?: EvidenceKind[]
  /** Defaults to 'supervisory', and the response says when it assumed one. */
  enforcement?: EnforcementLevel
  /** Replaces the change's effective date for this obligation. */
  dueAt?: string
  /** Overrides the obligation type's catalogued lead time. */
  preparationDays?: number
  /** INTEGER minor units. Requires `currency` on the change. */
  maxPenaltyMinor?: number
}

export interface RegulatoryChangeInput {
  changeId: string
  citation: string
  title: string
  jurisdiction: string
  regulator?: string
  changeType: ChangeType
  publishedAt: string
  effectiveAt: string
  /** Must not be before `effectiveAt` — a grace period cannot pull a deadline forward. */
  transitionEndsAt?: string
  summary?: string
  /** ISO-4217. Required when any obligation records a `maxPenaltyMinor`. */
  currency?: string
  obligations: ObligationInput[]
  metadata?: Record<string, string>
}

// --- outputs ---------------------------------------------------------------

export interface Finding {
  code: FindingCode
  controlId?: string
  detail: string
}

export interface MatchedControl {
  controlId: string
  name: string
  owner: string | null
  status: ControlStatus
  /** The required tags this control carries. */
  matchedTags: string[]
  lastTestedAt: string | null
  daysSinceTest: number | null
  testFrequencyDays: number
  /** Implemented, tested and within its frequency. */
  operating: boolean
  issues: FindingCode[]
}

export interface EvidenceRequirement {
  kind: EvidenceKind
  satisfied: boolean
  satisfiedBy?: string
  artifactId?: string
  collectedAt?: string
  reason?: 'evidence_missing' | 'evidence_undated' | 'evidence_predates_change'
}

export interface Deadline {
  kind: DeadlineKind
  dueAt: string
  /** Whole UTC days. Negative once passed. */
  daysRemaining: number
  status: DeadlineStatus
  description: string
}

export interface Priority {
  /** 0–100, the sum of the four components. */
  score: number
  band: PriorityBand
  components: { enforcement: number; coverage: number; urgency: number; evidence: number }
  /** One line per component, then the total. Recompute it on paper if you like. */
  derivation: string[]
}

export interface ObligationAssessment {
  obligationId: string
  type: ObligationType
  text: string
  enforcement: EnforcementLevel
  /** True when `enforcement` was defaulted rather than stated. */
  enforcementAssumed: boolean
  requiredTags: string[]
  coverage: CoverageStatus
  coverageReason: string
  matchedControls: MatchedControl[]
  requiredEvidence: EvidenceRequirement[]
  /** Every finding, not the first. */
  findings: Finding[]
  deadlines: Deadline[]
  /** The later of the effective date and any transition end. */
  complianceDate: string
  daysToCompliance: number
  priority: Priority
  owners: string[]
  /** Your recorded penalty, carried only while coverage is not `covered`. */
  exposureMinor: number
}

export type GraphNodeType = 'change' | 'obligation' | 'control' | 'team' | 'evidence'
export type GraphEdgeKind = 'creates' | 'mapped_to' | 'owned_by' | 'requires_evidence' | 'evidenced_by'

export interface GraphNode {
  id: string
  type: GraphNodeType
  label: string
  attributes: Record<string, string | number | boolean>
}

export interface GraphEdge {
  from: string
  to: string
  kind: GraphEdgeKind
  /** Why this edge exists. Always present — that is the point of the graph. */
  reason: string
}

export interface MappedChange {
  changeId: string
  citation: string
  title: string
  jurisdiction: string
  regulator: string | null
  changeType: ChangeType
  publishedAt: string
  effectiveAt: string
  transitionEndsAt: string | null
  /** Null for guidance and enforcement actions. */
  evidenceCutoff: string | null
  evaluatedAt: string
  summary: {
    obligations: number
    covered: number
    weak: number
    gaps: number
    controlsMatched: number
    controlsUnmatched: number
    teams: number
    highestPriority: PriorityBand | null
    earliestDeadline: string | null
    passedDeadlines: number
    unmetEvidenceKinds: number
  }
  /** Highest priority first, then soonest compliance date. */
  obligations: ObligationAssessment[]
  graph: { nodes: GraphNode[]; edges: GraphEdge[] }
  unmatchedControls: string[]
  exposure: {
    currency: string
    totalMinor: number
    /** byOwner plus unassignedMinor sum EXACTLY to totalMinor. */
    byOwner: Array<{ owner: string; amountMinor: number }>
    unassignedMinor: number
  } | null
  warnings: string[]
}

export type ApiErrorCode =
  | 'invalid_api_key' | 'missing_api_key' | 'quota_exceeded' | 'rate_limited'
  | 'invalid_request' | 'not_found' | 'method_not_allowed' | 'payload_too_large'
  | 'conflict' | 'internal_error'

/**
 * Thrown for any non-2xx response.
 *
 * NOT thrown when an obligation comes back as a `gap` — that is a successful
 * answer, and usually the answer you bought the API for. On a 400,
 * `details.path` names the exact field that failed validation.
 */
export class ApiError extends Error {
  // Declared as fields rather than constructor parameter properties: those are
  // unsupported by strip-only TypeScript runtimes (Node --experimental-strip-types),
  // and an SDK should run without a build step.
  readonly status: number
  readonly code: ApiErrorCode | 'unknown'
  readonly requestId?: string
  readonly details?: unknown

  constructor(status: number, code: ApiErrorCode | 'unknown', message: string, requestId?: string, details?: unknown) {
    super(`[${status} ${code}] ${message}`)
    this.name = 'ApiError'
    this.status = status
    this.code = code
    this.requestId = requestId
    this.details = details
  }
}

export interface ClientOptions {
  apiKey?: string
  baseUrl?: string
  /** Milliseconds. Default 30000. */
  timeoutMs?: number
  fetch?: typeof fetch
}

export interface MapRequest {
  /** Your control inventory. Required; may be empty, which returns every obligation as a gap. */
  controls: ControlInput[]
  change?: RegulatoryChangeInput
  /** Up to 50. The inventory is shared across the batch. */
  changes?: RegulatoryChangeInput[]
}

export interface MapResponse {
  count: number
  dueSoonWindowDays: number
  changes: MappedChange[]
  notice: string
  requestId: string
}

export class RegulationControl {
  private readonly apiKey: string
  private readonly baseUrl: string
  private readonly timeoutMs: number
  private readonly fetchImpl: typeof fetch

  constructor(options: ClientOptions = {}) {
    const key = options.apiKey ?? env('REGULATION_CONTROL_API_KEY')
    if (!key) {
      throw new Error(
        'No API key. Pass { apiKey } or set REGULATION_CONTROL_API_KEY. ' +
          'Create a free key: POST ' + (options.baseUrl ?? DEFAULT_BASE_URL) + '/v1/keys',
      )
    }
    this.apiKey = key
    this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '')
    this.timeoutMs = options.timeoutMs ?? 30_000
    this.fetchImpl = options.fetch ?? globalThis.fetch
  }

  private async request(method: string, path: string, body?: unknown, auth = true): Promise<any> {
    const controller = new AbortController()
    const timer = setTimeout(() => controller.abort(), this.timeoutMs)
    try {
      const res = await this.fetchImpl(this.baseUrl + path, {
        method,
        signal: controller.signal,
        headers: {
          ...(auth ? { authorization: `Bearer ${this.apiKey}` } : {}),
          accept: 'application/json',
          ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
        },
        ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
      })
      const text = await res.text()
      const json = text ? JSON.parse(text) : {}
      if (!res.ok) {
        const e = json?.error ?? {}
        throw new ApiError(res.status, e.code ?? 'unknown', e.message ?? text.slice(0, 200), e.requestId, e.details)
      }
      return json
    } finally {
      clearTimeout(timer)
    }
  }

  /** Liveness and deployed version. Does not require a key. */
  async health(): Promise<{ ok: boolean; product: string; version: string }> {
    return this.request('GET', '/health', undefined, false)
  }

  /**
   * Map one regulatory change, or up to 50, against one control inventory.
   *
   * Billed one unit per CHANGE, not per control or per obligation — the
   * inventory is shared across the batch, so re-sending 400 controls to map a
   * second change costs nothing extra.
   */
  async map(req: MapRequest): Promise<MapResponse> {
    if ((req.change === undefined) === (req.changes === undefined)) {
      throw new Error('Pass exactly one of change or changes.')
    }
    return this.request('POST', '/v1/changes', req)
  }

  /** The real engine with no key: one change, 5 obligations, 20 controls. */
  async demoMap(req: { controls: ControlInput[]; change: RegulatoryChangeInput }): Promise<{ change: MappedChange; notice: string }> {
    return this.request('POST', '/v1/demo/map', req, false)
  }

  /**
   * The code catalogue: every enum with its meaning, the preparation lead time
   * per obligation type, and the exact priority weights — so a score in a
   * response can be recomputed on paper.
   */
  async obligationTypes(): Promise<Record<string, unknown>> {
    return this.request('GET', '/v1/obligation-types', undefined, false)
  }

  // --- convenience ---------------------------------------------------------

  /** Obligations with no control mapped at all. The work nobody has started. */
  static gaps(mapped: MappedChange): ObligationAssessment[] {
    return mapped.obligations.filter((o) => o.coverage === 'gap')
  }

  /** Obligations with a control that cannot currently satisfy them. */
  static weaknesses(mapped: MappedChange): ObligationAssessment[] {
    return mapped.obligations.filter((o) => o.coverage === 'weak')
  }

  /** Obligations whose compliance date is inside `days` — negative once passed. */
  static dueWithin(mapped: MappedChange, days: number): ObligationAssessment[] {
    return mapped.obligations.filter((o) => o.daysToCompliance <= days)
  }

  /** Create a free sandbox key. Returned once and never again. */
  static async createKey(email: string, opts: { baseUrl?: string; name?: string } = {}): Promise<any> {
    const res = await fetch((opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '') + '/v1/keys', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ email, ...(opts.name ? { name: opts.name } : {}) }),
    })
    const json = await res.json()
    if (!res.ok) throw new ApiError(res.status, json?.error?.code ?? 'unknown', json?.error?.message ?? 'failed', json?.error?.requestId)
    return json
  }
}

export default RegulationControl
