///|
/// RoboPolicy parses robots.txt documents and answers crawler access decisions.
/// It is designed for deterministic CI checks, site audits, and Wasm tools.
pub(all) enum IssueKind {
  MissingColon
  EmptyUserAgent
  EmptyDirective
  InvalidCrawlDelay
  RuleBeforeUserAgent
  UnknownDirective
} derive(Eq, Debug)

///|
pub(all) enum DirectiveKind {
  UserAgent
  Allow
  Disallow
  Sitemap
  CrawlDelay
  Host
  Unknown
} derive(Eq, Debug)

///|
pub(all) enum FindingSeverity {
  Error
  Warning
  Info
} derive(Eq, Debug)

///|
pub(all) struct ParseIssue {
  kind : IssueKind
  line : Int
  directive : String
  value : String
  message : String
} derive(Eq, Debug)

///|
pub(all) struct Rule {
  allow : Bool
  pattern : String
  line : Int
} derive(Eq, Debug)

///|
pub(all) struct AgentGroup {
  agents : Array[String]
  rules : Array[Rule]
  crawl_delay : Int?
  line : Int
} derive(Eq, Debug)

///|
pub(all) struct RobotsPolicy {
  groups : Array[AgentGroup]
  sitemaps : Array[String]
  host : String?
  issues : Array[ParseIssue]
} derive(Eq, Debug)

///|
pub(all) struct AccessDecision {
  allowed : Bool
  user_agent : String
  path : String
  matched_group : String
  matched_rule : Rule?
  reason : String
} derive(Eq, Debug)

///|
pub(all) struct Finding {
  code : String
  severity : FindingSeverity
  message : String
  detail : String
} derive(Eq, Debug)

///|
pub(all) struct AuditReport {
  policy : RobotsPolicy
  findings : Array[Finding]
} derive(Eq, Debug)

///|
pub fn IssueKind::label(self : IssueKind) -> String {
  match self {
    MissingColon => "missing-colon"
    EmptyUserAgent => "empty-user-agent"
    EmptyDirective => "empty-directive"
    InvalidCrawlDelay => "invalid-crawl-delay"
    RuleBeforeUserAgent => "rule-before-user-agent"
    UnknownDirective => "unknown-directive"
  }
}

///|
pub fn DirectiveKind::label(self : DirectiveKind) -> String {
  match self {
    UserAgent => "user-agent"
    Allow => "allow"
    Disallow => "disallow"
    Sitemap => "sitemap"
    CrawlDelay => "crawl-delay"
    Host => "host"
    Unknown => "unknown"
  }
}

///|
pub fn FindingSeverity::label(self : FindingSeverity) -> String {
  match self {
    Error => "error"
    Warning => "warning"
    Info => "info"
  }
}

///|
pub fn parse(raw : StringView) -> RobotsPolicy {
  let groups : Array[AgentGroup] = []
  let sitemaps : Array[String] = []
  let issues : Array[ParseIssue] = []
  let mut host : String? = None
  let mut current_agents : Array[String] = []
  let mut current_rules : Array[Rule] = []
  let mut current_delay : Int? = None
  let mut current_line = 0
  let mut group_has_rules = false
  for offset, line in raw.split("\n").iter2() {
    let line_number = offset + 1
    let cleaned = strip_comment(line).trim()
    if cleaned.is_empty() {
      continue
    }
    match cleaned.split_once(":") {
      Some((name_view, value_view)) => {
        let name = name_view.trim().to_lower()
        let value = value_view.trim().to_owned()
        if name.is_empty() {
          issues.push(
            issue(
              EmptyDirective,
              line_number,
              name,
              value,
              "directive name must not be empty",
            ),
          )
          continue
        }
        match directive_kind(name) {
          UserAgent => {
            if value.is_empty() {
              issues.push(
                issue(
                  EmptyUserAgent,
                  line_number,
                  name,
                  value,
                  "user-agent value must not be empty",
                ),
              )
              continue
            }
            if group_has_rules && !current_agents.is_empty() {
              groups.push({
                agents: current_agents,
                rules: current_rules,
                crawl_delay: current_delay,
                line: current_line,
              })
              current_agents = []
              current_rules = []
              current_delay = None
              group_has_rules = false
            }
            if current_agents.is_empty() {
              current_line = line_number
            }
            current_agents.push(normalize_agent(value))
          }
          Allow =>
            if current_agents.is_empty() {
              issues.push(
                issue(
                  RuleBeforeUserAgent,
                  line_number,
                  name,
                  value,
                  "allow rule appeared before any user-agent group",
                ),
              )
            } else {
              current_rules.push({
                allow: true,
                pattern: value,
                line: line_number,
              })
              group_has_rules = true
            }
          Disallow =>
            if current_agents.is_empty() {
              issues.push(
                issue(
                  RuleBeforeUserAgent,
                  line_number,
                  name,
                  value,
                  "disallow rule appeared before any user-agent group",
                ),
              )
            } else if !value.is_empty() {
              current_rules.push({
                allow: false,
                pattern: value,
                line: line_number,
              })
              group_has_rules = true
            } else {
              group_has_rules = true
            }
          CrawlDelay =>
            if current_agents.is_empty() {
              issues.push(
                issue(
                  RuleBeforeUserAgent,
                  line_number,
                  name,
                  value,
                  "crawl-delay appeared before any user-agent group",
                ),
              )
            } else {
              match parse_non_negative_int(value) {
                Some(delay) => {
                  current_delay = Some(delay)
                  group_has_rules = true
                }
                None =>
                  issues.push(
                    issue(
                      InvalidCrawlDelay,
                      line_number,
                      name,
                      value,
                      "crawl-delay must be a non-negative integer",
                    ),
                  )
              }
            }
          Sitemap =>
            if value.is_empty() {
              issues.push(
                issue(
                  EmptyDirective,
                  line_number,
                  name,
                  value,
                  "sitemap value must not be empty",
                ),
              )
            } else {
              sitemaps.push(value)
            }
          Host => if !value.is_empty() { host = Some(value) }
          Unknown =>
            issues.push(
              issue(
                UnknownDirective,
                line_number,
                name,
                value,
                "directive is not part of the supported robots.txt subset",
              ),
            )
        }
      }
      None =>
        issues.push(
          issue(
            MissingColon,
            line_number,
            "",
            cleaned.to_owned(),
            "robots.txt directives must use 'name: value' syntax",
          ),
        )
    }
  }
  if !current_agents.is_empty() {
    groups.push({
      agents: current_agents,
      rules: current_rules,
      crawl_delay: current_delay,
      line: current_line,
    })
  }
  { groups, sitemaps, host, issues }
}

///|
pub fn RobotsPolicy::is_allowed(
  self : RobotsPolicy,
  user_agent : StringView,
  url_or_path : StringView,
) -> Bool {
  self.decide(user_agent, url_or_path).allowed
}

///|
pub fn RobotsPolicy::decide(
  self : RobotsPolicy,
  user_agent : StringView,
  url_or_path : StringView,
) -> AccessDecision {
  let path = extract_path(url_or_path)
  match best_group(self.groups, user_agent) {
    Some(group) => {
      let rule = best_rule(group.rules, path)
      match rule {
        Some(r) =>
          {
            allowed: r.allow,
            user_agent: user_agent.to_owned(),
            path,
            matched_group: group.agents.join(","),
            matched_rule: Some(r),
            reason: if r.allow {
              "allowed by longest matching allow rule"
            } else {
              "blocked by longest matching disallow rule"
            },
          }
        None =>
          {
            allowed: true,
            user_agent: user_agent.to_owned(),
            path,
            matched_group: group.agents.join(","),
            matched_rule: None,
            reason: "no rule matched in selected group",
          }
      }
    }
    None =>
      {
        allowed: true,
        user_agent: user_agent.to_owned(),
        path,
        matched_group: "",
        matched_rule: None,
        reason: "no matching user-agent group",
      }
  }
}

///|
pub fn RobotsPolicy::crawl_delay_for(
  self : RobotsPolicy,
  user_agent : StringView,
) -> Int? {
  match best_group(self.groups, user_agent) {
    Some(group) => group.crawl_delay
    None => None
  }
}

///|
pub fn audit(policy : RobotsPolicy) -> AuditReport {
  let findings : Array[Finding] = []
  for issue in policy.issues {
    findings.push({
      code: "parse." + issue.kind.label(),
      severity: if issue.kind == UnknownDirective {
        Info
      } else {
        Warning
      },
      message: issue.message,
      detail: "line " + issue.line.to_string() + ": " + issue.directive,
    })
  }
  if policy.groups.is_empty() {
    findings.push({
      code: "policy.no-user-agent",
      severity: Error,
      message: "robots.txt does not define any user-agent group",
      detail: "crawlers will treat the policy as allow-all",
    })
  }
  if policy.sitemaps.is_empty() {
    findings.push({
      code: "policy.no-sitemap",
      severity: Info,
      message: "no sitemap directive was found",
      detail: "adding sitemap helps crawlers discover canonical URLs",
    })
  }
  for group in policy.groups {
    if group.agents.contains("*") && group.rules.is_empty() {
      findings.push({
        code: "policy.wildcard-empty",
        severity: Info,
        message: "wildcard group contains no effective allow/disallow rule",
        detail: "line " + group.line.to_string(),
      })
    }
    if blocks_everything(group) {
      findings.push({
        code: "policy.block-all",
        severity: Warning,
        message: "a user-agent group blocks the entire site",
        detail: group.agents.join(","),
      })
    }
    if group.crawl_delay is Some(delay) && delay > 60 {
      findings.push({
        code: "policy.high-crawl-delay",
        severity: Info,
        message: "crawl-delay is higher than 60 seconds",
        detail: group.agents.join(","),
      })
    }
  }
  if findings.is_empty() {
    findings.push({
      code: "policy.ok",
      severity: Info,
      message: "robots.txt parsed successfully and no blocking issue was found",
      detail: policy.groups.length().to_string() + " group(s)",
    })
  }
  { policy, findings }
}

///|
pub fn AuditReport::has_finding(self : AuditReport, code : StringView) -> Bool {
  let expected = code.to_owned()
  self.findings.any(f => f.code == expected)
}

///|
pub fn AuditReport::to_markdown(self : AuditReport) -> String {
  let lines : Array[String] = []
  lines.push("# RoboPolicy Audit")
  lines.push("")
  lines.push("- Groups: " + self.policy.groups.length().to_string())
  lines.push("- Sitemaps: " + self.policy.sitemaps.length().to_string())
  lines.push("- Findings: " + self.findings.length().to_string())
  lines.push("")
  lines.push("| Severity | Code | Message |")
  lines.push("| --- | --- | --- |")
  for finding in self.findings {
    lines.push(
      "| " +
      finding.severity.label() +
      " | " +
      finding.code +
      " | " +
      finding.message.replace_all(old="|", new="\\|") +
      " |",
    )
  }
  lines.join("\n")
}

///|
pub fn AccessDecision::to_line(self : AccessDecision) -> String {
  let status = if self.allowed { "ALLOW" } else { "BLOCK" }
  status + " " + self.user_agent + " " + self.path + " - " + self.reason
}

///|
pub fn sample_robots() -> String {
  [
    "User-agent: *", "Disallow: /admin/", "Disallow: /tmp/*.json$", "Allow: /tmp/public.json",
    "Crawl-delay: 5", "", "User-agent: ResearchBot", "Disallow: /private/", "Allow: /private/summary.html",
    "Sitemap: https://example.test/sitemap.xml",
  ].join("\n")
}

///|
pub fn sample_audit_markdown() -> String {
  audit(parse(sample_robots())).to_markdown()
}

///|
fn directive_kind(name : StringView) -> DirectiveKind {
  match name {
    "user-agent" => UserAgent
    "allow" => Allow
    "disallow" => Disallow
    "sitemap" => Sitemap
    "crawl-delay" => CrawlDelay
    "host" => Host
    _ => Unknown
  }
}

///|
fn strip_comment(line : StringView) -> StringView {
  match line.split_once("#") {
    Some((left, _)) => left
    None => line
  }
}

///|
fn normalize_agent(agent : StringView) -> String {
  agent.trim().to_lower().to_owned()
}

///|
fn issue(
  kind : IssueKind,
  line : Int,
  directive : StringView,
  value : String,
  message : String,
) -> ParseIssue {
  { kind, line, directive: directive.to_owned(), value, message }
}

///|
fn parse_non_negative_int(value : StringView) -> Int? {
  if !value.is_empty() && value.all(ch => ch >= '0' && ch <= '9') {
    try @string.parse_int(value) |> Some catch {
      _ => None
    }
  } else {
    None
  }
}

///|
fn best_group(
  groups : Array[AgentGroup],
  user_agent : StringView,
) -> AgentGroup? {
  let agent = normalize_agent(user_agent)
  let mut best : AgentGroup? = None
  let mut best_len = -1
  for group in groups {
    for token in group.agents {
      let score = if token == "*" {
        0
      } else if agent.contains(token) {
        token.length()
      } else {
        -1
      }
      if score >= 0 && score > best_len {
        best = Some(group)
        best_len = score
      }
    }
  }
  best
}

///|
fn best_rule(rules : Array[Rule], path : String) -> Rule? {
  let mut best : Rule? = None
  let mut best_len = -1
  for rule in rules {
    if rule_matches(rule.pattern, path) {
      let length = pattern_weight(rule.pattern)
      let replace = length > best_len ||
        (length == best_len && rule.allow && best is Some(old) && !old.allow)
      if replace {
        best = Some(rule)
        best_len = length
      }
    }
  }
  best
}

///|
fn rule_matches(pattern : String, path : String) -> Bool {
  if pattern.is_empty() {
    false
  } else if pattern.has_suffix("$") {
    let body = pattern.strip_suffix("$").unwrap().to_owned()
    wildcard_match(body, path, anchored_end=true)
  } else {
    wildcard_match(pattern, path, anchored_end=false)
  }
}

///|
fn wildcard_match(
  pattern : String,
  path : String,
  anchored_end~ : Bool,
) -> Bool {
  wildcard_from(pattern, 0, path, 0, anchored_end)
}

///|
fn wildcard_from(
  pattern : String,
  pi : Int,
  path : String,
  si : Int,
  anchored_end : Bool,
) -> Bool {
  if pi >= pattern.length() {
    if anchored_end {
      si == path.length()
    } else {
      true
    }
  } else {
    match pattern.get_char(pi) {
      Some('*') => {
        let mut cursor = si
        while cursor <= path.length() {
          if wildcard_from(pattern, pi + 1, path, cursor, anchored_end) {
            return true
          }
          cursor = cursor + 1
        }
        false
      }
      Some(ch) =>
        match path.get_char(si) {
          Some(actual) =>
            if ch == actual {
              wildcard_from(pattern, pi + 1, path, si + 1, anchored_end)
            } else {
              false
            }
          None => false
        }
      None => false
    }
  }
}

///|
fn pattern_weight(pattern : String) -> Int {
  let mut count = 0
  for ch in pattern {
    if ch != '*' && ch != '$' {
      count = count + 1
    }
  }
  count
}

///|
fn blocks_everything(group : AgentGroup) -> Bool {
  group.rules.any(rule => !rule.allow && rule.pattern == "/")
}