///|
/// Release rules layered on MoonPolicy. They do not change authorization
/// semantics. Unlisted actions have a zero change budget.
pub(all) struct ReleaseConfig {
  action_budgets : Array[ActionBudget]
  forbid_cross_tenant : Bool
  tenant_attribute : String
  forbidden_actions : Array[String]
} derive(Debug)

///|
pub fn strict_release_config() -> ReleaseConfig {
  {
    action_budgets: [],
    forbid_cross_tenant: true,
    tenant_attribute: "tenant",
    forbidden_actions: [],
  }
}

///|
pub(all) struct ReleaseFinding {
  code : String
  message : String
  request : @moon_policy.Request?
} derive(Debug)

///|
pub(all) struct ReleaseReport {
  complete : Bool
  requests_evaluated : Int
  changes : Array[@moon_policy.AccessChange]
  findings : Array[ReleaseFinding]
  verdict : GateVerdict
} derive(Debug)

///|
fn release_issue(
  findings : Array[ReleaseFinding],
  code : String,
  message : String,
  request? : @moon_policy.Request,
) -> Unit {
  findings.push({ code, message, request, })
}

///|
/// Canonical object ordering prevents duplicate samples with different
/// attribute insertion orders from consuming multiple units of budget.
fn canonical_release_json(value : Json) -> String {
  match value {
    Object(fields) => {
      let keys = fields.keys().to_array()
      keys.sort()
      let out = StringBuilder()
      out.write_string("{")
      for i, key in keys {
        if i > 0 {
          out.write_string(",")
        }
        out.write_string(json_escape(key))
        out.write_string(":")
        if fields.get(key) is Some(item) {
          out.write_string(canonical_release_json(item))
        }
      }
      out.write_string("}")
      out.to_string()
    }
    Array(items) => "[" + items.map(canonical_release_json).join(",") + "]"
    _ => value.stringify()
  }
}

///|
fn release_request_key(request : @moon_policy.Request) -> String {
  canonical_release_json(request.to_json())
}

///|
fn release_tenant(
  attributes : Map[String, @moon_policy.AttributeValue],
  key : String,
) -> String? {
  match attributes.get(key) {
    Some(@moon_policy.StringValue(value)) =>
      if value == "" {
        None
      } else {
        Some(value)
      }
    _ => None
  }
}

///|
/// Delegates every access decision to the published MoonPolicy engine.
/// Tenant invariants cover all allowed replacement decisions, including
/// unchanged access. Invalid metadata makes the conclusion indeterminate.
pub fn audit_release(
  before : @moon_policy.Policy,
  after : @moon_policy.Policy,
  requests : Array[@moon_policy.Request],
  config : ReleaseConfig,
) -> ReleaseReport {
  let findings : Array[ReleaseFinding] = []
  for policy in [before, after] {
    for issue in policy.validate() {
      release_issue(
        findings,
        "INVALID_POLICY",
        "\{issue.code} at \{issue.location}: \{issue.message}",
      )
    }
  }
  if requests.length() == 0 || requests.length() > 100000 {
    release_issue(findings, "INVALID_CORPUS", "Need 1..100000 request samples")
  }
  if config.forbid_cross_tenant &&
    (
      config.tenant_attribute == "" ||
      config.tenant_attribute == "id" ||
      config.tenant_attribute.contains(".")
    ) {
    release_issue(
      findings, "INVALID_TENANT_KEY", "Tenant key must name one top-level attribute",
    )
  }
  let seen_actions : Map[String, Bool] = Map([])
  for budget in config.action_budgets {
    if budget.action == "" ||
      budget.max_new_grants < 0 ||
      budget.max_revocations < 0 ||
      seen_actions.get(budget.action) is Some(_) {
      release_issue(
        findings, "INVALID_BUDGET", "Invalid or repeated action budget",
      )
    }
    seen_actions.set(budget.action, true)
  }
  for action in config.forbidden_actions {
    if action == "" {
      release_issue(
        findings, "INVALID_ACTION", "Forbidden action cannot be empty",
      )
    }
  }
  for request in requests {
    if request.subject == "" || request.action == "" || request.resource == "" {
      release_issue(
        findings, "INVALID_REQUEST", "Request identifiers cannot be empty",
      )
    }
  }
  if findings.length() > 0 {
    return {
      complete: false,
      requests_evaluated: 0,
      changes: [],
      findings,
      verdict: Indeterminate,
    }
  }
  let unique_requests : Array[@moon_policy.Request] = []
  let seen_requests : Map[String, Bool] = Map([])
  for request in requests {
    let key = release_request_key(request)
    if seen_requests.get(key) is None {
      seen_requests.set(key, true)
      unique_requests.push(request)
    }
  }
  // The upstream API owns diff semantics and the original decision traces.
  let changes = before.access_changes(after, unique_requests)
  let counts : Map[String, (Int, Int)] = Map([])
  for change in changes {
    let (grants, revocations) = counts
      .get(change.request.action)
      .unwrap_or((0, 0))
    counts.set(
      change.request.action,
      if change.current.allowed {
        (grants + 1, revocations)
      } else {
        (grants, revocations + 1)
      },
    )
  }
  for change in changes {
    let budget = budget_for_action(config.action_budgets, change.request.action)
    let (grants, revocations) = counts
      .get(change.request.action)
      .unwrap_or((0, 0))
    if change.current.allowed && grants > budget.max_new_grants {
      release_issue(
        findings,
        "ACTION_GRANT_BUDGET",
        "New grants for \{change.request.action} exceed \{budget.max_new_grants}",
        request=change.request,
      )
    } else if !change.current.allowed && revocations > budget.max_revocations {
      release_issue(
        findings,
        "ACTION_REVOCATION_BUDGET",
        "Revocations for \{change.request.action} exceed \{budget.max_revocations}",
        request=change.request,
      )
    }
  }
  let mut complete = true
  for request in unique_requests {
    if !after.authorize(request).allowed {
      continue
    }
    if config.forbidden_actions.contains(request.action) {
      release_issue(
        findings,
        "FORBIDDEN_ACTION",
        "Replacement policy allows a forbidden action",
        request~,
      )
    }
    if config.forbid_cross_tenant {
      match
        (
          release_tenant(request.subject_attributes, config.tenant_attribute),
          release_tenant(request.resource_attributes, config.tenant_attribute),
        ) {
        (Some(subject_tenant), Some(resource_tenant)) =>
          if subject_tenant != resource_tenant {
            release_issue(
              findings,
              "TENANT_ISOLATION",
              "Replacement policy allows cross-tenant access",
              request~,
            )
          }
        _ => {
          complete = false
          release_issue(
            findings,
            "UNKNOWN_TENANT",
            "Allowed request lacks nonempty string tenant metadata",
            request~,
          )
        }
      }
    }
  }
  {
    complete,
    requests_evaluated: unique_requests.length(),
    changes,
    findings,
    verdict: if !complete {
      Indeterminate
    } else if findings.length() > 0 {
      Fail
    } else {
      Pass
    },
  }
}

///|
/// Replay cases preserve the old outcome for changed requests. Explicit
/// invariant violations override that baseline with an expected denial.
/// These are review artifacts, not automatically applied policy fixes.
pub fn release_regression_cases(
  report : ReleaseReport,
) -> Array[@moon_policy.PolicyCase] {
  if !report.complete {
    return []
  }
  let cases : Array[@moon_policy.PolicyCase] = []
  let positions : Map[String, Int] = Map([])
  for change in report.changes {
    let key = release_request_key(change.request)
    positions.set(key, cases.length())
    cases.push(
      @moon_policy.PolicyCase(
        name="baseline-\{cases.length()}",
        request=change.request,
        expected_allowed=change.previous.allowed,
      ),
    )
  }
  for finding in report.findings {
    if finding.code != "TENANT_ISOLATION" && finding.code != "FORBIDDEN_ACTION" {
      continue
    }
    if finding.request is Some(request) {
      let key = release_request_key(request)
      match positions.get(key) {
        Some(index) =>
          cases[index] = @moon_policy.PolicyCase(
            name="invariant-\{finding.code}-\{index}",
            request~,
            expected_allowed=false,
          )
        None => {
          positions.set(key, cases.length())
          cases.push(
            @moon_policy.PolicyCase(
              name="invariant-\{finding.code}-\{cases.length()}",
              request~,
              expected_allowed=false,
            ),
          )
        }
      }
    }
  }
  cases
}

///|
pub fn release_cases_json(report : ReleaseReport) -> Json {
  Json::array(
    [
      for item in release_regression_cases(report) => {
        Json::object({
          "name": Json::string(item.name),
          "request": item.request.to_json(),
          "expected_allowed": Json::boolean(item.expected_allowed),
        })
      }
    ],
  )
}

///|
pub fn release_report_json(report : ReleaseReport) -> Json {
  Json::object({
    "engine": Json::string("eisem/moon_policy@0.1.0"),
    "complete": Json::boolean(report.complete),
    "requests_evaluated": Json::number(report.requests_evaluated.to_double()),
    "verdict": Json::string(
      match report.verdict {
        Pass => "PASS"
        Fail => "FAIL"
        Indeterminate => "INDETERMINATE"
      },
    ),
    "changes": Json::array(report.changes.map(fn(change) { change.to_json() })),
    "findings": Json::array(
      report.findings.map(fn(finding) {
        Json::object({
          "code": Json::string(finding.code),
          "message": Json::string(finding.message),
          "request": match finding.request {
            Some(request) => request.to_json()
            None => Json::null()
          },
        })
      }),
    ),
    "regression_cases": release_cases_json(report),
  })
}