normalize_pr_changelog_entries() {
  local pr="$1"
  local changelog_path="CHANGELOG.md"

  [ -f "$changelog_path" ] || return 0

  PR_NUMBER_FOR_CHANGELOG="$pr" node <<'EOF_NODE'
const fs = require("node:fs");

const pr = process.env.PR_NUMBER_FOR_CHANGELOG;
const path = "CHANGELOG.md";
const original = fs.readFileSync(path, "utf8");
const lines = original.split("\n");
const prPattern = new RegExp(`(?:\\(#${pr}\\)|openclaw#${pr})`, "i");

function findActiveSectionIndex(arr) {
  return arr.findIndex((line) => line.trim() === "## Unreleased");
}

function findSectionEnd(arr, start) {
  for (let i = start + 1; i < arr.length; i += 1) {
    if (/^## /.test(arr[i])) {
      return i;
    }
  }
  return arr.length;
}

function ensureActiveSection(arr) {
  let activeIndex = findActiveSectionIndex(arr);
  if (activeIndex !== -1) {
    return activeIndex;
  }

  let insertAt = arr.findIndex((line, idx) => idx > 0 && /^## /.test(line));
  if (insertAt === -1) {
    insertAt = arr.length;
  }

  const block = ["## Unreleased", "", "### Changes", ""];
  if (insertAt > 0 && arr[insertAt - 1] !== "") {
    block.unshift("");
  }
  arr.splice(insertAt, 0, ...block);
  return findActiveSectionIndex(arr);
}

function contextFor(arr, index) {
  let major = "";
  let minor = "";
  for (let i = index; i >= 0; i -= 1) {
    const line = arr[i];
    if (!minor && /^### /.test(line)) {
      minor = line.trim();
    }
    if (/^## /.test(line)) {
      major = line.trim();
      break;
    }
  }
  return { major, minor };
}

function ensureSubsection(arr, subsection) {
  const activeIndex = ensureActiveSection(arr);
  const activeEnd = findSectionEnd(arr, activeIndex);
  const desired = subsection && /^### /.test(subsection) ? subsection : "### Changes";
  for (let i = activeIndex + 1; i < activeEnd; i += 1) {
    if (arr[i].trim() === desired) {
      return i;
    }
  }

  let insertAt = activeEnd;
  while (insertAt > activeIndex + 1 && arr[insertAt - 1] === "") {
    insertAt -= 1;
  }
  const block = ["", desired, ""];
  arr.splice(insertAt, 0, ...block);
  return insertAt + 1;
}

function sectionTailInsertIndex(arr, subsectionIndex) {
  let nextHeading = arr.length;
  for (let i = subsectionIndex + 1; i < arr.length; i += 1) {
    if (/^### /.test(arr[i]) || /^## /.test(arr[i])) {
      nextHeading = i;
      break;
    }
  }

  let insertAt = nextHeading;
  while (insertAt > subsectionIndex + 1 && arr[insertAt - 1] === "") {
    insertAt -= 1;
  }
  return insertAt;
}

ensureActiveSection(lines);

const moved = [];
for (let i = 0; i < lines.length; i += 1) {
  if (!prPattern.test(lines[i])) {
    continue;
  }
  const ctx = contextFor(lines, i);
  if (ctx.major === "## Unreleased") {
    continue;
  }
  moved.push({
    line: lines[i],
    subsection: ctx.minor || "### Changes",
    index: i,
  });
}

if (moved.length === 0) {
  process.exit(0);
}

const removeIndexes = new Set(moved.map((entry) => entry.index));
const nextLines = lines.filter((_, idx) => !removeIndexes.has(idx));

for (const entry of moved) {
  const subsectionIndex = ensureSubsection(nextLines, entry.subsection);
  const insertAt = sectionTailInsertIndex(nextLines, subsectionIndex);

  let nextHeading = nextLines.length;
  for (let i = subsectionIndex + 1; i < nextLines.length; i += 1) {
    if (/^### /.test(nextLines[i]) || /^## /.test(nextLines[i])) {
      nextHeading = i;
      break;
    }
  }

  const alreadyPresent = nextLines
    .slice(subsectionIndex + 1, nextHeading)
    .some((line) => line === entry.line);
  if (alreadyPresent) {
    continue;
  }
  nextLines.splice(insertAt, 0, entry.line);
}

const updated = nextLines.join("\n");
if (updated !== original) {
  fs.writeFileSync(path, updated);
}
EOF_NODE
}

validate_changelog_entry_for_pr() {
  local pr="$1"
  local contrib="$2"

  local added_lines
  added_lines=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md | awk '
    /^\+\+\+/ { next }
    /^\+/ { print substr($0, 2) }
  ')

  if [ -z "$added_lines" ]; then
    echo "CHANGELOG.md is in diff but no added lines were detected."
    exit 1
  fi

  local pr_pattern
  pr_pattern="(#$pr|openclaw#$pr)"

  local with_pr
  with_pr=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" || true)
  if [ -z "$with_pr" ]; then
    echo "CHANGELOG.md update must reference PR #$pr (for example, (#$pr))."
    exit 1
  fi

  local diff_file
  diff_file=$(mktemp)
  git diff --unified=0 origin/main...HEAD -- CHANGELOG.md > "$diff_file"

  if ! awk -v pr_pattern="$pr_pattern" '
BEGIN {
  line_no = 0
  file_line_count = 0
  issue_count = 0
}
FNR == NR {
  if ($0 ~ /^@@ /) {
    if (match($0, /\+[0-9]+/)) {
      line_no = substr($0, RSTART + 1, RLENGTH - 1) + 0
    } else {
      line_no = 0
    }
    next
  }
  if ($0 ~ /^\+\+\+/) {
    next
  }
  if ($0 ~ /^\+/) {
    if (line_no > 0) {
      added[line_no] = 1
      added_text = substr($0, 2)
      if (added_text ~ pr_pattern) {
        pr_added_lines[++pr_added_count] = line_no
        pr_added_text[line_no] = added_text
      }
      line_no++
    }
    next
  }
  if ($0 ~ /^-/) {
    next
  }
  if (line_no > 0) {
    line_no++
  }
  next
}
{
  changelog[FNR] = $0
  file_line_count = FNR
}
END {
  for (idx = 1; idx <= pr_added_count; idx++) {
    entry_line = pr_added_lines[idx]
    release_line = 0
    section_line = 0
    for (i = entry_line; i >= 1; i--) {
      if (section_line == 0 && changelog[i] ~ /^### /) {
        section_line = i
        continue
      }
      if (changelog[i] ~ /^## /) {
        release_line = i
        break
      }
    }
    if (release_line == 0 || changelog[release_line] != "## Unreleased") {
      printf "CHANGELOG.md PR-linked entry must be in ## Unreleased: line %d: %s\n", entry_line, pr_added_text[entry_line]
      issue_count++
      continue
    }
    if (section_line == 0) {
      printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_added_text[entry_line]
      issue_count++
      continue
    }

    section_name = changelog[section_line]
    next_heading = file_line_count + 1
    for (i = entry_line + 1; i <= file_line_count; i++) {
      if (changelog[i] ~ /^### / || changelog[i] ~ /^## /) {
        next_heading = i
        break
      }
    }

    for (i = entry_line + 1; i < next_heading; i++) {
      line_text = changelog[i]
      if (line_text ~ /^[[:space:]]*$/) {
        continue
      }
      if (i in added) {
        continue
      }
      printf "CHANGELOG.md PR-linked entry must be appended at the end of section %s: line %d: %s\n", section_name, entry_line, pr_added_text[entry_line]
      printf "Found existing non-added line below it at line %d: %s\n", i, line_text
      issue_count++
      break
    }
  }

  if (issue_count > 0) {
    print "Move this PR changelog entry to the end of its section (just before the next heading)."
    exit 1
  }
}
' "$diff_file" CHANGELOG.md; then
    rm -f "$diff_file"
    exit 1
  fi
  rm -f "$diff_file"
  echo "changelog placement validated: PR-linked entries are appended at section tail"

  if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then
    local with_pr_and_thanks
    with_pr_and_thanks=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true)
    if [ -z "$with_pr_and_thanks" ]; then
      echo "CHANGELOG.md update must include both PR #$pr and thanks @$contrib on the changelog entry line."
      exit 1
    fi
    echo "changelog validated: found PR #$pr + thanks @$contrib"
    return 0
  fi

  echo "changelog validated: found PR #$pr (contributor handle unavailable, skipping thanks check)"
}

validate_changelog_merge_hygiene() {
  local diff
  diff=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md)

  local removed_lines
  removed_lines=$(printf '%s\n' "$diff" | awk '
    /^---/ { next }
    /^-/ { print substr($0, 2) }
  ')
  if [ -z "$removed_lines" ]; then
    return 0
  fi

  local removed_refs
  removed_refs=$(printf '%s\n' "$removed_lines" | rg -o '#[0-9]+' | sort -u || true)
  if [ -z "$removed_refs" ]; then
    return 0
  fi

  local added_lines
  added_lines=$(printf '%s\n' "$diff" | awk '
    /^\+\+\+/ { next }
    /^\+/ { print substr($0, 2) }
  ')

  local ref
  while IFS= read -r ref; do
    [ -z "$ref" ] && continue
    if ! printf '%s\n' "$added_lines" | rg -q -F "$ref"; then
      echo "CHANGELOG.md drops existing entry reference $ref without re-adding it."
      echo "Likely merge conflict loss; restore the dropped entry (or keep the same PR ref in rewritten text)."
      exit 1
    fi
  done <<<"$removed_refs"

  echo "changelog merge hygiene validated: no dropped PR references"
}
