///|
fn parse_operation_kind(kind : String) -> PlanOperationKind raise {
  match kind {
    "RemovePart" => RemovePart
    "RemoveRelationship" => RemoveRelationship
    "RewriteSourceXml" => RewriteSourceXml
    "RewriteRelationshipPart" => RewriteRelationshipPart
    "RewriteContentTypes" => RewriteContentTypes
    "ChangeMainContentType" => ChangeMainContentType
    _ => raise Refused("unknown planner operation")
  }
}

///|
fn operation_writes(kind : PlanOperationKind) -> Bool {
  match kind {
    RemovePart
    | RewriteSourceXml
    | RewriteRelationshipPart
    | RewriteContentTypes => true
    RemoveRelationship | ChangeMainContentType => false
  }
}

///|
fn validate_operation_conflicts(
  operations : Array[CheckedOperation],
) -> Unit raise {
  let ids : Map[String, Bool] = Map([])
  let keys : Map[String, Bool] = Map([])
  let writers : Map[String, String] = Map([])
  for operation in operations {
    guard !ids.contains(operation.operation_id) else {
      raise Invalid("duplicate operation identity")
    }
    let key = operation.kind.label() +
      "|" +
      operation.part +
      "|" +
      operation.relationship_id
    guard !keys.contains(key) else { raise Invalid("duplicate plan operation") }
    for dependency in operation.depends_on {
      guard ids.contains(dependency) else {
        raise Invalid("operation dependency missing, cyclic or out of order")
      }
    }
    if operation_writes(operation.kind) {
      guard !writers.contains(operation.part) else {
        raise Invalid("conflicting payload operations: " + operation.part)
      }
      writers[operation.part] = operation.operation_id
    }
    ids[operation.operation_id] = true
    keys[key] = true
  }
}

///|
fn validate_removal_closure(
  pkg : Package,
  prepared : PreparedPlan,
) -> Unit raise {
  let removed : Map[String, Bool] = Map([])
  for part in prepared.controlled_removals {
    guard part != pkg.audit.main_part && part != "[Content_Types].xml" else {
      raise Invalid("protected part selected for deletion")
    }
    guard !removed.contains(part) && pkg.graph.part_index.contains(part) else {
      raise Invalid("duplicate/missing controlled deletion")
    }
    removed[part] = true
    guard prepared.operations.any(fn(op) {
      op.kind == RemovePart && op.part == part
    }) else {
      raise Invalid("controlled deletion missing its operation")
    }
  }
  for r in pkg.audit.relationships {
    if !r.external &&
      removed.contains(r.resolved) &&
      !removed.contains(r.source) {
      guard prepared.operations.any(fn(op) {
        op.kind == RemoveRelationship &&
        op.source == r.source &&
        op.relationship_id == r.id
      }) else {
        raise Refused("retained relationship still requires controlled target")
      }
    }
  }
  for reference in pkg.graph.graph.xml_references {
    let r = pkg.audit.relationships[reference.relationship_index]
    guard !removed.contains(r.resolved) ||
      removed.contains(reference.source) ||
      (
        ole_reference_removed(pkg, reference) &&
        prepared.operations.any(fn(op) {
          op.kind == RewriteSourceXml &&
          op.part == reference.source &&
          op.rule_id == "ole-v1"
        })
      ) else {
      raise Refused(
        "retained XML reference cannot be repaired by the VBA handler",
      )
    }
  }
}

///|
fn make_prepared_plan(pkg : Package, limits : Limits) -> PreparedPlan raise {
  pkg.control.checkpoint("prepare", 1)
  let assessment = assess_package(pkg)
  guard assessment.decision.result == Pass &&
    assessment.decision.rebuild_allowed else {
    raise Refused(assessment.decision.reason)
  }
  let decisions : Array[PolicyDecision] = []
  let finding_decisions : Map[String, String] = Map([])
  for i, finding in assessment.findings {
    guard (
        finding.capability == Vba ||
        finding.capability == RemoteTemplate ||
        finding.capability == HttpHyperlink ||
        finding.capability == Ole
      ) &&
      finding.rebuild_support == Supported else {
      raise Refused("finding has no accepted planner handler")
    }
    let id = "finding-" + i.to_string()
    finding_decisions[finding.part] = id
    if finding.capability == Ole {
      finding_decisions["ole-v1"] = id
    }
    if finding.capability == RemoteTemplate {
      finding_decisions["remote-template-v1"] = id
    }
    decisions.push({
      decision_id: id,
      finding_index: Some(i),
      action: if finding.capability == HttpHyperlink {
        AllowAction
      } else {
        RemoveAction
      },
      rule_id: finding.rule_id,
      part: finding.part,
      reason: if finding.capability == Ole {
        "Remove one validated Word inline OLE object, its private preview and exclusively owned parts."
      } else if finding.capability == HttpHyperlink {
        "Retain the validated HTTP(S) click target and its source XML without network access."
      } else if finding.capability == RemoteTemplate {
        "Remove the external attached template and its settings-local XML reference; do not fetch the target."
      } else {
        "Remove VBA and its exclusively owned supporting parts under passive-office-v1."
      },
    })
  }
  if ole_relationships(pkg).length() > 1 {
    decisions.push({
      decision_id: "ole-source",
      finding_index: None,
      action: RemoveAction,
      rule_id: "ole-v1",
      part: pkg.audit.main_part,
      reason: "Remove the complete validated set of independent inline OLE subtrees and regenerate their shared metadata; each private payload has its own finding decision.",
    })
  }
  if is_macro_format(pkg.audit.format) {
    decisions.push({
      decision_id: "format",
      finding_index: None,
      action: AllowAction,
      rule_id: "format-v1",
      part: pkg.audit.main_part,
      reason: "Convert macro-enabled main type; this does not assert that VBA is present.",
    })
  }
  let operations : Array[CheckedOperation] = []
  let removed = controlled_parts(pkg)
  for i, operation in make_plan(pkg).operations {
    pkg.control.checkpoint("prepare-operation", 1)
    let kind = parse_operation_kind(operation.kind)
    let part = if kind == RemoveRelationship {
      relationship_part(operation.part)
    } else if kind == ChangeMainContentType {
      "[Content_Types].xml"
    } else {
      operation.part
    }
    let before = pkg.audit.parts[pkg.graph.part_index[part]]
    let after = match kind {
      RemovePart => None
      RewriteRelationshipPart =>
        Some(
          work_digest(
            write_relationships(pkg, relationship_source(part))[:],
            pkg.control,
          ),
        )
      RewriteContentTypes =>
        Some(work_digest(write_content_types(pkg)[:], pkg.control))
      RewriteSourceXml =>
        Some(work_digest(write_source(pkg, part)[:], pkg.control))
      RemoveRelationship | ChangeMainContentType => Some(before.sha256)
    }
    let depends_on : Array[String] = []
    for previous in operations {
      if (
          kind == RemoveRelationship &&
          previous.kind == RewriteSourceXml &&
          previous.part == operation.part
        ) ||
        (
          kind == RewriteRelationshipPart &&
          previous.kind == RemoveRelationship &&
          previous.part == part
        ) ||
        (
          kind == RewriteContentTypes &&
          (
            previous.kind == RemovePart ||
            previous.kind == ChangeMainContentType
          )
        ) {
        depends_on.push(previous.operation_id)
      }
    }
    let decision_id = if operation.rule == "ole-v1" {
      if ole_relationships(pkg).length() == 1 {
        finding_decisions["ole-v1"]
      } else if [RewriteSourceXml, RewriteRelationshipPart, RewriteContentTypes].contains(
          kind,
        ) {
        "ole-source"
      } else {
        let owners = ole_relationships(pkg).filter(fn(r) {
          let preview = ole_preview(pkg, r)
          (kind == RemovePart && [r.resolved, preview.resolved].contains(part)) ||
          (
            kind == RemoveRelationship &&
            [r.id, preview.id].contains(operation.relationship_id)
          )
        })
        guard owners.length() == 1 else {
          raise Invalid("OLE operation has no unique owning object")
        }
        finding_decisions[owners[0].resolved]
      }
    } else if operation.rule == "remote-template-v1" {
      finding_decisions["remote-template-v1"]
    } else if operation.rule == "format-v1" {
      "format"
    } else if finding_decisions.get(operation.part) is Some(id) {
      id
    } else if pkg.audit.findings.length() == 1 &&
      finding_decisions.get(pkg.audit.findings[0].part) is Some(id) {
      id
    } else {
      raise Invalid("operation has no finding/policy decision")
    }
    operations.push({
      operation_id: "op-" + i.to_string(),
      kind,
      part,
      source: operation.part,
      relationship_id: operation.relationship_id,
      rule_id: operation.rule,
      decision_id,
      before_hash: before.sha256,
      expected_after_hash: after,
      depends_on,
      reason: operation.reason,
    })
  }
  let prepared : PreparedPlan = {
    schema: "partsieve.prepared-plan.v1",
    tool: "partsieve/0.7.1-spike",
    rules: if ole_relationships(pkg).length() > 1 {
      "bounded-links-ole-plan-v2"
    } else if extended_office(pkg) {
      "bounded-links-ole-plan-v1"
    } else {
      "bounded-business-plan-v1"
    },
    input_hash: pkg.audit.input_hash,
    input_format: pkg.audit.format,
    output_format: if is_word_format(pkg.audit.format) {
      "DOCX"
    } else {
      "XLSX"
    },
    policy: "passive-office-v1",
    profile: pkg.audit.profile,
    limits,
    preconditions: pkg.audit.parts,
    findings: assessment.findings,
    decisions,
    operations,
    controlled_removals: removed,
    orphan_candidates: pkg.graph.graph.orphan_candidates,
    coverage: assessment.coverage,
    gate: assessment.decision,
    unavailable_handlers: if extended_office(pkg) {
      [
        "Linked/shared OLE, Excel OLE, ActiveX, fields and general VML rewriting are unavailable.",
      ]
    } else {
      [
        "Source XML edits other than validated attachedTemplate removal are unavailable.",
      ]
    },
  }
  pkg.control.pairs(
    "plan-operation-scans",
    prepared.operations.length(),
    prepared.operations.length(),
  )
  validate_operation_conflicts(prepared.operations)
  validate_removal_closure(pkg, prepared)
  prepared
}

///|
pub fn prepare(
  input : Bytes,
  limits? : Limits = Limits::default(),
  control? : WorkControl = WorkControl::default(),
) -> PreparedPlan raise {
  make_prepared_plan(load_package(input, limits, control~), limits)
}

///|
fn execute_prepared(
  pkg : Package,
  prepared : PreparedPlan,
  limits : Limits,
) -> Bytes raise {
  guard prepared.input_hash == pkg.audit.input_hash &&
    prepared.limits == limits &&
    prepared.preconditions == pkg.audit.parts else {
    raise Invalid("plan input/hash/resource precondition mismatch")
  }
  pkg.control.pairs(
    "plan-operation-scans",
    prepared.operations.length(),
    prepared.operations.length(),
  )
  validate_operation_conflicts(prepared.operations)
  validate_removal_closure(pkg, prepared)
  pkg.control.checkpoint("execute", 1)
  let archive = pkg.archive.clone()
  for operation in prepared.operations {
    pkg.control.checkpoint("execute-operation", 1)
    let before = pkg.audit.parts[pkg.graph.part_index[operation.part]]
    guard before.sha256 == operation.before_hash else {
      raise Invalid("operation payload precondition mismatch")
    }
    match operation.kind {
      RemovePart => {
        guard prepared.controlled_removals.contains(operation.part) &&
          archive.remove(operation.part[:]) else {
          raise Invalid("operation controlled removal precondition mismatch")
        }
      }
      RemoveRelationship => {
        guard graph_relationship(
            pkg.graph,
            operation.source,
            operation.relationship_id,
          )
          is Some(r) &&
          removed_relationship(pkg, r) else {
          raise Invalid("relationship operation precondition mismatch")
        }
      }
      ChangeMainContentType => {
        guard is_macro_format(pkg.audit.format) &&
          operation.source == pkg.audit.main_part else {
          raise Invalid("format operation precondition mismatch")
        }
      }
      RewriteSourceXml => {
        guard source_rewritten(pkg, operation.part) else {
          raise Refused("no accepted source XML handler for this part")
        }
        let payload = write_source(pkg, operation.part)
        guard operation.expected_after_hash ==
          Some(work_digest(payload[:], pkg.control)) &&
          archive.replace(operation.part[:], payload[:]) else {
          raise Invalid("source XML rewrite postcondition mismatch")
        }
      }
      RewriteRelationshipPart | RewriteContentTypes => {
        let payload = if operation.kind == RewriteContentTypes {
          write_content_types(pkg)
        } else {
          write_relationships(pkg, relationship_source(operation.part))
        }
        guard operation.expected_after_hash ==
          Some(work_digest(payload[:], pkg.control)) &&
          archive.replace(operation.part[:], payload[:]) else {
          raise Invalid("metadata operation postcondition mismatch")
        }
      }
    }
  }
  for entry in archive.entries() {
    pkg.control.checkpoint("zip-write-bytes", entry.data().length())
  }
  let bytes = @zip.write_preserving_limited(
    archive,
    max_output_bytes=limits.output_bytes,
  ) catch {
    @zip.ZipError(_, message) =>
      raise Incomplete("bounded output writing did not complete: " + message)
  }
  pkg.control.checkpoint("zip-write-complete", bytes.length())
  bytes
}

///|
// A supplied JSON plan is data, not authority. Recompute every field from the
// actual bounded input before execution, then verify the serialized output.
fn execute_supplied_plan(
  input : Bytes,
  supplied : Json,
  limits : Limits,
  control : WorkControl,
) -> Bytes raise {
  let pkg = load_package(input, limits, control~)
  let expected = make_prepared_plan(pkg, limits)
  guard supplied == expected.to_json() else {
    raise Invalid("supplied plan differs from independently recomputed plan")
  }
  execute_prepared(pkg, expected, limits)
}

///|
pub fn rebuild_with_plan(
  input : Bytes,
  supplied : Json,
  limits? : Limits = Limits::default(),
  control? : WorkControl = WorkControl::default(),
) -> Rebuilt raise {
  let bytes = execute_supplied_plan(input, supplied, limits, control)
  { bytes, receipt: verify(bytes, input, limits~, control~), }
}