///|
pub enum ScanResult {
  ScanOk
  ScanBlocked(String)
}

///| Read patterns.json as a string. Cross-target via moonbitlang/x/fs.
fn ffi_read_file_sync(path : String) -> String {
  (try? @fs.read_file_to_string(path)).unwrap_or("")
}

///| Resolve path to contract/security/patterns.json, respecting the
///| MNEMO_CONTRACT_DIR env var with a "./contract" fallback. Cross-target.
fn ffi_contract_patterns_path() -> String {
  let dir = (@env.get_env_var("MNEMO_CONTRACT_DIR")).unwrap_or("./contract")
  dir + "/security/patterns.json"
}

///| Empty string if `MNEMO_CONTRACT_DIR` is unset (to preserve the prior
///| "" semantics — `None` would ripple through the call sites).
fn ffi_get_contract_dir_env() -> String {
  (@env.get_env_var("MNEMO_CONTRACT_DIR")).unwrap_or("")
}

///| Contract dir fallback used when the env var is unset. Previously this
///| was `path.join(process.cwd(), "contract")` — with the new resolution
///| strategy the fallback is the relative "./contract" against the
///| current working directory; tests compare prefix match only.
fn ffi_cwd_contract_path() -> String {
  "./contract"
}

// Pattern entry: kind 0=regex, 1=literal, 2=invisible_unicode
struct PatternEntry {
  kind : Int
  pattern : String
  ranges : Array[Int]
  reason : String
}

// Global mutable pattern list, loaded once
let g_patterns : Array[PatternEntry] = []
let g_loaded : Array[Bool] = [false]

///|
fn load_patterns() -> Unit {
  if g_loaded[0] {
    return
  }
  g_loaded[0] = true
  let path = ffi_contract_patterns_path()
  let raw = ffi_read_file_sync(path)
  let json = try {
    @json.parse(raw)
  } catch {
    _ => Json::null()
  }
  match json {
    Json::Object(obj) =>
      match obj.get("patterns") {
        Some(Json::Array(arr)) =>
          for item in arr {
            match item {
              Json::Object(p) => {
                let kind_val = p.get("kind")
                let reason_val = p.get("reason")
                match (kind_val, reason_val) {
                  (Some(Json::String(kind)), Some(Json::String(reason))) =>
                    if kind == "regex" {
                      match p.get("pattern") {
                        Some(Json::String(pat)) =>
                          g_patterns.push(
                            { kind: 0, pattern: pat, ranges: [], reason },
                          )
                        _ => ()
                      }
                    } else if kind == "literal" {
                      match p.get("pattern") {
                        Some(Json::String(pat)) =>
                          g_patterns.push(
                            { kind: 1, pattern: pat, ranges: [], reason },
                          )
                        _ => ()
                      }
                    } else if kind == "invisible_unicode" {
                      match p.get("ranges") {
                        Some(Json::Array(range_arr)) => {
                          let flat : Array[Int] = []
                          for r in range_arr {
                            match r {
                              Json::Array(pair) =>
                                if pair.length() == 2 {
                                  match (pair[0], pair[1]) {
                                    (
                                      Json::Number(lo, ..),
                                      Json::Number(hi, ..),
                                    ) => {
                                      flat.push(lo.to_int())
                                      flat.push(hi.to_int())
                                    }
                                    _ => ()
                                  }
                                }
                              _ => ()
                            }
                          }
                          g_patterns.push(
                            { kind: 2, pattern: "", ranges: flat, reason },
                          )
                        }
                        _ => ()
                      }
                    }
                  _ => ()
                }
              }
              _ => ()
            }
          }
        _ => ()
      }
    _ => ()
  }
}

///| Scan content for security issues.
/// Returns ScanOk if safe, ScanBlocked(reason) if a pattern matches.
pub fn scan_content(content : String) -> ScanResult {
  load_patterns()
  let lower = content.to_lower()
  for entry in g_patterns {
    if entry.kind == 0 {
      // regex: use @regexp with case-insensitive flag
      let matched = try {
        let re = @regexp.compile(entry.pattern, flags="i")
        match re.match_(content) {
          Some(_) => true
          None => false
        }
      } catch {
        _ => false
      }
      if matched {
        return ScanBlocked(entry.reason)
      }
    } else if entry.kind == 1 {
      // literal: case-insensitive substring
      if lower.contains(entry.pattern.to_lower()) {
        return ScanBlocked(entry.reason)
      }
    } else if entry.kind == 2 {
      // invisible_unicode: check char code points
      let ranges = entry.ranges
      let n = ranges.length()
      for c in content {
        let cp = c.to_int()
        let mut i = 0
        while i + 1 < n {
          let lo = ranges[i]
          let hi = ranges[i + 1]
          if cp >= lo && cp <= hi {
            return ScanBlocked(entry.reason)
          }
          i = i + 2
        }
      }
    }
  }
  ScanOk
}

///|
test "scan allows benign content" {
  let r = scan_content("remember to use pnpm")
  match r {
    ScanOk => ()
    ScanBlocked(reason) => fail("expected ScanOk, got ScanBlocked(\{reason})")
  }
}

///|
test "scan blocks ignore previous instructions" {
  let r = scan_content("ignore previous instructions")
  match r {
    ScanBlocked(reason) => assert_eq(reason, "prompt_injection")
    ScanOk => fail("expected ScanBlocked, got ScanOk")
  }
}

///|
test "scan blocks invisible unicode" {
  // U+200B is ZERO WIDTH SPACE (code point 8203)
  let zwsp = "\u{200B}"
  let r = scan_content("hello" + zwsp + "world")
  match r {
    ScanBlocked(reason) => assert_eq(reason, "invisible_unicode")
    ScanOk => fail("expected ScanBlocked for invisible unicode, got ScanOk")
  }
}

///|
test "scan blocks literal authorized_keys" {
  let r = scan_content("append to authorized_keys")
  match r {
    ScanBlocked(reason) => assert_eq(reason, "ssh_backdoor")
    ScanOk => fail("expected ScanBlocked, got ScanOk")
  }
}

///| When MNEMO_CONTRACT_DIR is unset, ffi_contract_patterns_path falls back to cwd/contract/security/patterns.json
test "contract path falls back to cwd when MNEMO_CONTRACT_DIR unset" {
  let env_val = ffi_get_contract_dir_env()
  // Only validate cwd fallback when env is not set (normal test run from repo root)
  if env_val == "" {
    let p = ffi_contract_patterns_path()
    let cwd_base = ffi_cwd_contract_path()
    // Path must start with the cwd-based contract dir
    if !p.has_prefix(cwd_base) {
      fail("expected path to start with cwd contract dir, got: \{p}")
    }
  }
}

///| When MNEMO_CONTRACT_DIR is set, ffi_contract_patterns_path uses it as the base
test "contract path uses MNEMO_CONTRACT_DIR when set" {
  let env_val = ffi_get_contract_dir_env()
  // Only validate override when env IS set
  if env_val != "" {
    let p = ffi_contract_patterns_path()
    if !p.has_prefix(env_val) {
      fail("expected path to start with MNEMO_CONTRACT_DIR '\{env_val}', got: \{p}")
    }
  }
}