// Async Native DNS stub resolver core.
//
// The published root package is Native-only because it imports the async
// socket/TLS runtime. Tests and Native embedders can inject a DnsTransport
// without opening a socket.

///|
pub struct Resolver {
  transport : DnsTransport
  cache : DnsCache
  recurse : Bool
  max_cname_depth : Int
  clock : () -> Int64
}

///|
pub(all) struct ResolveOptions {
  server : String
  timeout_ms : Int
  retries : Int
  recurse : Bool
  max_cname_depth : Int
}

///|
pub fn ResolveOptions::default() -> ResolveOptions {
  {
    server: "8.8.8.8:53",
    timeout_ms: default_timeout_ms,
    retries: default_retries,
    recurse: true,
    max_cname_depth,
  }
}

///|
/// Construct resolver options for callers outside this package. The fields of
/// public structs are read-only across MoonBit package boundaries, so exposing
/// this constructor makes server, timeout, and retry configuration available
/// to the CLI and embedding applications.
pub fn ResolveOptions::new(
  server~ : String,
  timeout_ms? : Int = default_timeout_ms,
  retries? : Int = default_retries,
  recurse? : Bool = true,
  max_depth? : Int = max_cname_depth,
) -> ResolveOptions {
  { server, timeout_ms, retries, recurse, max_cname_depth: max_depth }
}

///|
pub(all) struct DnsResult {
  answers : Array[RR]
  cname_chain : Array[String]
}

///|
pub enum ResolveError {
  FormatError(String)
  ResponseMismatch(String)
  RandomnessUnavailable
  FormErr
  NXDomain
  NoData
  Referral
  ServFail
  Refused
  NotImplemented
  ExtendedRcode(Int)
  Timeout
  TruncatedAndTcpFailed
  ConnectionFailed(String)
  CnameLoop(String)
} derive(Debug, Eq)

///|
let resolver_clock_origin : Int64 = @async.now()

///|
let resolver_clock_last : Ref[Int64] = Ref(0L)

///|
fn resolver_now_ms() -> Int64 {
  // Keep an Int64 elapsed process-local clock. This avoids the ~24.855-day
  // overflow of a 32-bit millisecond counter and clamps runtime regressions so
  // TTL evaluation never moves backwards.
  let elapsed = @async.now() - resolver_clock_origin
  if elapsed > resolver_clock_last.val {
    resolver_clock_last.val = elapsed
  }
  resolver_clock_last.val
}

// A DNS message ID is a transaction-security field. Native builds obtain it
// from OpenSSL RAND_bytes; failure is explicit instead of degrading to a
// predictable timestamp or counter.

///|
#warnings("-alert_internal")
fn random_id() -> Result[UInt16, ResolveError] {
  try {
    let bytes = @tls.rand_bytes(2)
    Ok(((bytes[0].to_int() << 8) | bytes[1].to_int()).to_uint16())
  } catch {
    _ => Err(RandomnessUnavailable)
  }
}

///|
pub fn Resolver::new(
  options? : ResolveOptions = ResolveOptions::default(),
) -> Resolver {
  let transport = FallbackTransport::with_options(
    options.server,
    options.timeout_ms,
    options.retries,
  ).as_transport()
  {
    transport,
    cache: DnsCache::new(),
    recurse: options.recurse,
    max_cname_depth: options.max_cname_depth,
    clock: resolver_now_ms,
  }
}

// Create a resolver with a caller-supplied transport. This is the primary
// extension point for Native adapters and deterministic tests.

///|
pub fn Resolver::with_transport(
  transport~ : DnsTransport,
  options? : ResolveOptions = ResolveOptions::default(),
) -> Resolver {
  {
    transport,
    cache: DnsCache::new(),
    recurse: options.recurse,
    max_cname_depth: options.max_cname_depth,
    clock: resolver_now_ms,
  }
}

// Like `with_transport`, but with a caller-controlled monotonic millisecond
// source. The default clock uses runtime time; this constructor makes TTL
// expiry and LRU tests deterministic without sleeping.

///|
pub fn Resolver::with_transport_and_clock(
  transport~ : DnsTransport,
  clock~ : () -> Int,
  options? : ResolveOptions = ResolveOptions::default(),
) -> Resolver {
  Resolver::with_transport_and_clock64(
    transport~,
    clock=() => clock().to_int64(),
    options~,
  )
}

///|
/// Construct a resolver with a caller-controlled Int64 monotonic millisecond
/// source. Use this API for long-running processes and tests that cross the
/// 32-bit millisecond boundary. The older Int clock constructor remains as a
/// compatibility wrapper.
pub fn Resolver::with_transport_and_clock64(
  transport~ : DnsTransport,
  clock~ : () -> Int64,
  options? : ResolveOptions = ResolveOptions::default(),
) -> Resolver {
  {
    transport,
    cache: DnsCache::new(),
    recurse: options.recurse,
    max_cname_depth: options.max_cname_depth,
    clock,
  }
}

///|
pub fn Resolver::cache_stats(self : Resolver) -> CacheStats {
  self.cache.stats()
}

///|
fn cached_result_for_tracker(
  cached : DnsResult,
  tracker : CnameTracker,
) -> Result[DnsResult, ResolveError] {
  // Cached chains are relative to their cache key and therefore start with
  // the name already present at the end of the current tracker. Replay only
  // subsequent aliases through the tracker so cache hits preserve loop and
  // maximum-depth enforcement as well as provenance.
  for i in 1.. ()
      Err(reason) => return Err(CnameLoop(reason))
    }
  }
  Ok({ answers: cached.answers, cname_chain: tracker.get_chain() })
}

///|
fn relative_cache_result(
  result : DnsResult,
  entry_chain_length : Int,
) -> DnsResult {
  let relative_chain : Array[String] = []
  let start = if entry_chain_length > 0 { entry_chain_length - 1 } else { 0 }
  for i in start.. Result[DnsResult, ResolveError] {
  self.resolve_with_tracker(
    name,
    qtype,
    CnameTracker::new(name, max_depth=self.max_cname_depth),
  )
}

///|
async fn Resolver::resolve_with_tracker(
  self : Resolver,
  name : String,
  qtype : UInt16,
  tracker : CnameTracker,
) -> Result[DnsResult, ResolveError] {
  let entry_chain_length = tracker.get_chain().length()
  let now_ms = (self.clock)()
  match self.cache.lookup_ms64(name, qtype, now_ms) {
    Positive(cached) =>
      match cached_result_for_tracker(cached, tracker) {
        Ok(result) => return Ok(result)
        Err(error) => return Err(error)
      }
    Negative(NXDomain, _) => return Err(NXDomain)
    // NOERROR/NODATA is distinct from NXDOMAIN, but it remains a negative
    // result. Never flatten a negative-cache hit into an empty success.
    Negative(NoData, _) => return Err(NoData)
    Miss => ()
  }

  let message_id = match random_id() {
    Ok(id) => id
    Err(error) => return Err(error)
  }
  // Advertise the payload capacity of the selected transport. This makes
  // EDNS(0) part of every resolver request rather than an unused codec helper;
  // the default native transport advertises 1232 bytes.
  let opt = {
    ..OptRR::default(),
    udp_payload_size: self.transport.max_payload(),
  }
  let query = match
    build_query_with_edns_checked(message_id, name, qtype, self.recurse, opt) {
    Ok(value) => value
    Err(error) => return Err(FormatError(error))
  }
  let query_bytes = match query.encode_checked() {
    Ok(value) => value
    Err(error) => return Err(FormatError(error))
  }
  let response_bytes = match self.transport.send(query_bytes) {
    Ok(bytes) => bytes
    Err(error) => return Err(map_transport_error(error))
  }
  let response = match decode_message(response_bytes) {
    Ok(message) => message
    Err(error) => return Err(FormatError(error))
  }
  match validate_response(response, message_id, name, qtype) {
    Ok(_) => ()
    Err(error) => return Err(error)
  }
  match rcode_error(response) {
    Ok(_) => ()
    Err(NXDomain) => {
      match negative_cache_ttl_for_query(response, name, self.max_cname_depth) {
        Some(ttl) =>
          self.cache.put_negative_ms64(name, qtype, NXDomain, ttl, now_ms)
        None => ()
      }
      return Err(NXDomain)
    }
    Err(error) => return Err(error)
  }
  if response_is_referral(response) {
    return Err(Referral)
  }

  // A CNAME query returns the CNAME RR itself. Other qtypes follow only a
  // CNAME whose owner is the current queried name, never an unrelated answer.
  if qtype == qtype_cname {
    let answers = records_for_question(response.answers, name, qtype_cname)
    let result = DnsResult::{ answers, cname_chain: tracker.get_chain() }
    if result.answers.length() == 0 {
      match extract_negative_ttl(response) {
        Some(ttl) =>
          self.cache.put_negative_ms64(name, qtype, NoData, ttl, now_ms)
        None => ()
      }
      return Err(NoData)
    } else {
      self.cache.put_positive_ms64(
        name,
        qtype,
        relative_cache_result(result, entry_chain_length),
        min_answer_ttl(result.answers),
        now_ms,
      )
    }
    return Ok(result)
  }

  let current_name = Ref(name)
  let cname_ttl = Ref(-1L)
  for ;; {
    let terminal = records_for_question(
      response.answers,
      current_name.val,
      qtype,
    )
    if terminal.length() > 0 {
      let terminal_ttl = min_answer_ttl(terminal)
      let ttl = if cname_ttl.val < 0 {
        terminal_ttl
      } else {
        min_ttl(cname_ttl.val, terminal_ttl)
      }
      let result = DnsResult::{
        answers: terminal,
        cname_chain: tracker.get_chain(),
      }
      self.cache.put_positive_ms64(
        name,
        qtype,
        relative_cache_result(result, entry_chain_length),
        ttl,
        now_ms,
      )
      return Ok(result)
    }
    match cname_target_for_name(response.answers, current_name.val) {
      None => break
      Some(target) => {
        match tracker.follow(target) {
          Ok(_) => ()
          Err(reason) => return Err(CnameLoop(reason))
        }
        match cname_ttl_for_name(response.answers, current_name.val) {
          Some(ttl) =>
            cname_ttl.val = if cname_ttl.val < 0 {
              ttl
            } else {
              min_ttl(cname_ttl.val, ttl)
            }
          None => ()
        }
        current_name.val = target
      }
    }
  }

  // The response can contain only the first CNAME hop; recurse with the same
  // tracker so cycles such as A -> B -> A are rejected globally.
  if !dns_name_equal(current_name.val, name) {
    let followed = match
      self.resolve_with_tracker(current_name.val, qtype, tracker) {
      Ok(result) => result
      Err(NoData) => {
        // The target's authority SOA supplies its exact negative TTL. The alias
        // cache must not outlive either its own CNAME TTL or the target's
        // remaining negative-cache deadline.
        let target_ttl = match
          self.cache.remaining_ttl_seconds_ms64(current_name.val, qtype, now_ms) {
          Some(value) => value
          // The recursive target response had no cacheable SOA lifetime. Do
          // not invent one for the alias, or the alias could outlive proof of
          // the negative result.
          None => return Err(NoData)
        }
        let ttl = if cname_ttl.val < 0 {
          target_ttl
        } else {
          min_ttl(cname_ttl.val, target_ttl)
        }
        self.cache.put_negative_ms64(name, qtype, NoData, ttl, now_ms)
        return Err(NoData)
      }
      Err(error) => return Err(error)
    }
    let ttl = if cname_ttl.val < 0 {
      min_answer_ttl(followed.answers)
    } else {
      min_ttl(cname_ttl.val, min_answer_ttl(followed.answers))
    }
    self.cache.put_positive_ms64(
      name,
      qtype,
      relative_cache_result(followed, entry_chain_length),
      ttl,
      now_ms,
    )
    return Ok(followed)
  }

  // NOERROR with no requested records is NODATA, not NXDOMAIN. Cache it with
  // RFC 2308 TTL and preserve it as a negative resolver result.
  match extract_negative_ttl(response) {
    Some(ttl) => self.cache.put_negative_ms64(name, qtype, NoData, ttl, now_ms)
    None => ()
  }
  Err(NoData)
}