///| Issue operations

///|
/// Create a new Issue
pub fn Hub::create_issue(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  title : String,
  body : String,
  author : String,
  labels? : Array[String] = [],
  assignees? : Array[String] = [],
  parent_id? : String? = None,
) -> Issue raise @bit.GitError {
  let timestamp = clock.now()
  let issue_id = generate_entity_id(
    "issue",
    author,
    timestamp,
    title + "\n" + body,
  )
  let issue = Issue::new(
    issue_id,
    title,
    body,
    author,
    timestamp,
    timestamp,
    IssueState::Open,
    labels~,
    assignees~,
    parent_id~,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      work_item_meta_key(issue_id),
      canonical_work_item_record_kind(),
      issue.to_work_item().serialize(),
      author,
    ),
  )
  issue
}

///|
/// Get an Issue by ID
pub fn Hub::get_issue(
  self : Hub,
  objects : &@lib.ObjectStore,
  issue_id : String,
) -> Issue? {
  let work_item = self.get_work_item(objects, issue_id)
  match work_item {
    None => None
    Some(item) => item.to_issue()
  }
}

///|
/// List Issues, optionally filtered by state
pub fn Hub::list_issues(
  self : Hub,
  objects : &@lib.ObjectStore,
  state? : IssueState? = None,
) -> Array[Issue] {
  let item_state = match state {
    None => None
    Some(s) => Some(s.to_work_item_state())
  }
  let items = self.list_work_items(
    objects,
    state=item_state,
    kind=Some(WorkItemKind::Issue),
  )
  let result : Array[Issue] = []
  for item in items {
    match item.to_issue() {
      Some(issue) => result.push(issue)
      None => ()
    }
  }
  result
}

///|
/// Update an Issue (title, body, labels, assignees)
pub fn Hub::update_issue(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  issue_id : String,
  title? : String? = None,
  body? : String? = None,
  labels? : Array[String]? = None,
  assignees? : Array[String]? = None,
) -> Issue raise @bit.GitError {
  let issue = self.get_issue(objects, issue_id)
  guard issue is Some(existing) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
  }
  let new_title = match title {
    Some(t) => t
    None => existing.title
  }
  let new_body = match body {
    Some(b) => b
    None => existing.body
  }
  let new_labels = match labels {
    Some(l) => l
    None => existing.labels
  }
  let new_assignees = match assignees {
    Some(a) => a
    None => existing.assignees
  }
  let updated = Issue::new(
    existing.id,
    new_title,
    new_body,
    existing.author,
    existing.created_at,
    clock.now(),
    existing.state,
    labels=new_labels,
    assignees=new_assignees,
    linked_prs=existing.linked_prs,
    linked_issues=existing.linked_issues,
    parent_id=existing.parent_id,
    blocked_by=existing.blocked_by,
    blocking=existing.blocking,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      work_item_meta_key(issue_id),
      canonical_work_item_record_kind(),
      updated.to_work_item().serialize(),
      existing.author,
    ),
  )
  updated
}

///|
/// Close an Issue
pub fn Hub::close_issue(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  issue_id : String,
) -> Unit raise @bit.GitError {
  let issue = self.get_issue(objects, issue_id)
  guard issue is Some(existing) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
  }
  if existing.state != IssueState::Open {
    raise @bit.GitError::InvalidObject("Issue is not open: \{issue_id}")
  }
  let updated = Issue::new(
    existing.id,
    existing.title,
    existing.body,
    existing.author,
    existing.created_at,
    clock.now(),
    IssueState::Closed,
    labels=existing.labels,
    assignees=existing.assignees,
    linked_prs=existing.linked_prs,
    parent_id=existing.parent_id,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      work_item_meta_key(issue_id),
      canonical_work_item_record_kind(),
      updated.to_work_item().serialize(),
      existing.author,
    ),
  )
}

///|
/// Reopen a closed Issue
pub fn Hub::reopen_issue(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  issue_id : String,
) -> Unit raise @bit.GitError {
  let issue = self.get_issue(objects, issue_id)
  guard issue is Some(existing) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
  }
  if existing.state != IssueState::Closed {
    raise @bit.GitError::InvalidObject("Issue is not closed: \{issue_id}")
  }
  let updated = Issue::new(
    existing.id,
    existing.title,
    existing.body,
    existing.author,
    existing.created_at,
    clock.now(),
    IssueState::Open,
    labels=existing.labels,
    assignees=existing.assignees,
    linked_prs=existing.linked_prs,
    parent_id=existing.parent_id,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      work_item_meta_key(issue_id),
      canonical_work_item_record_kind(),
      updated.to_work_item().serialize(),
      existing.author,
    ),
  )
}

///|
/// List sub-issues of a parent issue
pub fn Hub::list_sub_issues(
  self : Hub,
  objects : &@lib.ObjectStore,
  parent_id : String,
  state? : IssueState? = None,
) -> Array[Issue] {
  let all_issues = self.list_issues(objects, state~)
  let result : Array[Issue] = []
  for issue in all_issues {
    if issue.parent_id == Some(parent_id) {
      result.push(issue)
    }
  }
  result
}

///|
/// Add a comment to an Issue
pub fn Hub::add_issue_comment(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  issue_id : String,
  author : String,
  body : String,
  reply_to? : String? = None,
) -> IssueComment raise @bit.GitError {
  let issue = self.get_issue(objects, issue_id)
  guard issue is Some(_) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
  }
  let timestamp = clock.now()
  let comment = IssueComment::new(
    "",
    issue_id,
    author,
    body,
    timestamp,
    reply_to~,
  )
  let comment_data = comment.serialize()
  let (blob_id, _compressed) = @bit.create_blob_string(comment_data)
  let comment_id = short_hex(blob_id.to_hex(), 8)
  let final_comment = IssueComment::new(
    comment_id,
    issue_id,
    author,
    body,
    timestamp,
    reply_to~,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      issue_comment_key(issue_id, comment_id),
      "issue.comment",
      final_comment.serialize(),
      author,
    ),
  )
  final_comment
}

///|
/// Update an existing comment on an Issue (replace body, keep id/timestamps)
pub fn Hub::update_issue_comment(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  issue_id : String,
  comment_id : String,
  body : String,
  author : String,
) -> Unit raise @bit.GitError {
  let updated_comment = IssueComment::new(
    comment_id,
    issue_id,
    author,
    body,
    clock.now(),
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      issue_comment_key(issue_id, comment_id),
      "issue.comment",
      updated_comment.serialize(),
      author,
    ),
  )
}

///|
/// Delete a comment from an Issue
pub fn Hub::delete_issue_comment(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  issue_id : String,
  comment_id : String,
) -> Unit raise @bit.GitError {
  ignore(
    self.store.delete_record(
      objects,
      refs,
      clock,
      issue_comment_key(issue_id, comment_id),
      "issue.comment",
      "github-sync",
    ),
  )
}

///|
/// List comments for an Issue
pub fn Hub::list_issue_comments(
  self : Hub,
  objects : &@lib.ObjectStore,
  issue_id : String,
) -> Array[IssueComment] {
  let result : Array[IssueComment] = []
  let records = self.store.list_records(objects, issue_comment_prefix(issue_id))
  for record in records {
    if record.kind != "issue.comment" {
      continue
    }
    let comment = parse_issue_comment(record.payload) catch { _ => continue }
    result.push(comment)
  }
  result.sort_by(fn(a, b) {
    if a.created_at < b.created_at {
      -1
    } else if a.created_at > b.created_at {
      1
    } else {
      0
    }
  })
  result
}

///|
/// Link a cross-repo issue reference to an Issue
pub fn Hub::link_issue(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  issue_id : String,
  target_ref : String,
) -> Unit raise @bit.GitError {
  let issue = self.get_issue(objects, issue_id)
  guard issue is Some(existing) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
  }
  let linked = existing.linked_issues
  if !linked.contains(target_ref) {
    linked.push(target_ref)
  }
  let updated = Issue::new(
    existing.id,
    existing.title,
    existing.body,
    existing.author,
    existing.created_at,
    clock.now(),
    existing.state,
    labels=existing.labels,
    assignees=existing.assignees,
    linked_prs=existing.linked_prs,
    linked_issues=linked,
    parent_id=existing.parent_id,
    blocked_by=existing.blocked_by,
    blocking=existing.blocking,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      work_item_meta_key(issue_id),
      canonical_work_item_record_kind(),
      updated.to_work_item().serialize(),
      existing.author,
    ),
  )
}

///|
/// Link a PR to an Issue
pub fn Hub::link_pr_to_issue(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  issue_id : String,
  pr_id : String,
) -> Unit raise @bit.GitError {
  let issue = self.get_issue(objects, issue_id)
  guard issue is Some(existing) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{issue_id}")
  }
  let linked = existing.linked_prs
  if !linked.contains(pr_id) {
    linked.push(pr_id)
  }
  let updated = Issue::new(
    existing.id,
    existing.title,
    existing.body,
    existing.author,
    existing.created_at,
    clock.now(),
    existing.state,
    labels=existing.labels,
    assignees=existing.assignees,
    linked_prs=linked,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      work_item_meta_key(issue_id),
      canonical_work_item_record_kind(),
      updated.to_work_item().serialize(),
      existing.author,
    ),
  )
}

///|
/// Returns true if `to_id` is reachable from `from_id` by following
/// `blocked_by` edges, i.e. `from_id` already (transitively) depends on
/// `to_id`. Used to reject dependency edges that would close a cycle.
fn Hub::dependency_path_exists(
  self : Hub,
  objects : &@lib.ObjectStore,
  from_id : String,
  to_id : String,
) -> Bool raise @bit.GitError {
  if from_id == to_id {
    return true
  }
  let visited : Array[String] = []
  let queue : Array[String] = [from_id]
  let mut head = 0
  while head < queue.length() {
    let current = queue[head]
    head += 1
    if visited.contains(current) {
      continue
    }
    visited.push(current)
    match self.get_issue(objects, current) {
      Some(issue) =>
        for dep in issue.blocked_by() {
          if dep == to_id {
            return true
          }
          if !visited.contains(dep) {
            queue.push(dep)
          }
        }
      None => ()
    }
  }
  false
}

///|
/// Add a dependency relationship between two issues.
/// `source_id` is blocked by `target_id`.
/// Both issues are updated in separate put_record calls.
pub fn Hub::add_dep(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  source_id : String,
  target_id : String,
) -> Unit raise @bit.GitError {
  if source_id == target_id {
    raise @bit.GitError::InvalidObject(
      "Issue cannot depend on itself: \{source_id}",
    )
  }
  let source = self.get_issue(objects, source_id)
  guard source is Some(src) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{source_id}")
  }
  let target = self.get_issue(objects, target_id)
  guard target is Some(tgt) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{target_id}")
  }
  if
    !src.blocked_by.contains(target_id) &&
    self.dependency_path_exists(objects, target_id, source_id) {
    raise @bit.GitError::InvalidObject(
      "Adding this dependency would create a cycle: #\{target_id} already depends on #\{source_id}",
    )
  }
  if !src.blocked_by.contains(target_id) {
    let new_blocked_by = src.blocked_by.copy()
    new_blocked_by.push(target_id)
    let updated_src = Issue::new(
      src.id,
      src.title,
      src.body,
      src.author,
      src.created_at,
      clock.now(),
      src.state,
      labels=src.labels,
      assignees=src.assignees,
      linked_prs=src.linked_prs,
      linked_issues=src.linked_issues,
      parent_id=src.parent_id,
      blocked_by=new_blocked_by,
      blocking=src.blocking,
    )
    ignore(
      self.store.put_record(
        objects,
        refs,
        clock,
        work_item_meta_key(source_id),
        canonical_work_item_record_kind(),
        updated_src.to_work_item().serialize(),
        src.author,
      ),
    )
  }
  if !tgt.blocking.contains(source_id) {
    let new_blocking = tgt.blocking.copy()
    new_blocking.push(source_id)
    let updated_tgt = Issue::new(
      tgt.id,
      tgt.title,
      tgt.body,
      tgt.author,
      tgt.created_at,
      clock.now(),
      tgt.state,
      labels=tgt.labels,
      assignees=tgt.assignees,
      linked_prs=tgt.linked_prs,
      linked_issues=tgt.linked_issues,
      parent_id=tgt.parent_id,
      blocked_by=tgt.blocked_by,
      blocking=new_blocking,
    )
    ignore(
      self.store.put_record(
        objects,
        refs,
        clock,
        work_item_meta_key(target_id),
        canonical_work_item_record_kind(),
        updated_tgt.to_work_item().serialize(),
        tgt.author,
      ),
    )
  }
}

///|
/// Remove a dependency relationship between two issues.
/// `source_id` is no longer blocked by `target_id`.
pub fn Hub::remove_dep(
  self : Hub,
  objects : &@lib.ObjectStore,
  refs : &@lib.RefStore,
  clock : &@lib.Clock,
  source_id : String,
  target_id : String,
) -> Unit raise @bit.GitError {
  let source = self.get_issue(objects, source_id)
  guard source is Some(src) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{source_id}")
  }
  let target = self.get_issue(objects, target_id)
  guard target is Some(tgt) else {
    raise @bit.GitError::InvalidObject("Issue not found: \{target_id}")
  }
  let new_blocked_by = src.blocked_by.filter(fn(id) { id != target_id })
  let updated_src = Issue::new(
    src.id,
    src.title,
    src.body,
    src.author,
    src.created_at,
    clock.now(),
    src.state,
    labels=src.labels,
    assignees=src.assignees,
    linked_prs=src.linked_prs,
    linked_issues=src.linked_issues,
    parent_id=src.parent_id,
    blocked_by=new_blocked_by,
    blocking=src.blocking,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      work_item_meta_key(source_id),
      canonical_work_item_record_kind(),
      updated_src.to_work_item().serialize(),
      src.author,
    ),
  )
  let new_blocking = tgt.blocking.filter(fn(id) { id != source_id })
  let updated_tgt = Issue::new(
    tgt.id,
    tgt.title,
    tgt.body,
    tgt.author,
    tgt.created_at,
    clock.now(),
    tgt.state,
    labels=tgt.labels,
    assignees=tgt.assignees,
    linked_prs=tgt.linked_prs,
    linked_issues=tgt.linked_issues,
    parent_id=tgt.parent_id,
    blocked_by=tgt.blocked_by,
    blocking=new_blocking,
  )
  ignore(
    self.store.put_record(
      objects,
      refs,
      clock,
      work_item_meta_key(target_id),
      canonical_work_item_record_kind(),
      updated_tgt.to_work_item().serialize(),
      tgt.author,
    ),
  )
}