///|
/// Small policy layer for examples and integration tests.
///
/// MoonRADIUS deliberately does not contain a user database.  This module
/// supplies deterministic in-memory decisions so an application can wire its
/// own store into the packet and authenticator APIs.
pub enum AccessDecision {
  Accept
  Reject(String)
  Challenge(String)
} derive(Eq, Debug)

///|
pub extend AccessDecision with Eq::{not_equal, equal}

///|
pub extend AccessDecision with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub struct Credential {
  username : String
  password : Bytes
}

///|
pub fn Credential::new(username : String, password : Bytes) -> Credential {
  Credential::{ username, password, }
}

///|
pub struct AccessPolicy {
  credentials : Array[Credential]
  require_chap : Bool
  challenge : Bytes
  reply_message : String?
}

///|
pub fn AccessPolicy::new() -> AccessPolicy {
  AccessPolicy::{
    credentials: [],
    require_chap: false,
    challenge: b"",
    reply_message: None,
  }
}

///|
pub fn AccessPolicy::allow(
  self : AccessPolicy,
  credential : Credential,
) -> AccessPolicy {
  self.credentials.push(credential)
  self
}

///|
pub fn AccessPolicy::with_chap(
  self : AccessPolicy,
  challenge : Bytes,
) -> AccessPolicy {
  AccessPolicy::{
    credentials: self.credentials,
    require_chap: true,
    challenge,
    reply_message: self.reply_message,
  }
}

///|
pub fn AccessPolicy::with_reply(
  self : AccessPolicy,
  message : String,
) -> AccessPolicy {
  AccessPolicy::{
    credentials: self.credentials,
    require_chap: self.require_chap,
    challenge: self.challenge,
    reply_message: Some(message),
  }
}

///|
fn text_attribute(packet : Packet, kind : Byte) -> String? {
  match packet.attribute(kind) {
    Some(attribute) => Some(@utf8.decode_lossy(attribute.value))
    None => None
  }
}

///|
fn credential_for(policy : AccessPolicy, username : String) -> Credential? {
  for credential in policy.credentials {
    if credential.username == username {
      return Some(credential)
    }
  }
  None
}

///|
/// Decide an Access-Request using PAP or CHAP, without performing I/O.
pub fn decide_access(
  packet : Packet,
  policy : AccessPolicy,
  secret : Bytes,
) -> AccessDecision raise RadiusError {
  if !packet.is_access_request() {
    return Reject("packet is not an Access-Request")
  }
  let username = match text_attribute(packet, User_Name) {
    Some(value) => value
    None => return Reject("missing User-Name")
  }
  let credential = match credential_for(policy, username) {
    Some(value) => value
    None => return Reject("unknown user")
  }
  if policy.require_chap {
    match packet.attribute(CHAP_Password) {
      Some(attribute) =>
        if verify_chap(attribute, credential.password, policy.challenge) {
          Accept
        } else {
          Reject("CHAP response did not verify")
        }
      None => Challenge("CHAP-Password is required")
    }
  } else {
    match packet.user_password(secret) {
      Some(password) =>
        if constant_time_equal(password, credential.password) ||
          trim_zero_padding(password) == credential.password {
          Accept
        } else {
          Reject("PAP password did not verify")
        }
      None => Reject("missing User-Password")
    }
  }
}

///|
/// Remove RFC 2865 block padding from a decrypted password.
pub fn trim_zero_padding(value : Bytes) -> Bytes {
  let mut end = value.length()
  while end > 0 && value[end - 1] == b'\x00' {
    end = end - 1
  }
  Bytes::makei(end, fn(index) { value[index] })
}

///|
/// Build the response corresponding to an Access decision.
pub fn access_response(
  request : Packet,
  decision : AccessDecision,
  secret : Bytes,
) -> Packet raise RadiusError {
  let base = match decision {
    Accept => Packet::new(AccessAccept, request.header.identifier, zeros(16))
    Reject(reason) =>
      Packet::new(AccessReject, request.header.identifier, zeros(16)).with_attribute(
        Attribute::text(Reply_Message, reason),
      )
    Challenge(message) =>
      Packet::new(AccessChallenge, request.header.identifier, zeros(16)).with_attribute(
        Attribute::text(Reply_Message, message),
      )
  }
  base.with_response_authenticator(request.header.authenticator, secret)
}

///|
/// Accounting data extracted from an Accounting-Request.
pub struct AccountingRecord {
  session_id : String?
  status_type : Int?
  input_octets : Int?
  output_octets : Int?
  input_packets : Int?
  output_packets : Int?
}

///|
pub fn AccountingRecord::from_packet(packet : Packet) -> AccountingRecord {
  AccountingRecord::{
    session_id: text_attribute(packet, Acct_Session_Id),
    status_type: integer_attribute(packet, Acct_Status_Type),
    input_octets: integer_attribute(packet, Acct_Input_Octets),
    output_octets: integer_attribute(packet, Acct_Output_Octets),
    input_packets: integer_attribute(packet, Acct_Input_Packets),
    output_packets: integer_attribute(packet, Acct_Output_Packets),
  }
}

///|
fn integer_attribute(packet : Packet, kind : Byte) -> Int? {
  match packet.attribute(kind) {
    Some(attribute) => attribute.as_integer()
    None => None
  }
}

///|
/// Validate the minimum shape of an Accounting-Request.
pub fn validate_accounting_request(packet : Packet) -> Array[String] {
  let errors = []
  if !packet.is_accounting_request() {
    errors.push("packet is not an Accounting-Request")
  }
  if packet.attribute(Acct_Status_Type) is None {
    errors.push("missing Acct-Status-Type")
  }
  if packet.attribute(Acct_Session_Id) is None {
    errors.push("missing Acct-Session-Id")
  }
  errors
}

///|
/// Create an Accounting-Response with the correct authenticator.
pub fn accounting_response(
  request : Packet,
  secret : Bytes,
) -> Packet raise RadiusError {
  if validate_accounting_request(request).length() > 0 {
    raise InvalidAttribute("accounting request is missing required attributes")
  }
  Packet::new(AccountingResponse, request.header.identifier, zeros(16)).with_response_authenticator(
    request.header.authenticator,
    secret,
  )
}