///|
type PdfObject = @pdf.PdfObject

///|
type Op = @pdfops.Op

///|
fn clamp01(x : Double) -> Double {
  if x < 0.0 {
    0.0
  } else if x > 1.0 {
    1.0
  } else {
    x
  }
}

///|
fn hsv_to_rgb(h : Double, s : Double, v : Double) -> (Double, Double, Double) {
  let h = h % 360.0
  let s = clamp01(s)
  let v = clamp01(v)
  if s == 0.0 {
    return (v, v, v)
  }
  let c = v * s
  let x = c * (1.0 - (h / 60.0 % 2.0 - 1.0).abs())
  let m = v - c
  let (rp, gp, bp) = if h < 60.0 {
    (c, x, 0.0)
  } else if h < 120.0 {
    (x, c, 0.0)
  } else if h < 180.0 {
    (0.0, c, x)
  } else if h < 240.0 {
    (0.0, x, c)
  } else if h < 300.0 {
    (x, 0.0, c)
  } else {
    (c, 0.0, x)
  }
  (rp + m, gp + m, bp + m)
}

///|
fn font_type1(base_font : String) -> PdfObject {
  Dictionary([
    ("/Type", Name(PdfName(b"/Font"))),
    ("/Subtype", Name(PdfName(b"/Type1"))),
    ("/BaseFont", Name(@pdf.PdfName::of_string("/" + base_font))),
  ])
}

///|
fn default_resources() -> PdfObject {
  Dictionary([
    (
      "/Font",
      Dictionary([
        ("/F0", font_type1("Helvetica-Bold")),
        ("/F1", font_type1("Helvetica")),
        ("/F2", font_type1("Courier")),
      ]),
    ),
  ])
}

///|
fn a4_mediabox() -> PdfObject {
  @pdfpage.Page::blank(@pdfpaper.a4).mediabox
}

///|
fn ops_text(
  x : Double,
  y : Double,
  font : String,
  size : Double,
  text : String,
  rgb : (Double, Double, Double),
) -> Array[Op] {
  let (r, g, b) = rgb
  let tr = @pdftransform.Transform::new([Translate(x, y)]).to_matrix()
  [Opq, Oprg(r, g, b), OpBT, OpTf(font, size), OpTm(tr), OpTj(text), OpET, OpQ]
}

///|
fn ops_fill_rect(
  x : Double,
  y : Double,
  w : Double,
  h : Double,
  rgb : (Double, Double, Double),
) -> Array[Op] {
  let (r, g, b) = rgb
  [Opq, Oprg(r, g, b), Opre(x, y, w, h), Opf, OpQ]
}

///|
fn ops_stroke_rect(
  x : Double,
  y : Double,
  w : Double,
  h : Double,
  rgb : (Double, Double, Double),
  width : Double,
) -> Array[Op] {
  let (r, g, b) = rgb
  [Opq, Opw(width), OpRG(r, g, b), Opre(x, y, w, h), OpS, OpQ]
}

///|
fn ops_stroke_line(
  x0 : Double,
  y0 : Double,
  x1 : Double,
  y1 : Double,
  rgb : (Double, Double, Double),
  width : Double,
) -> Array[Op] {
  let (r, g, b) = rgb
  [Opq, Opw(width), OpRG(r, g, b), Opm(x0, y0), Opl(x1, y1), OpS, OpQ]
}

///|
fn ops_crosshatch(
  x : Double,
  y : Double,
  w : Double,
  h : Double,
  step : Double,
  rgb : (Double, Double, Double),
  width : Double,
) -> Array[Op] {
  let ops : Array[Op] = []
  let mut i = 0.0
  while i <= w + h {
    let x0 = x + i
    let y0 = y
    let x1 = x
    let y1 = y + i
    let (sx0, sy0, sx1, sy1) = if x0 <= x + w && y1 <= y + h {
      (x0, y0, x1, y1)
    } else if x0 > x + w {
      (x + w, y + (i - w), x1, y1)
    } else {
      (x0, y0, x + (i - h), y + h)
    }
    ops.append(ops_stroke_line(sx0, sy0, sx1, sy1, rgb, width)[:])
    i = i + step
  }
  let mut j = 0.0
  while j <= w + h {
    let x0 = x + j
    let y0 = y + h
    let x1 = x
    let y1 = y + h - j
    let (sx0, sy0, sx1, sy1) = if x0 <= x + w && y1 >= y {
      (x0, y0, x1, y1)
    } else if x0 > x + w {
      (x + w, y + h - (j - w), x1, y1)
    } else {
      (x0, y0, x + (j - h), y)
    }
    ops.append(ops_stroke_line(sx0, sy0, sx1, sy1, rgb, width)[:])
    j = j + step
  }
  ops
}

///|
priv struct Rng {
  state : Ref[UInt]
}

///|
fn Rng::new(seed : UInt) -> Rng {
  let s = if seed == 0 { 0x9E3779B9U } else { seed }
  { state: { val: s, }, }
}

///|
fn Rng::next_u32(self : Rng) -> UInt {
  let mut x = self.state.val
  x = x ^ (x << 13)
  x = x ^ (x >> 17)
  x = x ^ (x << 5)
  self.state.val = x
  x
}

///|
fn Rng::next_int(self : Rng, n : Int) -> Int {
  if n <= 0 {
    return 0
  }
  self.next_u32().reinterpret_as_int().abs() % n
}

///|
priv enum Dir {
  N
  E
  S
  W
} derive(Eq)

///|
fn opposite(d : Dir) -> Dir {
  match d {
    N => S
    S => N
    E => W
    W => E
  }
}

///|
fn dir_delta(d : Dir) -> (Int, Int) {
  match d {
    N => (0, 1)
    S => (0, -1)
    E => (1, 0)
    W => (-1, 0)
  }
}

///|
priv struct Cell {
  open_n : Bool
  open_e : Bool
  open_s : Bool
  open_w : Bool
}

///|
fn Cell::empty() -> Cell {
  { open_n: false, open_e: false, open_s: false, open_w: false, }
}

///|
fn Cell::is_open(self : Cell, d : Dir) -> Bool {
  match d {
    N => self.open_n
    E => self.open_e
    S => self.open_s
    W => self.open_w
  }
}

///|
fn Cell::with_open(self : Cell, d : Dir, value : Bool) -> Cell {
  match d {
    N =>
      {
        open_n: value,
        open_e: self.open_e,
        open_s: self.open_s,
        open_w: self.open_w,
      }
    E =>
      {
        open_n: self.open_n,
        open_e: value,
        open_s: self.open_s,
        open_w: self.open_w,
      }
    S =>
      {
        open_n: self.open_n,
        open_e: self.open_e,
        open_s: value,
        open_w: self.open_w,
      }
    W =>
      {
        open_n: self.open_n,
        open_e: self.open_e,
        open_s: self.open_s,
        open_w: value,
      }
  }
}

///|
priv struct Maze {
  w : Int
  h : Int
  cells : Array[Cell]
}

///|
fn Maze::new(w : Int, h : Int) -> Maze {
  let cells = Array(capacity=w * h)
  for _ in 0..<(w * h) {
    cells.push(Cell::empty())
  }
  { w, h, cells, }
}

///|
fn idx(w : Int, x : Int, y : Int) -> Int {
  y * w + x
}

///|
fn in_bounds(w : Int, h : Int, x : Int, y : Int) -> Bool {
  x >= 0 && x < w && y >= 0 && y < h
}

///|
fn Maze::neighbors(self : Maze, x : Int, y : Int) -> Array[(Dir, Int, Int)] {
  let out = Array(capacity=4)
  let dirs : Array[Dir] = [N, E, S, W]
  for d in dirs {
    let (dx, dy) = dir_delta(d)
    let nx = x + dx
    let ny = y + dy
    if in_bounds(self.w, self.h, nx, ny) {
      out.push((d, nx, ny))
    }
  }
  out
}

///|
fn Maze::carved(self : Maze, seed : UInt) -> Maze {
  let maze = self
  let rng = Rng::new(seed)
  let visited = Array(capacity=maze.w * maze.h)
  for _ in 0..<(maze.w * maze.h) {
    visited.push(false)
  }
  let stack : Array[(Int, Int)] = [(0, 0)]
  visited[idx(maze.w, 0, 0)] = true
  while stack.length() > 0 {
    let (cx, cy) = stack[stack.length() - 1]
    let neighbors = maze.neighbors(cx, cy)
    let candidates : Array[(Dir, Int, Int)] = []
    for entry in neighbors {
      let (_, nx, ny) = entry
      if !visited[idx(maze.w, nx, ny)] {
        candidates.push(entry)
      }
    }
    if candidates.length() == 0 {
      let _ = stack.pop()
      continue
    }
    let choice = candidates[rng.next_int(candidates.length())]
    let (d, nx, ny) = choice
    let i0 = idx(maze.w, cx, cy)
    let i1 = idx(maze.w, nx, ny)
    maze.cells[i0] = maze.cells[i0].with_open(d, true)
    maze.cells[i1] = maze.cells[i1].with_open(opposite(d), true)
    visited[i1] = true
    stack.push((nx, ny))
  }
  maze
}

///|
fn Maze::cell(self : Maze, x : Int, y : Int) -> Cell {
  self.cells[idx(self.w, x, y)]
}

///|
fn Maze::bfs_parents(
  self : Maze,
  start : (Int, Int),
) -> (Array[Int], Array[Int]) {
  let n = self.w * self.h
  let dist = Array(capacity=n)
  let parent = Array(capacity=n)
  for _ in 0.. Int {
  let mut best_i = 0
  let mut best_d = -1
  for i in 0.. best_d {
      best_d = dist[i]
      best_i = i
    }
  }
  best_i
}

///|
fn build_path(parent : Array[Int], start_i : Int, goal_i : Int) -> Array[Int] {
  let out = []
  let mut cur = goal_i
  while cur != -1 {
    out.push(cur)
    if cur == start_i {
      break
    }
    cur = parent[cur]
  }
  out.rev_in_place()
  out
}

///|
fn set_catalog_entry(
  pdf : @pdf.Pdf,
  key : String,
  value : PdfObject,
) -> Unit raise {
  let rootnum = pdf.root
  let root = pdf.lookup_obj(rootnum)
  let updated_root = root.add_entry(key, value)
  pdf.addobj_given_num((rootnum, updated_root))
  pdf.trailerdict = pdf.trailerdict.add_entry("/Root", Indirect(rootnum))
}

///|
fn add_layers(pdf : @pdf.Pdf) -> (Int, Int) raise {
  let fog : PdfObject = Dictionary([
    ("/Type", Name(PdfName(b"/OCG"))),
    ("/Name", PdfString("FOG")),
  ])
  let solution : PdfObject = Dictionary([
    ("/Type", Name(PdfName(b"/OCG"))),
    ("/Name", PdfString("SOLUTION")),
  ])
  let fog_num = pdf.addobj(fog)
  let sol_num = pdf.addobj(solution)
  let default_dict : PdfObject = Dictionary([
    ("/BaseState", Name(PdfName(b"/ON"))),
    ("/Intent", Name(PdfName(b"/View"))),
    ("/Name", PdfString("Default")),
    ("/ON", PdfArray([Indirect(fog_num)])),
    ("/OFF", PdfArray([Indirect(sol_num)])),
  ])
  let ocprops : PdfObject = Dictionary([
    ("/OCGs", PdfArray([Indirect(fog_num), Indirect(sol_num)])),
    ("/D", default_dict),
  ])
  let ocprops_num = pdf.addobj(ocprops)
  set_catalog_entry(pdf, "/OCProperties", Indirect(ocprops_num))
  (fog_num, sol_num)
}

///|
fn add_layer_properties_to_pages(
  pdf : @pdf.Pdf,
  fog_num : Int,
  sol_num : Int,
) -> @pdf.Pdf raise {
  let pages = @pdfpage.PdfPageDoc::new(pdf).pages_of_pagetree()
  for i in 0.. PdfObject {
  let dest = @pdfdest.Destination::Fit(PageObject(refnum)).to_pdf_object()
  Dictionary([("/S", Name(PdfName(b"/GoTo"))), ("/D", dest)])
}

///|
fn add_link(
  pdf : @pdf.Pdf,
  page : @pdfpage.Page,
  rect : (Double, Double, Double, Double),
  action : PdfObject,
) -> @pdfpage.Page raise {
  let annot : @pdfannot.Annotation = {
    subtype: Link,
    annot_contents: None,
    subject: None,
    rectangle: rect,
    border: @pdfannot.Border::new(0.0),
    colour: None,
    annotrest: Dictionary([("/A", action)]),
  }
  @pdfannot.PdfAnnot::new(pdf).add_annotation(page, annot)
}

///|
fn page_of_ops(ops : Array[Op]) -> @pdfpage.Page raise {
  let stream = @pdfops.Op::stream_of_ops(ops)
  Page(
    content=[stream],
    mediabox=a4_mediabox(),
    resources=default_resources(),
    rotate=Rotate0,
    rest=Dictionary([]),
  )
}

///|
fn cover_ops(seed : UInt) -> Array[Op] {
  let ops : Array[Op] = []
  ops.append(ops_fill_rect(0.0, 0.0, 595.0, 842.0, (0.06, 0.07, 0.1))[:])
  for i in 0..<20 {
    let t = i.to_double() / 19.0
    let rgb = hsv_to_rgb(210.0 - 140.0 * t, 0.75, 0.95)
    ops.append(
      ops_fill_rect(0.0, 720.0 - 12.0 * i.to_double(), 595.0, 10.0, rgb)[:],
    )
  }
  ops.append(
    ops_text(54.0, 760.0, "/F0", 36.0, "PDF Escape Maze", (0.95, 0.96, 0.98))[:],
  )
  ops.append(
    ops_text(
      56.0,
      720.0,
      "/F1",
      14.0,
      "A clickable maze: each door is a link to another room.",
      (0.8, 0.84, 0.9),
    )[:],
  )
  ops.append(
    ops_text(
      56.0,
      698.0,
      "/F1",
      14.0,
      "Open the Bookmarks panel for quick jumps.",
      (0.8, 0.84, 0.9),
    )[:],
  )
  ops.append(
    ops_text(
      56.0,
      676.0,
      "/F1",
      14.0,
      "Open the Layers panel to toggle FOG and SOLUTION.",
      (0.8, 0.84, 0.9),
    )[:],
  )
  ops.append(
    ops_text(
      56.0,
      620.0,
      "/F2",
      12.0,
      "Seed: " + seed.to_string(),
      (0.6, 0.64, 0.7),
    )[:],
  )
  ops.append(
    ops_text(
      56.0,
      590.0,
      "/F2",
      12.0,
      "Start in Room 1. Find the Exit.",
      (0.6, 0.64, 0.7),
    )[:],
  )
  ops
}

///|
fn room_ops(
  w : Int,
  h : Int,
  x : Int,
  y : Int,
  room_idx_1 : Int,
  is_start : Bool,
  is_exit : Bool,
  cell : Cell,
  next_hint? : Dir? = None,
) -> Array[Op] {
  let ops : Array[Op] = []
  ops.append(ops_fill_rect(0.0, 0.0, 595.0, 842.0, (0.985, 0.985, 0.99))[:])
  let title = if is_exit {
    "Room " + room_idx_1.to_string() + " • EXIT"
  } else if is_start {
    "Room " + room_idx_1.to_string() + " • START"
  } else {
    "Room " + room_idx_1.to_string()
  }
  ops.append(ops_text(54.0, 800.0, "/F0", 24.0, title, (0.12, 0.14, 0.18))[:])
  ops.append(
    ops_text(
      54.0,
      774.0,
      "/F2",
      11.0,
      "Grid: " +
      w.to_string() +
      "×" +
      h.to_string() +
      " | Coord: (" +
      x.to_string() +
      "," +
      y.to_string() +
      ")",
      (0.35, 0.38, 0.42),
    )[:],
  )

  // Room frame
  let rx = 120.0
  let ry = 280.0
  let rw = 355.0
  let rh = 410.0
  ops.append(ops_fill_rect(rx, ry, rw, rh, (1.0, 1.0, 1.0))[:])
  ops.append(ops_stroke_rect(rx, ry, rw, rh, (0.15, 0.17, 0.2), 2.0)[:])

  // Door gaps
  let door_w = 86.0
  let door_h = 20.0
  let cx = rx + rw / 2.0
  let cy = ry + rh / 2.0
  let hole = (0.985, 0.985, 0.99)
  if cell.open_n {
    ops.append(
      ops_fill_rect(cx - door_w / 2.0, ry + rh - 2.0, door_w, door_h, hole)[:],
    )
  }
  if cell.open_s {
    ops.append(
      ops_fill_rect(cx - door_w / 2.0, ry - door_h + 2.0, door_w, door_h, hole)[:],
    )
  }
  if cell.open_e {
    ops.append(
      ops_fill_rect(rx + rw - 2.0, cy - door_w / 2.0, door_h, door_w, hole)[:],
    )
  }
  if cell.open_w {
    ops.append(
      ops_fill_rect(rx - door_h + 2.0, cy - door_w / 2.0, door_h, door_w, hole)[:],
    )
  }

  // Icons indicating available doors
  let accent_on = (0.25, 0.55, 0.9)
  let accent_off = (0.78, 0.8, 0.84)
  ops.append(
    ops_text(56.0, 720.0, "/F2", 11.0, "Doors:", (0.35, 0.38, 0.42))[:],
  )
  ops.append(
    ops_text(
      120.0,
      720.0,
      "/F2",
      11.0,
      "N",
      if cell.open_n {
        accent_on
      } else {
        accent_off
      },
    )[:],
  )
  ops.append(
    ops_text(
      136.0,
      720.0,
      "/F2",
      11.0,
      "E",
      if cell.open_e {
        accent_on
      } else {
        accent_off
      },
    )[:],
  )
  ops.append(
    ops_text(
      152.0,
      720.0,
      "/F2",
      11.0,
      "S",
      if cell.open_s {
        accent_on
      } else {
        accent_off
      },
    )[:],
  )
  ops.append(
    ops_text(
      168.0,
      720.0,
      "/F2",
      11.0,
      "W",
      if cell.open_w {
        accent_on
      } else {
        accent_off
      },
    )[:],
  )

  // Fog layer: soft crosshatch overlay
  ops.push(OpBDC("/OC", Name(PdfName(b"/FOG"))))
  ops.append(ops_crosshatch(rx, ry, rw, rh, 18.0, (0.92, 0.93, 0.95), 0.5)[:])
  ops.push(OpEMC)

  // Solution layer: hint arrow
  match next_hint {
    None => ()
    Some(d) => {
      ops.push(OpBDC("/OC", Name(PdfName(b"/SOLUTION"))))
      let arrow_color = (0.95, 0.3, 0.35)
      let ax0 = cx
      let ay0 = cy
      let (ax1, ay1) = match d {
        N => (cx, ry + rh - 40.0)
        S => (cx, ry + 40.0)
        E => (rx + rw - 40.0, cy)
        W => (rx + 40.0, cy)
      }
      ops.append(ops_stroke_line(ax0, ay0, ax1, ay1, arrow_color, 4.0)[:])
      ops.append(
        ops_text(
          54.0,
          70.0,
          "/F2",
          11.0,
          "SOLUTION hint: follow the red arrow.",
          (0.55, 0.58, 0.62),
        )[:],
      )
      ops.push(OpEMC)
    }
  }

  // Footer
  ops.append(
    ops_text(
      54.0,
      40.0,
      "/F2",
      10.0,
      "Tip: click doors; use Back in your viewer too.",
      (0.55, 0.58, 0.62),
    )[:],
  )
  ops
}

///|
fn draw_map_ops(
  maze : Maze,
  start_i : Int,
  exit_i : Int,
  path : Array[Int],
) -> Array[Op] {
  let ops : Array[Op] = []
  ops.append(ops_fill_rect(0.0, 0.0, 595.0, 842.0, (0.99, 0.985, 0.97))[:])
  ops.append(
    ops_text(
      54.0,
      800.0,
      "/F0",
      26.0,
      "Maze Map (Clickable)",
      (0.12, 0.14, 0.18),
    )[:],
  )
  ops.append(
    ops_text(
      54.0,
      772.0,
      "/F1",
      13.0,
      "Click a tile to jump to that room.",
      (0.25, 0.28, 0.32),
    )[:],
  )
  let gx = 54.0
  let gy = 160.0
  let gw = 487.0
  let gh = 560.0
  ops.append(ops_stroke_rect(gx, gy, gw, gh, (0.2, 0.22, 0.26), 1.5)[:])
  let cell_w = gw / maze.w.to_double()
  let cell_h = gh / maze.h.to_double()

  // Grid lines
  for i in 1.. @pdf.Pdf raise {
  let marks : Array[@pdfmarks.Bookmark] = []
  if room_refs.length() > 0 {
    marks.push({
      level: 0,
      text: "Cover",
      target: Fit(PageObject(room_refs[0])),
      isopen: true,
      colour: (0.0, 0.0, 0.0),
      flags: 0,
    })
  }
  marks.push({
    level: 0,
    text: "Map",
    target: Fit(PageObject(map_ref)),
    isopen: true,
    colour: (0.0, 0.0, 0.0),
    flags: 0,
  })
  if room_refs.length() > 1 {
    marks.push({
      level: 0,
      text: "Rooms",
      target: Fit(PageObject(room_refs[1])),
      isopen: true,
      colour: (0.0, 0.0, 0.0),
      flags: 2,
    })
    for i in 1.. @pdfpage.Page raise {
  let mut p = page

  // A "Map" button.
  p = add_link(pdf, p, (54.0, 20.0, 124.0, 42.0), goto_action(map_ref))

  // Door buttons.
  let btn_y = 120.0
  let btn_w = 112.0
  let btn_h = 40.0
  let left_x = 54.0
  let mid_x = 54.0 + btn_w + 16.0
  let right_x = 54.0 + (btn_w + 16.0) * 2.0
  let positions : Array[(Dir, (Double, Double))] = [
    (N, (mid_x, btn_y + btn_h + 12.0)),
    (W, (left_x, btn_y)),
    (E, (right_x, btn_y)),
    (S, (mid_x, btn_y - btn_h - 12.0)),
  ]
  for entry in positions {
    let (d, (x, y)) = entry
    let found = neighbors.filter_map(n => {
      let (nd, ti) = n
      if nd == d {
        Some(ti)
      } else {
        None
      }
    })
    match found {
      [] => ()
      [ti, ..] => {
        let target_ref = room_refs[ti]
        p = add_link(
          pdf,
          p,
          (x, y, x + btn_w, y + btn_h),
          goto_action(target_ref),
        )
      }
    }
  }
  p
}

///|
fn annotate_map_page(
  pdf : @pdf.Pdf,
  page : @pdfpage.Page,
  maze : Maze,
  room_refs : Array[Int],
) -> @pdfpage.Page raise {
  let mut p = page
  let gx = 54.0
  let gy = 160.0
  let gw = 487.0
  let gh = 560.0
  let cell_w = gw / maze.w.to_double()
  let cell_h = gh / maze.h.to_double()
  for y in 0.. Unit {
  try {
    let maze = Maze::new(4, 4).carved(seed)
    let (dist, parent) = maze.bfs_parents((0, 0))
    let start_i = idx(maze.w, 0, 0)
    let exit_i = farthest(dist)
    let path = build_path(parent, start_i, exit_i)
    let path_set = Map([], capacity=path.length())
    for p in path {
      path_set.set(p, ())
    }

    // Pages: cover + rooms (w*h) + map.
    let pages : Array[@pdfpage.Page] = []
    pages.push(page_of_ops(cover_ops(seed)))
    for y in 0..
      if !quiet {
        println("Failed to produce output.")
        println(err)
      }
  }
}

///|
async fn main {
  let args = @env.args()
  match args {
    [_, out] => run(out_file=out)
    [_, out, seed] =>
      try @string.parse_int(seed) catch {
        _ => run(out_file=out)
      } noraise {
        n => run(out_file=out, seed=n.reinterpret_as_uint())
      }
    _ => run()
  }
}