let error_msgf fmt = Fmt.kstr (fun msg -> Error (`Msg msg)) fmt

let src = Logs.Src.create "dns-client-miou-unix"

module Log = (val Logs.src_log src : Logs.LOG)

module Transport = struct
  open Happy_eyeballs_miou_unix

  type +'a io = 'a

  type io_addr =
    [ `Plaintext of Ipaddr.t * int | `Tls of Tls.Config.client * Ipaddr.t * int ]
  
  type t = {
      nameservers: io_addr list
    ; proto: Dns.proto
    ; timeout: float
    ; happy: stack
  }
  and stack = Happy_eyeballs_miou_unix.t
  
  type context =
    { fd : [ `Udp of Miou_unix.file_descr
           | `Tcp of Miou_unix.file_descr
           | `Tls of Tls_miou_unix.t ]
    ; timeout : float }

  let clock = Mtime_clock.elapsed_ns

  let same_address ipaddr' port' = function
    | `Plaintext (ipaddr, port) -> Ipaddr.compare ipaddr ipaddr' = 0 && port = port'
    | `Tls (_, ipaddr, port) -> Ipaddr.compare ipaddr ipaddr' = 0 && port = port'

  exception Timeout

  let with_timeout ~timeout:ts fn =
    let timeout () = Miou_unix.sleep ts; raise Timeout in
    let prm1 = Miou.async timeout in
    let prm0 = Miou.async fn in
    Miou.await_first [ prm0; prm1 ]

  let connect_to_nameservers t =
    let ( let* ) = Result.bind in
    match t.proto with
    | `Tcp ->
      let ip_of_nameserver = function
        | `Plaintext (ipaddr, port) -> (ipaddr, port)
        | `Tls (_, ipaddr, port) -> (ipaddr, port) in
      let ips = List.map ip_of_nameserver t.nameservers in
      let* ((ipaddr, port) as addr), fd = connect_ip t.happy ips in
      begin match List.find (same_address ipaddr port) t.nameservers with
      | `Plaintext _ -> Ok (addr, `Tcp fd)
      | `Tls (config, _, _) ->
        try let fd = Tls_miou_unix.client_of_fd config fd in
            Ok (addr, `Tls fd)
        with End_of_file ->
            Miou_unix.close fd;
            error_msgf "Connection to nameservers (via TLS) impossible" end
    | `Udp ->
      let is_plaintext = function `Plaintext v -> Either.Left v | _ -> Either.Right () in
      let[@warning "-8"] (ipaddr, port) :: _, _ = List.partition_map is_plaintext t.nameservers in
      let proto_number, socket_type = Unix.((getprotobyname "udp").p_proto, SOCK_DGRAM) in
      let domain = match ipaddr with
        | Ipaddr.V4 _ -> Unix.PF_INET
        | Ipaddr.V6 _ -> Unix.PF_INET6 in
      let fd = Unix.socket domain socket_type proto_number in
      let addr = Unix.ADDR_INET (Ipaddr_unix.to_inet_addr ipaddr, port) in
      let connect () =
        Unix.connect fd addr;
        ((ipaddr, port), `Udp (Miou_unix.of_file_descr fd)) in
      match with_timeout ~timeout:t.timeout connect with
      | Ok value -> Ok value
      | Error Timeout ->
          Unix.close fd;
          error_msgf "Connection to nameservers (via UDP) timeout"
      | Error exn ->
          Unix.close fd;
          error_msgf "Unexpected error: %S" (Printexc.to_string exn)
  
  let nameservers { nameservers; proto; _ } = (proto, nameservers)
  let bind x f = f x
  let lift = Fun.id
  let rng = Mirage_crypto_rng.generate ?g:None
  
  let connect t =
    let ( let* ) = Result.bind in
    let* ((addr, port), fd) = connect_to_nameservers t in
    Log.debug (fun m -> m "Connected to a nameserver %a:%d" Ipaddr.pp addr port);
    match fd with
    | `Tcp _ | `Tls _ -> Ok (`Tcp, { fd; timeout= t.timeout })
    | `Udp _ -> Ok (`Udp, { fd; timeout= t.timeout })
  
  let send_recv_tls ~timeout ~id fd str =
    let send () = Tls_miou_unix.write fd str in
    let recv () =
      let rec go buf rx_len =
        let expected_len =
          if rx_len >= 2 then Some (Bytes.get_uint16_be buf 0) else None in
        match expected_len with
        | None ->
          let len = Tls_miou_unix.read fd buf ~off:rx_len in
          if rx_len + len >= 2 && len > 0 then go buf (rx_len + len)
          else failwith "TLS connection closed by nameserver"
        | Some expected_len when rx_len >= expected_len + 2 ->
          let id' = Bytes.get_uint16_be buf 2 in
          if id = id'
          then Bytes.sub_string buf 0 (expected_len + 2)
          else
            let buf' = Bytes.make 2048 '\000' in
            let rx_len' = rx_len - (expected_len + 2) in
            Bytes.blit buf (expected_len + 2) buf' 0 rx_len';
            go buf' rx_len'
        | Some expected_len when Bytes.length buf >= expected_len + 2 ->
          let len = (expected_len + 2) - rx_len in
          Tls_miou_unix.really_read fd buf ~off:rx_len ~len;
          go buf (rx_len + len)
        | Some expected_len ->
          (* NOTE(dinosaure): in this branch, [buf] is not large enough to store
             the DNS packet. We allocate a new buffer which can store the actual
             DNS packet and use it for the next [go] iteration. *)
          let buf' = Bytes.make (expected_len + 2) '\000' in
          Bytes.blit buf 0 buf' 0 rx_len;
          go buf' rx_len in
      go (Bytes.make 2048 '\000') 0 in
    let ( >>= ) = Result.bind in
    match with_timeout ~timeout send >>= fun () ->
          with_timeout ~timeout recv with
    | Ok _ as rx -> rx
    | Error Timeout -> error_msgf "DNS request timeout"
    | Error (Failure msg) -> Error (`Msg msg)
    | Error (End_of_file | Tls_miou_unix.Closed_by_peer) ->
      error_msgf "End of file reading from nameserver"
    | Error exn ->
      error_msgf "Got an unexpected exception: %s"
        (Printexc.to_string exn)
  
  let send_recv { fd; timeout } str =
    if String.length str > 4 then begin
      match fd with
      | `Tls fd ->
        let id = String.get_uint16_be str 2 in
        send_recv_tls ~timeout ~id fd str 
      | `Udp fd | `Tcp fd ->
          let fd = Miou_unix.to_file_descr fd in
          Unix.clear_nonblock fd;
          let send () =
            Log.debug (fun m -> m "sending a dns packet to resolver");
            Unix.setsockopt_float fd Unix.SO_SNDTIMEO timeout;
            let len = Unix.send_substring fd str 0 (String.length str) [] in
            if len <> String.length str
            then failwith "Broken write to upstream nameserver" in
          let recv () =
            let buffer = Bytes.make 2048 '\000' in
            Unix.setsockopt_float fd Unix.SO_RCVTIMEO timeout;
            let len = Unix.recv fd buffer 0 (Bytes.length buffer) [] in
            (* TODO(dinosaure): should we check rx_len and continue until we got
               the full packet (only for tcp/ip)? *)
            if len > 0 && len <= Bytes.length buffer
            then Bytes.sub_string buffer 0 len
            else failwith "Reading from nameserver socket failed" in
          let ( >>= ) = Result.bind in
          match with_timeout ~timeout send >>= fun () ->
                with_timeout ~timeout recv with
          | Ok _ as rx -> rx
          | Error Timeout -> error_msgf "DNS request timeout"
          | Error (Failure msg) -> Error (`Msg msg)
          | Error exn ->
              error_msgf "Got an unexpected exception: %s"
                (Printexc.to_string exn)
    end
    else error_msgf "Invalid context (data length <= 4)"
  
  let close { fd; _ } = match fd with
    | `Tcp fd | `Udp fd -> Miou_unix.close fd
    | `Tls fd -> Tls_miou_unix.close fd
  
  let of_ns ns = Int64.to_float ns /. 1_000_000_000.
  
  let create ?nameservers ~timeout happy =
    let proto, nameservers =
      match nameservers with
      | None -> (`Udp, [ `Plaintext (Ipaddr.of_string_exn "8.8.8.8", 53) ])
      | Some (a, nss) -> (a, nss)
    in
    { nameservers; proto; timeout= of_ns timeout; happy }
end

include Dns_client.Make (Transport)
