// Intentions: the allow/deny rules of the Connect service graph. An intention
// names a source and a destination service (either may be the wildcard `*`) and
// the action to take; the agent picks the most specific match, so each carries
// a read-only `precedence` the match reply is ordered by.

///|
/// What an intention does with a connection it matches.
pub(all) enum IntentionAction {
  /// Let the connection through.
  Allow
  /// Refuse the connection.
  Deny
}

///|
/// The wire value of an action: `allow` or `deny`.
pub fn IntentionAction::code(self : IntentionAction) -> String {
  match self {
    Allow => "allow"
    Deny => "deny"
  }
}

///|
/// An action from its wire value, or `None` for anything else.
pub fn IntentionAction::of(text : StringView) -> IntentionAction? {
  match text {
    "allow" => Some(Allow)
    "deny" => Some(Deny)
    _ => None
  }
}

///|
/// Which half of an intention a match or a check looks up by.
pub(all) enum IntentionMatchBy {
  /// Look up the intentions whose source is the name given.
  Source
  /// Look up the intentions whose destination is the name given.
  Destination
}

///|
/// The wire value of a match target: `source` or `destination`.
pub fn IntentionMatchBy::code(self : IntentionMatchBy) -> String {
  match self {
    Source => "source"
    Destination => "destination"
  }
}

///|
/// One intention of the service graph. `source` and `destination` name a
/// service, `ns` and `partition` where it lives (either may be the wildcard
/// `*`), and `*` as the whole name matches any service. `precedence` is the
/// agent's: the higher it is, the earlier the intention is considered, and an
/// exact pair outranks a wildcard.
pub(all) struct Intention {
  /// The agent's id for the intention, absent on agents that name intentions by
  /// their service pair instead.
  id : String?
  description : String?
  source_ns : String?
  source_name : String
  source_partition : String?
  source_peer : String?
  destination_ns : String?
  destination_name : String
  destination_partition : String?
  /// How the source is named: `consul` for a catalog service.
  source_type : String?
  action : IntentionAction
  meta : Map[String, String]?
  /// Layer-7 permissions, carried as the JSON the agent serves so that a rule
  /// this library does not model survives a read and a write.
  permissions : Json?
  /// The agent's ordering weight; a read-only field.
  precedence : Int32
  created_at : String?
  updated_at : String?
  create_index : UInt64
  modify_index : UInt64
}

///|
/// An intention with only the two service names set, denying by default: the
/// starting point for one built field by field.
pub let default_intention : Intention = {
  id: None,
  description: None,
  source_ns: None,
  source_name: "",
  source_partition: None,
  source_peer: None,
  destination_ns: None,
  destination_name: "",
  destination_partition: None,
  source_type: None,
  action: Deny,
  meta: None,
  permissions: None,
  precedence: 0,
  created_at: None,
  updated_at: None,
  create_index: 0UL,
  modify_index: 0UL,
}

///|
/// An intention from `source` to `destination` with `action`, both names in the
/// default namespace.
pub fn Intention::new(
  source : String,
  destination : String,
  action : IntentionAction,
) -> Intention {
  {
    ..default_intention,
    source_name: source,
    destination_name: destination,
    action,
  }
}

///|
/// An `Intention` as its write body.
fn intention_to_json(i : Intention) -> Json {
  let m = jobj()
  put_str(m, "Description", i.description)
  put_str(m, "SourceNS", i.source_ns)
  put_str(m, "SourceName", Some(i.source_name))
  put_str(m, "SourcePartition", i.source_partition)
  put_str(m, "SourcePeer", i.source_peer)
  put_str(m, "DestinationNS", i.destination_ns)
  put_str(m, "DestinationName", Some(i.destination_name))
  put_str(m, "DestinationPartition", i.destination_partition)
  put_str(m, "SourceType", i.source_type)
  put_str(m, "Action", Some(i.action.code()))
  match i.meta {
    Some(md) => m["Meta"] = map_to_json(md)
    None => ()
  }
  put_json(m, "Permissions", i.permissions)
  Json::object(m)
}

///|
/// An `Intention` from its JSON object.
fn intention_of(j : Json) -> Intention {
  let m = json_map(j)
  {
    id: jstr_opt(m, "ID"),
    description: jstr_opt(m, "Description"),
    source_ns: jstr_opt(m, "SourceNS"),
    source_name: jstr(m, "SourceName"),
    source_partition: jstr_opt(m, "SourcePartition"),
    source_peer: jstr_opt(m, "SourcePeer"),
    destination_ns: jstr_opt(m, "DestinationNS"),
    destination_name: jstr(m, "DestinationName"),
    destination_partition: jstr_opt(m, "DestinationPartition"),
    source_type: jstr_opt(m, "SourceType"),
    action: match m.get("Action") {
      Some(String(s)) => IntentionAction::of(s[:]).unwrap_or(Deny)
      _ => Deny
    },
    meta: jmap_opt(m, "Meta"),
    permissions: m.get("Permissions"),
    precedence: ji32(m, "Precedence"),
    created_at: jstr_opt(m, "CreatedAt"),
    updated_at: jstr_opt(m, "UpdatedAt"),
    create_index: ju64(m, "CreateIndex"),
    modify_index: ju64(m, "ModifyIndex"),
  }
}

///|
/// The `source` or `destination` parameter of the exact-intention endpoints: a
/// bare name in the default namespace, otherwise the namespaces and partitions
/// in front of it, as the agent writes them.
fn intention_ref(partition : String?, ns : String?, name : String) -> String {
  match (partition, ns) {
    (None, None) => name
    (None, Some(n)) => n + "/" + name
    (Some(p), None) => p + "/default/" + name
    (Some(p), Some(n)) => p + "/" + n + "/" + name
  }
}

///|
/// The `source` parameter naming this intention's source.
pub fn Intention::source_ref(self : Intention) -> String {
  intention_ref(self.source_partition, self.source_ns, self.source_name)
}

///|
/// The `destination` parameter naming this intention's destination.
pub fn Intention::destination_ref(self : Intention) -> String {
  intention_ref(
    self.destination_partition,
    self.destination_ns,
    self.destination_name,
  )
}

///|
/// Order intentions the way the agent ranks them: the highest precedence first.
fn sort_by_precedence(list : Array[Intention]) -> Unit {
  list.sort_by((a, b) => b.precedence - a.precedence)
}

///|
/// Every intention in the datacenter, highest precedence first.
pub async fn Connect::intention_list(
  self : Connect,
  options~ : QueryOptions = default_query_options,
) -> (Array[Intention], QueryMeta) raise Refused {
  let (json, meta) = self.client.do_get("/v1/connect/intentions", options, [])
  let out : Array[Intention] = []
  for item in json_array(json) {
    out.push(intention_of(item))
  }
  sort_by_precedence(out)
  (out, meta)
}

///|
/// One intention by the agent's id, or `None` when there is none.
pub async fn Connect::intention_get(
  self : Connect,
  id : String,
  options~ : QueryOptions = default_query_options,
) -> (Intention?, QueryMeta) raise Refused {
  let (json, meta) = self.client.do_get_found(
    "/v1/connect/intentions/" + url_encode(id),
    options,
    [],
  )
  (json.map(intention_of), meta)
}

///|
/// One intention by the pair of services it names, or `None` when there is
/// none. `source` and `destination` are names, `namespace/name` when the
/// service lives outside the default namespace.
pub async fn Connect::intention_get_exact(
  self : Connect,
  source : String,
  destination : String,
  options~ : QueryOptions = default_query_options,
) -> (Intention?, QueryMeta) raise Refused {
  let (json, meta) = self.client.do_get_found(
    "/v1/connect/intentions/exact",
    options,
    [("source", source), ("destination", destination)],
  )
  (json.map(intention_of), meta)
}

///|
/// Create the intention, or replace the one between the same two services.
/// Creating and updating are the same request: the pair of services names the
/// intention.
pub async fn Connect::intention_upsert(
  self : Connect,
  intention : Intention,
  options~ : WriteOptions = default_write_options,
) -> WriteMeta raise Refused {
  if intention.source_name == "" || intention.destination_name == "" {
    raise Refused::Param(
      "intention_upsert: source_name and destination_name are required",
    )
  }
  let body = @utf8.encode(intention_to_json(intention).stringify())
  let (_, meta) = self.client.do_put(
    "/v1/connect/intentions/exact",
    body,
    options,
    [
      ("source", intention.source_ref()),
      ("destination", intention.destination_ref()),
    ],
  )
  meta
}

///|
/// Delete the intention with the agent's id.
pub async fn Connect::intention_delete(
  self : Connect,
  id : String,
  options~ : WriteOptions = default_write_options,
) -> WriteMeta raise Refused {
  let (_, meta) = self.client.do_delete(
    "/v1/connect/intentions/" + url_encode(id),
    options,
    [],
  )
  meta
}

///|
/// Delete the intention between the two services named.
pub async fn Connect::intention_delete_exact(
  self : Connect,
  source : String,
  destination : String,
  options~ : WriteOptions = default_write_options,
) -> WriteMeta raise Refused {
  let (_, meta) = self.client.do_delete(
    "/v1/connect/intentions/exact",
    options,
    [("source", source), ("destination", destination)],
  )
  meta
}

///|
/// The intentions that come into play for each name given, keyed by that name,
/// highest precedence first. Matching by source answers "what may reach this
/// service", matching by destination "what may this service reach".
pub async fn Connect::intention_match(
  self : Connect,
  by : IntentionMatchBy,
  names : Array[String],
  options~ : QueryOptions = default_query_options,
) -> (Map[String, Array[Intention]], QueryMeta) raise Refused {
  let extra : Array[(String, String)] = [("by", by.code())]
  for name in names {
    extra.push(("name", name))
  }
  let (json, meta) = self.client.do_get(
    "/v1/connect/intentions/match", options, extra,
  )
  let out : Map[String, Array[Intention]] = Map([])
  guard json is Object(entries) else {
    raise Refused::Json("intention match: expected an object")
  }
  for name, value in entries {
    let list : Array[Intention] = []
    for item in json_array(value) {
      list.push(intention_of(item))
    }
    sort_by_precedence(list)
    out[name] = list
  }
  (out, meta)
}

///|
/// Whether a connection from `source` to `destination` would be allowed, as the
/// agent decides it from the intentions in place.
pub async fn Connect::intention_check(
  self : Connect,
  source : String,
  destination : String,
  source_type~ : String = "",
  options~ : QueryOptions = default_query_options,
) -> (Bool, QueryMeta) raise Refused {
  let extra : Array[(String, String)] = [
    ("source", source),
    ("destination", destination),
  ]
  if source_type != "" {
    extra.push(("source-type", source_type))
  }
  let (json, meta) = self.client.do_get(
    "/v1/connect/intentions/check", options, extra,
  )
  (jbool(json_map(json), "Allowed"), meta)
}