///|
pub(all) enum ResponseSource {
  CacheSource
  UpstreamSource
  RevalidatedSource
  StaleSource
  OnlyIfCachedSource
} derive(Eq, Compare, Debug)

///|
pub fn ResponseSource::label(self : ResponseSource) -> String {
  match self {
    CacheSource => "cache"
    UpstreamSource => "upstream"
    RevalidatedSource => "revalidated"
    StaleSource => "stale"
    OnlyIfCachedSource => "only-if-cached"
  }
}

///|
pub(all) struct RuntimeResponse {
  meta : ResponseMeta
  body : Bytes
  source : ResponseSource
  trace : CacheTrace
} derive(Eq, Debug)

///|
pub suberror RuntimeError {
  RuntimeTransportFailure(String)
} derive(Eq, Debug)

///|
pub fn RuntimeError::message(self : RuntimeError) -> String {
  match self {
    RuntimeTransportFailure(message) => message
  }
}

///|
fn[T : Transport] execute_origin(
  transport : T,
  request : RuntimeRequest,
) -> TransportResponse raise RuntimeError {
  Transport::execute(transport, request) catch {
    error => raise RuntimeTransportFailure(error.message())
  }
}

///|
fn cached_meta_with_age(entry : StoredEntry, now : Timestamp) -> ResponseMeta {
  let age = calculate_current_age(entry.response, now)
  let headers = entry.response.headers.copy()
  ignore(headers.set("age", age.current_age.seconds().to_string()))
  ResponseMeta::{ ..entry.response, headers, }
}

///|
fn response_body_for_method(body : Bytes, http_method : String) -> Bytes {
  if http_method == "HEAD" {
    b""
  } else {
    body
  }
}

///|
fn response_from_entry(
  entry : StoredEntry,
  request : RuntimeRequest,
  source : ResponseSource,
  trace : CacheTrace,
  now : Timestamp,
) -> RuntimeResponse {
  RuntimeResponse::{
    meta: cached_meta_with_age(entry, now),
    body: response_body_for_method(entry.body, request.meta.http_method),
    source,
    trace,
  }
}

///|
fn only_if_cached_response(
  request : RuntimeRequest,
  trace : CacheTrace,
  now : Timestamp,
) -> RuntimeResponse {
  let headers = HeaderMap::new()
  ignore(headers.set("content-length", "0"))
  RuntimeResponse::{
    meta: ResponseMeta::complete(504, headers, request.meta.request_time, now),
    body: b"",
    source: OnlyIfCachedSource,
    trace,
  }
}

///|
fn append_reasons(trace : CacheTrace, reasons : Array[CacheReason]) -> Unit {
  for reason in reasons {
    trace.add_reason(reason)
  }
}

///|
fn record_store_result(trace : CacheTrace, result : StorePutResult) -> Unit {
  match result {
    StoreInserted => trace.add_reason(CacheReason::new(RuntimeStored))
    StoreReplacedResult => {
      trace.add_reason(CacheReason::new(StoreReplaced))
      trace.add_reason(CacheReason::new(RuntimeStored))
    }
    StoreRejectedNoStore | StoreRejectedOversized | StoreRejectedCapacity =>
      trace.add_reason(CacheReason::new(RuntimeNotStored))
  }
}

///|
fn[S : CacheStore] store_transport_response(
  store : S,
  request : RuntimeRequest,
  upstream : TransportResponse,
  options : CacheOptions,
  now : Timestamp,
  trace : CacheTrace,
) -> Unit {
  let storage = evaluate_storage(request.meta, upstream.meta, options)
  append_reasons(trace, storage.reasons)
  if !storage.storable {
    trace.add_reason(CacheReason::new(RuntimeNotStored))
    return
  }
  guard primary_cache_key(request.meta) is Some(key) else {
    trace.add_reason(CacheReason::new(RuntimeNotStored))
    return
  }
  let vary = parse_vary(upstream.meta.headers)
  guard build_variant_key(request.meta.headers, vary) is Some(variant) else {
    trace.add_reason(CacheReason::new(RuntimeNotStored))
    return
  }
  let entry = StoredEntry::new(
    key,
    variant,
    request.meta,
    upstream.meta,
    upstream.body,
    storage.policy,
    now,
  )
  record_store_result(trace, CacheStore::put(store, entry))
}

///|
fn[S : CacheStore] apply_invalidation(
  store : S,
  request : RuntimeRequest,
  response : TransportResponse,
  trace : CacheTrace,
) -> Unit {
  let plan = plan_invalidation(request.meta, response.meta)
  append_reasons(trace, plan.reasons)
  for uri in plan.uris {
    let removed = CacheStore::invalidate_uri(store, uri)
    if removed > 0 {
      trace.add_reason(
        CacheReason::with_detail(
          StoreInvalidated,
          "\{removed} entries for \{uri}",
        ),
      )
    }
  }
}

///|
fn[S : CacheStore, T : Transport] fetch_from_origin(
  store : S,
  transport : T,
  request : RuntimeRequest,
  options : CacheOptions,
  now : Timestamp,
  trace : CacheTrace,
  allow_store : Bool,
) -> RuntimeResponse raise RuntimeError {
  if trace.action != Bypass {
    trace.action = Fetch
  }
  trace.add_reason(CacheReason::new(RuntimeFetch))
  let upstream = execute_origin(transport, request)
  if allow_store {
    store_transport_response(store, request, upstream, options, now, trace)
  }
  if !is_safe_http_method(request.meta.http_method) {
    apply_invalidation(store, request, upstream, trace)
  }
  RuntimeResponse::{
    meta: upstream.meta,
    body: response_body_for_method(upstream.body, request.meta.http_method),
    source: UpstreamSource,
    trace,
  }
}

///|
fn[S : CacheStore, T : Transport] execute_revalidation(
  store : S,
  transport : T,
  request : RuntimeRequest,
  entry : StoredEntry,
  options : CacheOptions,
  now : Timestamp,
  trace : CacheTrace,
) -> RuntimeResponse raise RuntimeError {
  let plan = create_revalidation_plan(request.meta, entry.response)
  if !plan.can_revalidate() {
    return fetch_from_origin(
      store, transport, request, options, now, trace, true,
    )
  }
  append_reasons(trace, plan.reasons)
  for pair in plan.generated_headers.pairs() {
    ignore(trace.generated_headers.append(pair.0, pair.1))
  }
  match plan.validator_kind {
    Some(kind) => trace.validator = Some(kind.label())
    None => ()
  }
  let conditional = RuntimeRequest::{ ..request, meta: plan.request }
  let upstream = execute_origin(transport, conditional)
  if upstream.meta.status == 304 {
    let merged = merge_not_modified(entry, upstream.meta, options, now) catch {
      error => raise RuntimeTransportFailure(error.message())
    }
    append_reasons(trace, merged.reasons)
    if merged.storage.storable {
      record_store_result(trace, CacheStore::put(store, merged.entry))
    } else {
      ignore(
        CacheStore::remove_variant(store, entry.primary_key, entry.variant_key),
      )
    }
    trace.age = Some(calculate_current_age(merged.entry.response, now))
    trace.freshness_lifetime = Some(merged.entry.policy.freshness_lifetime)
    return response_from_entry(
      merged.entry,
      request,
      RevalidatedSource,
      trace,
      now,
    )
  }
  trace.add_reason(CacheReason::new(RevalidateModified))
  ignore(
    CacheStore::remove_variant(store, entry.primary_key, entry.variant_key),
  )
  store_transport_response(store, request, upstream, options, now, trace)
  RuntimeResponse::{
    meta: upstream.meta,
    body: response_body_for_method(upstream.body, request.meta.http_method),
    source: RevalidatedSource,
    trace,
  }
}

///|
/// Execute the complete initial-acceptance cache lifecycle.
pub fn[S : CacheStore, T : Transport] execute_cached(
  store : S,
  transport : T,
  options : CacheOptions,
  request : RuntimeRequest,
  now : Timestamp,
) -> RuntimeResponse raise RuntimeError {
  let request_policy = evaluate_request_policy(request.meta)
  let initial_trace = CacheTrace::new(Fetch)
  if request_policy.bypass {
    initial_trace.action = Bypass
    initial_trace.add_reason(CacheReason::new(RequestNoStore))
    initial_trace.add_reason(CacheReason::new(RuntimeBypass))
    return fetch_from_origin(
      store, transport, request, options, now, initial_trace, false,
    )
  }
  if request.meta.http_method != "GET" && request.meta.http_method != "HEAD" {
    initial_trace.add_reason(CacheReason::new(RuntimeBypass))
    return fetch_from_origin(
      store, transport, request, options, now, initial_trace, false,
    )
  }
  guard primary_cache_key(request.meta) is Some(key) else {
    initial_trace.add_reason(CacheReason::new(RuntimeBypass))
    return fetch_from_origin(
      store, transport, request, options, now, initial_trace, false,
    )
  }
  initial_trace.primary_key = Some(key.label())
  let candidates = CacheStore::find_variants(store, key)
  let selection = select_variant(candidates, request.meta)
  append_reasons(initial_trace, selection.reasons)
  guard selection.entry is Some(entry) else {
    initial_trace.add_reason(CacheReason::new(RuntimeMiss))
    if request_policy.only_if_cached {
      initial_trace.action = OnlyIfCachedMiss
      initial_trace.add_reason(CacheReason::new(RuntimeOnlyIfCachedMiss))
      return only_if_cached_response(request, initial_trace, now)
    }
    return fetch_from_origin(
      store, transport, request, options, now, initial_trace, true,
    )
  }
  initial_trace.selected_variant = Some(entry.variant_key.label)
  let decision = evaluate_cached_response(
    request.meta,
    entry.response,
    options,
    now,
  )
  let trace = decision.trace
  trace.primary_key = Some(key.label())
  trace.selected_variant = Some(entry.variant_key.label)
  append_reasons(trace, selection.reasons)
  match decision.action {
    ServeFresh => response_from_entry(entry, request, CacheSource, trace, now)
    ServeStale => response_from_entry(entry, request, StaleSource, trace, now)
    Revalidate =>
      execute_revalidation(
        store, transport, request, entry, options, now, trace,
      )
    Fetch => {
      let upstream = execute_origin(transport, request)
      trace.add_reason(CacheReason::new(RuntimeFetch))
      ignore(
        CacheStore::remove_variant(store, entry.primary_key, entry.variant_key),
      )
      store_transport_response(store, request, upstream, options, now, trace)
      RuntimeResponse::{
        meta: upstream.meta,
        body: response_body_for_method(upstream.body, request.meta.http_method),
        source: UpstreamSource,
        trace,
      }
    }
    Bypass =>
      fetch_from_origin(store, transport, request, options, now, trace, false)
    OnlyIfCachedMiss => only_if_cached_response(request, trace, now)
  }
}

///|
pub struct CachedRuntime[S, T] {
  priv store : S
  priv transport : T
  priv options : CacheOptions
}

///|
pub fn[S, T] CachedRuntime::new(
  store : S,
  transport : T,
  options : CacheOptions,
) -> CachedRuntime[S, T] {
  CachedRuntime::{ store, transport, options }
}

///|
pub fn[S : CacheStore, T : Transport] CachedRuntime::execute(
  self : CachedRuntime[S, T],
  request : RuntimeRequest,
  now : Timestamp,
) -> RuntimeResponse raise RuntimeError {
  execute_cached(self.store, self.transport, self.options, request, now)
}

///|
pub fn[S : CacheStore, T] CachedRuntime::store_stats(
  self : CachedRuntime[S, T],
) -> StoreStats {
  CacheStore::stats(self.store)
}