///|
/// A parsed Content-Security-Policy header.
pub struct Policy {
  directives : Array[Directive]
}

///|
/// One CSP directive such as `script-src 'self' https:`.
pub struct Directive {
  name : String
  sources : Array[Source]
}

///|
/// A source expression inside a CSP directive.
pub enum Source {
  None
  Self
  UnsafeInline
  UnsafeEval
  StrictDynamic
  UnsafeHashes
  ReportSample
  WasmUnsafeEval
  Host(String)
  Scheme(String)
  Nonce(String)
  Hash(String, String)
  Keyword(String)
} derive(Eq, Debug)

///|
/// A resource request used by the violation checker.
pub struct Request {
  kind : String
  url : String
  origin : String
  nonce : String?
  hash_sha256 : String?
}

///|
/// Build a checker request.
pub fn request(
  kind : String,
  url : String,
  origin : String,
  nonce : String?,
  hash_sha256 : String?,
) -> Request {
  { kind, url, origin, nonce, hash_sha256, }
}

///|
/// Parse a CSP header into directives and source expressions.
pub fn parse(input : String) -> Result[Policy, String] {
  let trimmed = input.trim().to_owned()
  if trimmed == "" {
    return Err("csp.empty")
  }
  let dirs : Array[Directive] = []
  for chunk in trimmed.split(";") {
    let item = chunk.trim().to_owned()
    if item == "" {
      continue
    }
    let tokens = split_ws(item)
    if tokens.length() == 0 {
      continue
    }
    let name = tokens[0].to_lower()
    if !valid_name(name) {
      return Err("csp.name")
    }
    let sources : Array[Source] = []
    for i = 1; i < tokens.length(); i = i + 1 {
      match parse_source(tokens[i]) {
        Ok(src) => sources.push(src)
        Err(e) => return Err(e)
      }
    }
    dirs.push({ name, sources, })
  }
  if dirs.length() == 0 {
    return Err("csp.empty")
  }
  Ok({ directives: dirs, })
}

///|
/// Parse or return a diagnostic explanation.
pub fn parse_or_error(input : String) -> String {
  match parse(input) {
    Ok(policy) => Policy::to_string(policy)
    Err(e) => explain(e)
  }
}

///|
/// Directives in source order.
pub fn Policy::directives(self : Policy) -> Array[Directive] {
  self.directives
}

///|
/// Look up the first directive by case-insensitive name.
pub fn Policy::names(self : Policy) -> Array[String] {
  let out : Array[String] = []
  for d in self.directives {
    out.push(d.name)
  }
  out
}

///|
pub fn Policy::directive(self : Policy, name : String) -> Directive? {
  let wanted = name.to_lower()
  for d in self.directives {
    if d.name == wanted {
      return Some(d)
    }
  }
  None
}

///|
/// Canonical serialization: names and keywords lower-cased, order preserved.
pub fn Policy::to_string(self : Policy) -> String {
  let mut out = ""
  let mut first_dir = true
  for d in self.directives {
    if first_dir {
      first_dir = false
    } else {
      out = out + "; "
    }
    out = out + d.name
    for s in d.sources {
      out = out + " " + source_to_string(s)
    }
  }
  out
}

///|
/// Whether a request is allowed by this policy.
pub fn Policy::allows(self : Policy, request : Request) -> Bool {
  decide(self, request) == "allow"
}

///|
/// Return `allow` or a stable violation code.
pub fn decide(policy : Policy, request : Request) -> String {
  let kind = request.kind.to_lower()
  if !valid_kind(kind) {
    return "csp.kind"
  }
  match effective_directive(policy, kind) {
    None => "allow"
    Some(d) => check_directive(d, request)
  }
}

///|
fn effective_directive(policy : Policy, kind : String) -> Directive? {
  match Policy::directive(policy, kind + "-src") {
    Some(d) => Some(d)
    None => Policy::directive(policy, "default-src")
  }
}

///|
fn check_directive(d : Directive, request : Request) -> String {
  if d.sources.length() == 0 {
    return "csp.blocked"
  }
  for s in d.sources {
    match s {
      Source::None => return "csp.none"
      _ => ()
    }
  }
  match request.nonce {
    Some(n) =>
      for s in d.sources {
        match s {
          Source::Nonce(v) => if v == n { return "allow" }
          _ => ()
        }
      }
    None => ()
  }
  match request.hash_sha256 {
    Some(h) =>
      for s in d.sources {
        match s {
          Source::Hash(algo, v) =>
            if algo == "sha256" && v == h {
              return "allow"
            }
          _ => ()
        }
      }
    None => ()
  }
  if has_strict_dynamic(d) {
    return "csp.strict-dynamic"
  }
  for s in d.sources {
    if source_matches_url(s, request.url, request.origin) {
      return "allow"
    }
  }
  "csp.blocked"
}

///|
fn has_strict_dynamic(d : Directive) -> Bool {
  for s in d.sources {
    match s {
      Source::StrictDynamic => return true
      _ => ()
    }
  }
  false
}

///|
fn source_matches_url(s : Source, url : String, origin : String) -> Bool {
  match s {
    Source::Self => url_host(url) != "" && url_host(url) == url_host(origin)
    Source::Scheme(scheme) => url_scheme(url) == scheme
    Source::Host(host) => host_matches(host, url)
    _ => false
  }
}

///|
fn url_scheme(url : String) -> String {
  match url.find(":") {
    Some(i) => url.sub(end=i).to_owned().to_lower()
    None => ""
  }
}

///|
fn host_matches(pattern : String, url : String) -> Bool {
  let host = url_host(url)
  if host == "" {
    return false
  }
  if pattern.has_prefix("*.") {
    let suffix = pattern.sub(start=1).to_owned()
    host.has_suffix(suffix)
  } else {
    host == pattern || host.has_suffix("." + pattern)
  }
}

///|
fn url_host(url : String) -> String {
  let rest = match url.find("://") {
    Some(i) => url.sub(start=i + 3).to_owned()
    None => url
  }
  let authority = match rest.find("/") {
    Some(i) => rest.sub(end=i).to_owned()
    None => rest
  }
  let hostport = match authority.find("@") {
    Some(i) => authority.sub(start=i + 1).to_owned()
    None => authority
  }
  let host = match hostport.find(":") {
    Some(i) => hostport.sub(end=i).to_owned()
    None => hostport
  }
  host.to_lower()
}

///|
fn parse_source(token : String) -> Result[Source, String] {
  if token == "" {
    return Err("csp.source")
  }
  if token.has_prefix("'") {
    if token.length() < 2 || !token.has_suffix("'") {
      return Err("csp.quote")
    }
    let inner = token.sub(start=1, end=token.length() - 1).to_owned().to_lower()
    if inner == "none" {
      return Ok(Source::None)
    }
    if inner == "self" {
      return Ok(Source::Self)
    }
    if inner == "unsafe-inline" {
      return Ok(Source::UnsafeInline)
    }
    if inner == "unsafe-eval" {
      return Ok(Source::UnsafeEval)
    }
    if inner == "strict-dynamic" {
      return Ok(Source::StrictDynamic)
    }
    if inner == "unsafe-hashes" {
      return Ok(Source::UnsafeHashes)
    }
    if inner == "report-sample" {
      return Ok(Source::ReportSample)
    }
    if inner == "wasm-unsafe-eval" {
      return Ok(Source::WasmUnsafeEval)
    }
    if inner.has_prefix("nonce-") {
      let n = inner.sub(start=6).to_owned()
      if n == "" {
        return Err("csp.nonce")
      }
      return Ok(Source::Nonce(n))
    }
    if inner.has_prefix("sha256-") ||
      inner.has_prefix("sha384-") ||
      inner.has_prefix("sha512-") {
      match inner.find("-") {
        Some(dash) => {
          let algo = inner.sub(end=dash).to_owned()
          let value = inner.sub(start=dash + 1).to_owned()
          if value == "" {
            return Err("csp.hash")
          }
          return Ok(Source::Hash(algo, value))
        }
        None => return Err("csp.hash")
      }
    }
    return Ok(Source::Keyword(inner))
  }
  if token.has_suffix(":") &&
    url_scheme(token.sub(end=token.length() - 1).to_owned() + "://x") ==
    token.sub(end=token.length() - 1).to_owned().to_lower() {
    let scheme = token.sub(end=token.length() - 1).to_owned().to_lower()
    if scheme == "" {
      return Err("csp.scheme")
    }
    if !scheme.contains("/") {
      return Ok(Source::Scheme(scheme))
    }
  }
  if token.has_suffix(":") && !token.contains("/") {
    let scheme = token.sub(end=token.length() - 1).to_owned().to_lower()
    if scheme == "" {
      return Err("csp.scheme")
    }
    return Ok(Source::Scheme(scheme))
  }
  Ok(Source::Host(token.to_lower()))
}

///|
fn source_to_string(s : Source) -> String {
  match s {
    Source::None => "'none'"
    Source::Self => "'self'"
    Source::UnsafeInline => "'unsafe-inline'"
    Source::UnsafeEval => "'unsafe-eval'"
    Source::StrictDynamic => "'strict-dynamic'"
    Source::UnsafeHashes => "'unsafe-hashes'"
    Source::ReportSample => "'report-sample'"
    Source::WasmUnsafeEval => "'wasm-unsafe-eval'"
    Source::Host(h) => h
    Source::Scheme(sc) => sc + ":"
    Source::Nonce(n) => "'nonce-" + n + "'"
    Source::Hash(algo, v) => "'" + algo + "-" + v + "'"
    Source::Keyword(k) => "'" + k + "'"
  }
}

///|
fn split_ws(s : String) -> Array[String] {
  let out : Array[String] = []
  let mut acc = ""
  fn flush() {
    if acc != "" {
      out.push(acc)
      acc = ""
    }
  }

  for c in s {
    if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
      flush()
    } else {
      acc = acc + c.to_string()
    }
  }
  flush()
  out
}

///|
fn valid_name(s : String) -> Bool {
  if s == "" {
    return false
  }
  for c in s {
    if !(is_alnum(c) || c == '-') {
      return false
    }
  }
  true
}

///|
fn is_alnum(c : Char) -> Bool {
  (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}

///|
fn valid_kind(s : String) -> Bool {
  s == "script" ||
  s == "style" ||
  s == "img" ||
  s == "connect" ||
  s == "font" ||
  s == "frame" ||
  s == "media" ||
  s == "object" ||
  s == "default"
}

///|
/// Explain parser and checker diagnostics.
pub fn explain(error : String) -> String {
  match error {
    "csp.empty" => "policy is empty"
    "csp.name" => "directive name is invalid"
    "csp.source" => "source expression is empty"
    "csp.quote" => "quoted source is unterminated"
    "csp.nonce" => "nonce source is missing a value"
    "csp.hash" => "hash source is missing a value"
    "csp.scheme" => "scheme source is empty"
    "csp.kind" => "request kind is not supported"
    "csp.none" => "directive contains 'none'"
    "csp.blocked" => "request is not allowed by the effective directive"
    "csp.strict-dynamic" => "strict-dynamic requires a matching nonce or hash"
    "allow" => "request is allowed"
    _ => "invalid content-security-policy"
  }
}