///|
// Builder-style CAD helpers.
//
// The goal is a pleasant authoring UX for examples: build a model using a few
// high-level calls, then serialize to a valid STEP Part 21 file.

///|
/// Repository URL included in generated STEP headers.
let step_repo_url : String = "https://github.com/gmlewis/moonbit-step"

///|
/// Notice appended to the STEP file description when a design contains a
/// nominal boolean (one whose faces are concatenated rather than trimmed).
let nominal_boolean_notice : String = "NOMINAL BOOLEANS: difference()/union_all() concatenate faces; they do not trim or subtract."

///|
pub struct Design {
  nodes : Array[SceneNode]
  next_solid_id : Int32
  name : String
  description : String
  /// Self-documentation emitted as a comment block in the STEP header.
  documentation : String
  snapping : Double?
}

///|
pub fn Design::new(
  name~ : String = "model",
  description~ : String = "generated by gmlewis/step/cad",
  documentation~ : String = "",
  snapping? : Double,
) -> Design {
  Design::{
    nodes: [],
    next_solid_id: 1,
    name,
    description,
    documentation,
    snapping,
  }
}

///|
/// with_documentation sets the self-documentation block written into the STEP
/// header (see `@cli.Args::documentation`).
pub fn Design::with_documentation(
  self : Design,
  documentation : String,
) -> Design {
  { ..self, documentation, }
}

///|

///|
/// add_styled_item writes one STEP presentation style for `item_ref`. It may
/// target a whole solid (`MANIFOLD_SOLID_BREP`) or a single face
/// (`ADVANCED_FACE`).
fn add_styled_item(
  ctx : StepContext,
  name : String,
  color : Rgb,
  item_ref : @step.Ref,
) -> @step.Ref {
  let rgb = @presentation.ColorRgb::new(name, color.r, color.g, color.b)
  let rgb_ref = ctx.add(@repository.any_entity_color_rgb(rgb))
  let fas_col = @presentation.FillAreaStyleColor::new("", rgb_ref)
  let fas_col_ref = ctx.add(
    @repository.any_entity_fill_area_style_color(fas_col),
  )
  let fas = @presentation.FillAreaStyle::new("", [fas_col_ref])
  let fas_ref = ctx.add(@repository.any_entity_fill_area_style(fas))
  let ssfa = @presentation.SurfaceStyleFillArea::new(fas_ref)
  let ssfa_ref = ctx.add(@repository.any_entity_surface_style_fill_area(ssfa))
  let sss = @presentation.SurfaceSideStyle::new("", [ssfa_ref])
  let sss_ref = ctx.add(@repository.any_entity_surface_side_style(sss))
  let ssu = @presentation.SurfaceStyleUsage::new(".BOTH.", sss_ref)
  let ssu_ref = ctx.add(@repository.any_entity_surface_style_usage(ssu))
  let psa = @presentation.PresentationStyleAssignment::new([ssu_ref])
  let psa_ref = ctx.add(
    @repository.any_entity_presentation_style_assignment(psa),
  )
  let styled = @presentation.StyledItem::new("", [psa_ref], item_ref)
  ctx.add(@repository.any_entity_styled_item(styled))
}

///|
fn compile_node_geometry_and_styling(
  ctx : StepContext,
  node : SceneNode,
  solid_ref : @step.Ref,
  faces~ : Array[@step.Ref] = [],
) -> Unit {
  // Presentation styling for the whole solid.
  let styled_ref = add_styled_item(ctx, node.name, node.color, solid_ref)
  // A `RevolvedProfile` may carry per-band colors (for example a black shield
  // groove on a grey bearing). Emit one face-level style for each colored band
  // so the solid stays a single closed B-rep.
  let face_styled : Array[@step.Ref] = []
  match node.shape {
    RevolvedProfile(rev) => {
      let colors = rev.emission_colors()
      let limit = if faces.length() < colors.length() {
        faces.length()
      } else {
        colors.length()
      }
      for i in 0..
            face_styled.push(
              add_styled_item(ctx, node.name + "-face-\{i}", color, faces[i]),
            )
          None => ()
        }
      }
    }
    _ => ()
  }
  let layer = @presentation.PresentationLayerAssignment::new("1", "Layer 1", [
    solid_ref,
  ])
  let _ = ctx.add(@repository.any_entity_presentation_layer_assignment(layer))
  let md = @presentation.MechanicalDesignGeometricPresentationRepresentation::new(
    "",
    [styled_ref, ..face_styled],
    { id: ctx.repo_ctx_id, },
  )
  let _ = ctx.add(
    @repository.any_entity_mechanical_design_geometric_presentation_representation(
      md,
    ),
  )
}

///|
fn add_node_to_ctx(
  ctx : StepContext,
  node : SceneNode,
  product_defs : Array[@step.Ref],
  shape_reps : Array[@step.Ref],
  parent~ : Transform? = None,
) -> Unit {
  let (brep_ref, faces) = compile_node_to_brep_with_faces(ctx, node, parent~)
  add_solid_node_to_ctx(
    ctx,
    node,
    brep_ref,
    product_defs,
    shape_reps,
    faces~,
    is_csg=false,
  )
}

///|
fn add_solid_node_to_ctx(
  ctx : StepContext,
  node : SceneNode,
  solid_ref : @step.Ref,
  product_defs : Array[@step.Ref],
  shape_reps : Array[@step.Ref],
  faces~ : Array[@step.Ref] = [],
  is_csg~ : Bool,
) -> Unit {
  compile_node_geometry_and_styling(ctx, node, solid_ref, faces~)

  // Product structure for this part
  let prod_def_ref = ctx.add_product_definition(node.name)
  product_defs.push(prod_def_ref)

  // Axis placement for the part
  let origin = ctx.cartesian_point(node.tx, node.ty, node.tz)
  let (dzx, dzy, dzz) = rotate_point((0.0, 0.0, 1.0), node.rx, node.ry, node.rz)
  let (dxx, dxy, dxz) = rotate_point((1.0, 0.0, 0.0), node.rx, node.ry, node.rz)
  let dz = ctx.direction(dzx, dzy, dzz)
  let dx = ctx.direction(dxx, dxy, dxz)
  let _a2p_ref = ctx.axis2_placement_3d(origin, dz, dx)
  // Axis placement for the part
  let origin = ctx.cartesian_point(node.tx, node.ty, node.tz)
  let (dzx, dzy, dzz) = rotate_point((0.0, 0.0, 1.0), node.rx, node.ry, node.rz)
  let (dxx, dxy, dxz) = rotate_point((1.0, 0.0, 0.0), node.rx, node.ry, node.rz)
  let dz = ctx.direction(dzx, dzy, dzz)
  let dx = ctx.direction(dxx, dxy, dxz)
  let a2p_ref = ctx.axis2_placement_3d(origin, dz, dx)
  let shape_rep = @product.ShapeRepresentation::new(
    node.name + "-None",
    [a2p_ref],
    { id: ctx.repo_ctx_id, },
  )
  let shape_rep_ref = ctx.add(
    @repository.any_entity_shape_representation(shape_rep),
  )
  shape_reps.push(shape_rep_ref)
  let specific_rep_ref = if is_csg {
    let csg_rep = @product.CsgShapeRepresentation::new(
      node.name + "-None",
      [solid_ref],
      { id: ctx.repo_ctx_id, },
    )
    ctx.add(@repository.any_entity_csg_shape_representation(csg_rep))
  } else {
    let brep_rep = @product.AdvancedBrepShapeRepresentation::new(
      node.name + "-None",
      [solid_ref],
      { id: ctx.repo_ctx_id, },
    )
    ctx.add(@repository.any_entity_advanced_brep_shape_representation(brep_rep))
  }
  let rel = @product.ShapeRepresentationRelationship::new(
    "None", "relationship", shape_rep_ref, specific_rep_ref,
  )
  let _ = ctx.add(@repository.any_entity_shape_representation_relationship(rel))
  let pds = @product.ProductDefinitionShape::new("", "", prod_def_ref)
  let pds_ref = ctx.add(@repository.any_entity_product_definition_shape(pds))
  let sdr = @product.ShapeDefinitionRepresentation::new(pds_ref, shape_rep_ref)
  let _ = ctx.add(@repository.any_entity_shape_definition_representation(sdr))
}

///|
fn compile_node_to_brep_with_faces(
  ctx : StepContext,
  node : SceneNode,
  reverse~ : Bool = false,
  parent~ : Transform? = None,
) -> (@step.Ref, Array[@step.Ref]) {
  let faces = compile_node_shape(ctx, node, reverse~, parent~)
  if faces.is_empty() {
    abort("shape '\{node.name}' produced no faces")
  }
  let shell = @topology.ClosedShell::new("", faces)
  let shell_ref = ctx.add(@repository.any_entity_closed_shell(shell))
  let brep = @topology.ManifoldSolidBrep::new("", shell_ref)
  let brep_ref = ctx.add(@repository.any_entity_manifold_solid_brep(brep))
  (brep_ref, faces)
}

///|
fn compile_node_to_brep(
  ctx : StepContext,
  node : SceneNode,
  reverse~ : Bool = false,
  parent~ : Transform? = None,
) -> @step.Ref {
  let (brep_ref, _faces) = compile_node_to_brep_with_faces(
    ctx,
    node,
    reverse~,
    parent~,
  )
  brep_ref
}

///|
pub fn Design::compile(self : Design) -> @repository.Repository {
  let ctx = StepContext::new(snapping?=self.snapping)
  ctx.init_standard_context(self.name)
  let product_defs : Array[@step.Ref] = []
  let shape_reps : Array[@step.Ref] = []
  for node in self.nodes {
    match node.shape {
      Union(u) => {
        if u.nodes.length() == 0 {
          continue
        }
        let parent = Transform::from_node(node)
        if u.nodes.length() == 1 {
          add_node_to_ctx(
            ctx,
            { ..u.nodes[0], name: node.name, },
            product_defs,
            shape_reps,
            parent=Some(parent),
          )
          continue
        }
        let all_faces : Array[@step.Ref] = []
        for child in u.nodes {
          all_faces.push_iter(
            compile_node_shape(ctx, child, parent=Some(parent)).iter(),
          )
        }
        if all_faces.is_empty() {
          abort("shape '\{node.name}' produced no faces")
        }
        let shell = @topology.ClosedShell::new("", all_faces)
        let shell_ref = ctx.add(@repository.any_entity_closed_shell(shell))
        let brep = @topology.ManifoldSolidBrep::new("", shell_ref)
        let solid_ref = ctx.add(
          @repository.any_entity_manifold_solid_brep(brep),
        )
        add_solid_node_to_ctx(
          ctx,
          node,
          solid_ref,
          product_defs,
          shape_reps,
          is_csg=false,
        )
      }
      Difference(d) => {
        let parent = Transform::from_node(node)
        if d.cutters.length() == 0 {
          add_node_to_ctx(
            ctx,
            { ..d.base, name: node.name, },
            product_defs,
            shape_reps,
            parent=Some(parent),
          )
          continue
        }
        let all_faces : Array[@step.Ref] = []
        all_faces.push_iter(
          compile_node_shape(ctx, d.base, parent=Some(parent)).iter(),
        )
        for cutter in d.cutters {
          all_faces.push_iter(
            compile_node_shape(ctx, cutter, reverse=true, parent=Some(parent)).iter(),
          )
        }
        if all_faces.is_empty() {
          abort("shape '\{node.name}' produced no faces")
        }
        let shell = @topology.ClosedShell::new("", all_faces)
        let shell_ref = ctx.add(@repository.any_entity_closed_shell(shell))
        let brep = @topology.ManifoldSolidBrep::new("", shell_ref)
        let solid_ref = ctx.add(
          @repository.any_entity_manifold_solid_brep(brep),
        )
        add_solid_node_to_ctx(
          ctx,
          node,
          solid_ref,
          product_defs,
          shape_reps,
          is_csg=false,
        )
      }
      _ => add_node_to_ctx(ctx, node, product_defs, shape_reps)
    }
  }

  // Set the header
  let file_name = self.name + ".step"
  let has_nominal_boolean = self.diagnostics().any(fn(d) { d.is_nominal() })
  ctx.repo = ctx.repo.set_header(
    default_header(
      file_name,
      self.description,
      self.documentation,
      nominal=has_nominal_boolean,
    ),
  )
  ctx.repo
}

///|
fn compile_node_shape(
  ctx : StepContext,
  node : SceneNode,
  reverse~ : Bool = false,
  parent~ : Transform? = None,
) -> Array[@step.Ref] {
  match node.shape {
    Cuboid(c) => compile_cuboid(ctx, node, c, reverse~, parent~)
    Cylinder(c) => compile_cylinder(ctx, node, c, reverse~, parent~)
    RevolvedProfile(r) =>
      compile_revolved_profile(ctx, node, r, reverse~, parent~)
    ChamferedPrism(p) =>
      compile_chamfered_prism(ctx, node, p, reverse~, parent~)
    SocketedSolid(s) => compile_socketed_solid(ctx, node, s, reverse~, parent~)
    ConicalNut(n) => compile_conical_nut(ctx, node, n, reverse~, parent~)
    AcornNut(n) => compile_acorn_nut(ctx, node, n, reverse~, parent~)
    SquareNut(n) => compile_square_nut(ctx, node, n, reverse~, parent~)
    SlitTube(t) => compile_slit_tube(ctx, node, t, reverse~, parent~)
    CalibrationChamferBlock(b) =>
      compile_calibration_chamfer_block(ctx, node, b, reverse~, parent~)
    ExtrudedProfile(e) =>
      compile_extruded_profile(ctx, node, e, reverse~, parent~)
    ExtrudedArcProfile(e) =>
      compile_extruded_arc_profile(ctx, node, e, reverse~, parent~)
    SquareHoleWasher(w) =>
      compile_square_hole_washer(ctx, node, w, reverse~, parent~)
    TabWasher(w) => compile_tab_washer(ctx, node, w, reverse~, parent~)
    HexHeadWasherFace(h) =>
      compile_hex_head_pcurves(ctx, node, h, reverse~, parent~)
    ExtrudedBezierProfile(e) =>
      compile_extruded_bezier_profile(ctx, node, e, reverse~, parent~)
    SweptProfile(s) => compile_swept_profile(ctx, node, s, reverse~, parent~)
    Fillet(_) | Chamfer(_) =>
      abort(
        "edge feature '\{node.name}' requires an evaluated kernel: the flat STEP writer cannot trim edges. Export with Design::to_step_evaluated() (OCCT) or the Blender backend.",
      )
    Difference(_) => []
    _ =>
      // This should not be reached for Union shapes as they are expanded in Design::compile
      []
  }
}

///|
/// Design::to_step_file compiles the design to ISO 10303-21 text.
///
/// Booleans are nominal: `difference()`/`union_all()` concatenate faces rather
/// than trimming or subtracting. Use `to_step_file_checked()` to refuse
/// designs with error diagnostics.
pub fn Design::to_step_file(self : Design) -> String {
  self.compile().to_step_file()
}

///|
// Convenience alias for examples that want `println(design.to_string())`.
pub fn Design::to_string(self : Design) -> String {
  self.to_step_file()
}

///|
/// Design::has_errors reports whether the design has any diagnostic that
/// blocks checked export (nested booleans, empty solids).
pub fn Design::has_errors(self : Design) -> Bool {
  self.diagnostics().any(fn(d) { d.severity().is_error() })
}

///|
/// Design::to_step_file_checked compiles the design, aborting with the first
/// error diagnostic instead of emitting plausible-but-wrong geometry.
pub fn Design::to_step_file_checked(self : Design) -> String {
  for d in self.diagnostics() {
    if d.severity().is_error() {
      abort(d.message())
    }
  }
  self.to_step_file()
}

///|
/// Design::write_step_file writes the design as STEP. It is unchecked by
/// default (booleans stay nominal); pass `checked=true` to abort on error
/// diagnostics instead.
pub async fn Design::write_step_file(
  self : Design,
  out_path : String?,
  checked~ : Bool = false,
) -> Unit {
  let step = if checked {
    self.to_step_file_checked()
  } else {
    self.to_step_file()
  }
  write_text_file(step, out_path)
}

///|
/// Convenience alias: `design.write_step(...)`. Unchecked by default; pass
/// `checked=true` to refuse error designs.
pub async fn Design::write_step(
  self : Design,
  out_path : String?,
  checked~ : Bool = false,
) -> Unit {
  self.write_step_file(out_path, checked~)
}

///|
pub fn Design::add(self : Design, node : SceneNode) -> Design {
  let nodes = self.nodes.copy()
  nodes.push(node)
  Design::{ ..self, nodes, next_solid_id: self.next_solid_id + 1, }
}

///|
pub fn Design::add_iter(self : Design, nodes : Iter[SceneNode]) -> Design {
  let mut d = self
  for node in nodes {
    d = d.add(node)
  }
  d
}

///|
pub fn Design::rotate_x(self : Design, angle : Double) -> Design {
  let nodes = self.nodes.map(fn(node) {
    let (tx, ty, tz) = rotate_point(
      (node.tx, node.ty, node.tz),
      angle,
      0.0,
      0.0,
    )
    let new_node = node.rotate_x(angle)
    { ..new_node, tx, ty, tz, }
  })
  { ..self, nodes, }
}

///|
pub fn Design::rotate_y(self : Design, angle : Double) -> Design {
  let nodes = self.nodes.map(fn(node) {
    let (tx, ty, tz) = rotate_point(
      (node.tx, node.ty, node.tz),
      0.0,
      angle,
      0.0,
    )
    let new_node = node.rotate_y(angle)
    { ..new_node, tx, ty, tz, }
  })
  { ..self, nodes, }
}

///|
pub fn Design::rotate_z(self : Design, angle : Double) -> Design {
  let nodes = self.nodes.map(fn(node) {
    let (tx, ty, tz) = rotate_point(
      (node.tx, node.ty, node.tz),
      0.0,
      0.0,
      angle,
    )
    let new_node = node.rotate_z(angle)
    { ..new_node, tx, ty, tz, }
  })
  { ..self, nodes, }
}

///|
pub fn Design::translate(
  self : Design,
  x~ : Double = 0.0,
  y~ : Double = 0.0,
  z~ : Double = 0.0,
) -> Design {
  let nodes = self.nodes.map(fn(node) { node.translate(x~, y~, z~) })
  { ..self, nodes, }
}

///|
/// Writes `text` to `out_path`, or to stdout when `out_path` is `None`.
///
/// Existing files are overwritten. I/O errors propagate, so callers (and the
/// process exit code) see a failed write instead of a stale output file.
pub async fn write_text_file(text : String, out_path : String?) -> Unit {
  match out_path {
    None => println(text)
    Some(path) =>
      @fs.write_file(
        path,
        @encoding/utf8.encode(text),
        permission=0o644,
        create_mode=@fs.CreateMode::CreateOrTruncate,
      )
  }
}

///|
fn default_header(
  file_name : String,
  description : String,
  documentation : String,
  nominal~ : Bool = false,
) -> @header.HeaderSection {
  let timestamp = now_iso_timestamp()
  let entities : Array[@header.HeaderEntity] = []
  if documentation != "" {
    entities.push(@header.HeaderEntity::Comment(documentation))
  }
  let description_strings = if nominal {
    [description, nominal_boolean_notice]
  } else {
    [description]
  }
  entities.push(
    @header.HeaderEntity::FileDescription(
      @header.FileDescription::new(description_strings, "2;1"),
    ),
  )
  entities.push(
    @header.HeaderEntity::FileName(
      @header.FileName::new(
        @header.StringOrDollar::Str(file_name),
        @header.StringOrDollar::Str(timestamp),
        @header.StringListOrDollar::List(["gmlewis"]),
        @header.StringListOrDollar::List(["gmlewis/step", step_repo_url]),
        @header.StringOrDollar::Str("gmlewis/step"),
        @header.StringOrDollar::Str("moonbit"),
        @header.StringOrDollar::Str("none"),
      ),
    ),
  )
  entities.push(
    @header.HeaderEntity::FileSchema(
      @header.FileSchema::new(["AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }"]),
    ),
  )
  @header.HeaderSection::new(entities)
}

///|
fn now_iso_timestamp() -> String {
  let ms = @env.now()
  let seconds = (ms / 1000UL).reinterpret_as_int64()
  try
    @time.PlainDateTime::from_unix_second(seconds, 0, @time.utc_offset)
  catch {
    _ => "2025-01-01T00:00:00"
  } noraise {
    dt => dt.to_string()
  }
}