///|
/// A fault in the program that sets up a host: a service name, a code or a
/// service set that breaks its rules. An invalid application name is an
/// `@lander_core.AppNameError`.
pub(all) suberror HostError {
  /// The text is not a valid service name.
  InvalidServiceName(text~ : String, reason~ : String)
  /// The builder already has the service `existing`, whose name equals `name`
  /// or differs from it only in letter case.
  DuplicateService(name~ : ServiceName, existing~ : ServiceName)
  /// The number is not in the exit code range 0 to 255.
  InvalidExitCode(code~ : Int)
} derive(Debug)

///|
pub extend HostError with @debug.Debug::{to_repr}

///|
/// Text for people that describes the fault.
pub fn HostError::message(self : HostError) -> String {
  match self {
    InvalidServiceName(text~, reason~) =>
      "Invalid service name \{text.escape()}: \{reason}"
    DuplicateService(name~, existing~) =>
      if name == existing {
        "The host already has a service named \{name.to_string().escape()}"
      } else {
        "The host already has a service named \{existing.to_string().escape()}; \{name.to_string().escape()} differs from it only in letter case"
      }
    InvalidExitCode(code~) =>
      "Invalid exit code \{code}: an exit code is in the range 0 to 255"
  }
}

///|
/// The name of a hosted service. It is safe as one component of a file path on
/// Linux, macOS and Windows:
///
/// - It starts with a character that Unicode allows at the start of an
///   identifier (`XID_Start`: letters and letter numbers).
/// - The other characters are identifier characters (`XID_Continue`: letters,
///   combining marks, digits, `_` and a few connector characters such as `·`),
///   `-` or `.`. So it has no path separator, no reserved punctuation, no
///   whitespace and no control or direction character.
/// - It has no invisible identifier character, such as a zero-width joiner, a
///   variation selector or a Hangul filler. A name that looks blank or looks
///   like another name cannot occur, and macOS HFS+ cannot ignore part of it.
/// - It does not end with `.`, which Windows removes.
/// - It is not a Windows device name such as `CON` or `com1`, also not before
///   an extension, as in `nul.log`.
/// - It is at most 128 bytes in UTF-8, so a file name with a prefix or an
///   extension stays under the 255-byte limit.
///
/// The parse does not normalize the text. Two names that differ only in their
/// Unicode normalization form are different names.
pub struct ServiceName {
  priv text : String
} derive(Eq, Hash, Debug)

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

///|
pub extend ServiceName with Hash::{hash, hash_combine}

///|
pub extend ServiceName with @debug.Debug::{to_repr}

///|
/// The longest service name in UTF-8 bytes.
const MAX_SERVICE_NAME_BYTES : Int = 128

///|
/// Parse a service name, for example `db`, `db.primary` or `données`.
pub fn ServiceName::parse(text : String) -> ServiceName raise HostError {
  fn invalid(reason : String) -> HostError {
    InvalidServiceName(text~, reason~)
  }

  guard text.iter().head() is Some(first) else {
    raise invalid("the name is empty")
  }
  guard @xid.is_xid_start(first) && !is_invisible(first) else {
    raise invalid("the name must start with a letter")
  }
  let mut bytes = 0
  let mut last = first
  for char in text {
    guard @xid.is_xid_continue(char) || char == '-' || char == '.' else {
      raise invalid(
        "the character \{char.to_string().escape()} is not allowed; use letters, combining marks, digits, '_', '-' and '.'",
      )
    }
    if is_invisible(char) {
      raise invalid(
        "the character \{char.to_string().escape()} is not allowed; it is invisible",
      )
    }
    bytes += utf8_length(char)
    last = char
  }
  if last == '.' {
    raise invalid("the name must not end with '.'")
  }
  if bytes > MAX_SERVICE_NAME_BYTES {
    raise invalid(
      "the name is \{bytes} bytes in UTF-8; the limit is \{MAX_SERVICE_NAME_BYTES}",
    )
  }
  if is_windows_device_name(text) {
    raise invalid("the name is a Windows device name")
  }
  { text, }
}

///|
/// The name as text, which `ServiceName::parse` reads back to an equal name.
pub impl Show for ServiceName with output(self, logger) {
  logger.write_string(self.text)
}

///|
pub extend ServiceName with Show::{output, to_string}

///|
/// A value that gives a service name: a `ServiceName`, or a `String` that
/// `ServiceName::parse` reads. So `service` takes either, and parses text at the
/// boundary. After that step, the host holds only the `ServiceName`.
pub trait IntoServiceName {
  into_service_name(Self) -> ServiceName raise HostError
}

///|
pub impl IntoServiceName for ServiceName with into_service_name(self) {
  self
}

///|
pub extend ServiceName with IntoServiceName::{into_service_name}

///|
pub impl IntoServiceName for String with into_service_name(self) {
  ServiceName::parse(self)
}

///|
/// Whether two names give the same file on a file system that ignores case:
/// Windows compares names by their uppercase form, and macOS by case folding.
fn ServiceName::same_file(self : ServiceName, other : ServiceName) -> Bool {
  fold_case(self.text) == fold_case(other.text)
}

///|
/// The lowercase of the uppercase of each character. Two names that differ only
/// in letter case give the same text, also for pairs whose case mappings are
/// not symmetric, such as `Σ`, `σ` and `ς`, `µ` and `μ`, or `ſ` and `s`.
fn fold_case(text : String) -> String {
  let builder = StringBuilder()
  for char in text {
    for upper in @case_mapping.full_uppercase(char) {
      builder.write_string(@case_mapping.full_lowercase(upper))
    }
  }
  builder.to_string()
}

///|
/// Whether `char` is an identifier character that does not show: a Unicode
/// default-ignorable code point that `XID_Continue` allows, or a Hangul filler.
fn is_invisible(char : Char) -> Bool {
  char
  is (
    '\u{034F}'
    | '\u{115F}'..='\u{1160}'
    | '\u{17B4}'..='\u{17B5}'
    | '\u{180B}'..='\u{180F}'
    | '\u{200C}'..='\u{200D}'
    | '\u{3164}'
    | '\u{FE00}'..='\u{FE0F}'
    | '\u{FFA0}'
    | '\u{E0100}'..='\u{E01EF}'
  )
}

///|
/// The number of bytes that `char` takes in UTF-8.
fn utf8_length(char : Char) -> Int {
  let code = char.to_int()
  if code < 0x80 {
    1
  } else if code < 0x800 {
    2
  } else if code < 0x10000 {
    3
  } else {
    4
  }
}

///|
/// Whether the part before the first `.` is a name that Windows reserves for a
/// device: `CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9` and `LPT0` to `LPT9`,
/// in any letter case. Windows also reserves `COM` and `LPT` with a superscript
/// digit, but a service name cannot hold a superscript digit.
fn is_windows_device_name(text : String) -> Bool {
  let base = match text.find(".") {
    Some(index) => text.view(end_offset=index).to_owned()
    None => text
  }
  let upper = base.to_upper()
  if upper is ("CON" | "PRN" | "AUX" | "NUL") {
    return true
  }
  let chars = Array::from_iter(upper.iter())
  chars.length() == 4 &&
  (upper.has_prefix("COM") || upper.has_prefix("LPT")) &&
  (chars[3] is ('0'..='9'))
}