#!/usr/bin/env bash
set -euo pipefail

# Force UTF-8 on the embedded Python heredoc. Without this, `Path.read_text()`
# falls back to the platform default codec — on Chinese-locale Windows that's
# GBK, which dies with UnicodeDecodeError on any non-ASCII byte in upstream
# pack JSON (e.g. Chinese labels in `Opportunity_Stage_To_Lunched`).
export PYTHONUTF8=1
export PYTHONIOENCODING=utf-8

usage() {
  cat <<'EOF'
Usage: bash scripts/build_pingcap_salesforce_sample_pack.sh [--reset] [--from-final-pack-lineage] [--l1-dir <path>] [--final-dir <path>] [--nexuscrm-source-repo <path>] [--nexuscrm-ref <ref>] [--official-app-packs-source-repo <path>] [--official-app-packs-ref <ref>]

Build the final merged pack from:
1. checked-in intermediate `packs/Salesforce-PingCAP-L2`
2. official `salesforce-crm-standard` from the selected `nexuscrm-app-packs` ref

Options:
  --from-final-pack-lineage
                            Rebuild from the exact upstream and official
                            app-pack commits recorded by the current final
                            pack after validating its build contract.
EOF
}

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCK_FILE="${ROOT_DIR}/stack.lock.json"
if COMMON_GIT_DIR="$(git -C "${ROOT_DIR}" rev-parse --git-common-dir 2>/dev/null)"; then
  if [[ "${COMMON_GIT_DIR}" != /* ]]; then
    COMMON_GIT_DIR="${ROOT_DIR}/${COMMON_GIT_DIR}"
  fi
  CANONICAL_REPO_ROOT="$(cd "${COMMON_GIT_DIR}/.." && pwd)"
else
  CANONICAL_REPO_ROOT="${ROOT_DIR}"
fi
PACK_BUILD_CONFIG="$(jq -c '.pack_build // {}' "${LOCK_FILE}")"
L2_DIR="${ROOT_DIR}/$(jq -r '.intermediate_pack_path // "packs/Salesforce-PingCAP-L2"' <<<"${PACK_BUILD_CONFIG}")"
FINAL_DIR="${ROOT_DIR}/$(jq -r '.final_pack_path // "packs/NOT-EDIT-final-merged-packs"' <<<"${PACK_BUILD_CONFIG}")"
TMP_DIR="${ROOT_DIR}/.tmp/build-pingcap-salesforce-sample-pack"
PREVIOUS_FINAL_LINEAGE="${TMP_DIR}/previous-final-build-lineage.json"
NEXUSCRM_SOURCE_REPO="${NEXUSCRM_SOURCE_REPO:-${CANONICAL_REPO_ROOT}/../NexusCRM}"
NEXUSCRM_REF="${NEXUSCRM_REF:-origin/main}"
OFFICIAL_APP_PACKS_SOURCE_REPO="${OFFICIAL_APP_PACKS_SOURCE_REPO:-${NEXUSCRM_OFFICIAL_APP_PACKS_ROOT:-${CANONICAL_REPO_ROOT}/../nexuscrm-app-packs}}"
OFFICIAL_APP_PACKS_REF="${OFFICIAL_APP_PACKS_REF:-origin/main}"
OFFICIAL_PACK_PATH="$(jq -r '.official_pack_path // "salesforce-crm-standard"' <<<"${PACK_BUILD_CONFIG}")"
OFFICIAL_APP_PACKS_REPO_NAME="$(jq -r '.official_app_packs_repo // "pingcap-inc/nexuscrm-app-packs"' <<<"${PACK_BUILD_CONFIG}")"
FLOW_WHITELIST="$(jq -c '.flow_whitelist // ["Std_ConvertLead","Std_OrderActivateStamp","Std_RelinkLeadChildren","Std_SyncOpportunityContactRole"]' <<<"${PACK_BUILD_CONFIG}")"
RESET=0
FROM_FINAL_PACK_LINEAGE=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --l1-dir)
      L2_DIR="$2"
      shift 2
      ;;
    --final-dir)
      FINAL_DIR="$2"
      shift 2
      ;;
    --nexuscrm-source-repo)
      NEXUSCRM_SOURCE_REPO="$2"
      shift 2
      ;;
    --nexuscrm-ref)
      NEXUSCRM_REF="$2"
      shift 2
      ;;
    --official-app-packs-source-repo)
      OFFICIAL_APP_PACKS_SOURCE_REPO="$2"
      shift 2
      ;;
    --official-app-packs-ref)
      OFFICIAL_APP_PACKS_REF="$2"
      shift 2
      ;;
    --reset)
      RESET=1
      shift
      ;;
    --from-final-pack-lineage)
      FROM_FINAL_PACK_LINEAGE=1
      shift
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      echo "unknown argument: $1" >&2
      usage >&2
      exit 2
      ;;
  esac
done

if [[ "${FROM_FINAL_PACK_LINEAGE}" == "1" ]]; then
  FINAL_PACK_LINEAGE_FILE="${FINAL_DIR}/.build-lineage.json"
  PYTHONPATH="${ROOT_DIR}" python3 - "${ROOT_DIR}" <<'PY'
import sys
from pathlib import Path

from scripts.lib.final_pack_contract import inspect_checkout

inspect_checkout(Path(sys.argv[1]))
PY
  NEXUSCRM_REF="$(jq -er '.upstreamRuntime.ref' "${FINAL_PACK_LINEAGE_FILE}")"
  OFFICIAL_APP_PACKS_REF="$(jq -er '.officialPack.ref' "${FINAL_PACK_LINEAGE_FILE}")"
fi

if [[ ! -f "${L2_DIR}/pack.json" || ! -d "${L2_DIR}/objects" ]]; then
  echo "missing checked-in intermediate pack: ${L2_DIR}" >&2
  echo "generate it once with scripts/convert_issue157_object_census_pack.sh before building final" >&2
  exit 1
fi

NEXUSCRM_SOURCE_REPO="$(cd "${NEXUSCRM_SOURCE_REPO}" && pwd)"
if [[ ! -d "${NEXUSCRM_SOURCE_REPO}/.git" && ! -f "${NEXUSCRM_SOURCE_REPO}/backend/cmd/setup/main.go" ]]; then
  echo "NEXUSCRM_SOURCE_REPO does not look like a NexusCRM checkout: ${NEXUSCRM_SOURCE_REPO}" >&2
  exit 1
fi

OFFICIAL_APP_PACKS_SOURCE_REPO="$(cd "${OFFICIAL_APP_PACKS_SOURCE_REPO}" && pwd)"
if [[ ! -d "${OFFICIAL_APP_PACKS_SOURCE_REPO}/.git" && ! -f "${OFFICIAL_APP_PACKS_SOURCE_REPO}/.git" ]]; then
  echo "OFFICIAL_APP_PACKS_SOURCE_REPO does not look like a git checkout: ${OFFICIAL_APP_PACKS_SOURCE_REPO}" >&2
  exit 1
fi

ensure_pinned_commit() {
  local repo="$1"
  local sha="$2"
  local label="$3"
  if git -C "${repo}" cat-file -e "${sha}^{commit}" 2>/dev/null; then
    return 0
  fi
  echo "fetching pinned ${label} commit ${sha}" >&2
  git -C "${repo}" fetch -q origin "${sha}" 2>/dev/null || git -C "${repo}" fetch -q origin
  if ! git -C "${repo}" cat-file -e "${sha}^{commit}" 2>/dev/null; then
    echo "pinned ${label} commit ${sha} not found in ${repo} after fetch" >&2
    exit 1
  fi
}

if [[ "${FROM_FINAL_PACK_LINEAGE}" == "1" ]]; then
  ensure_pinned_commit "${NEXUSCRM_SOURCE_REPO}" "${NEXUSCRM_REF}" "NexusCRM"
  ensure_pinned_commit "${OFFICIAL_APP_PACKS_SOURCE_REPO}" "${OFFICIAL_APP_PACKS_REF}" "nexuscrm-app-packs"
fi

if [[ -z "${OFFICIAL_APP_PACKS_REF}" || "${OFFICIAL_APP_PACKS_REF}" == "null" ]]; then
  echo "OFFICIAL_APP_PACKS_REF resolved empty" >&2
  exit 1
fi

if ! git -C "${OFFICIAL_APP_PACKS_SOURCE_REPO}" cat-file -e "${OFFICIAL_APP_PACKS_REF}:${OFFICIAL_PACK_PATH}/pack.json" 2>/dev/null; then
  echo "missing ${OFFICIAL_PACK_PATH} at ref ${OFFICIAL_APP_PACKS_REF}" >&2
  exit 1
fi

rm -rf "${TMP_DIR}"
mkdir -p "${TMP_DIR}"
if [[ -f "${FINAL_DIR}/.build-lineage.json" ]]; then
  cp "${FINAL_DIR}/.build-lineage.json" "${PREVIOUS_FINAL_LINEAGE}"
fi
if [[ "${RESET}" == "1" ]]; then
  rm -rf "${FINAL_DIR}"
fi
OFFICIAL_DIR="${TMP_DIR}/official"
mkdir -p "${OFFICIAL_DIR}"
git -C "${OFFICIAL_APP_PACKS_SOURCE_REPO}" archive "${OFFICIAL_APP_PACKS_REF}" "${OFFICIAL_PACK_PATH}" | tar -x -C "${OFFICIAL_DIR}"

OFFICIAL_PACK_DIR="${OFFICIAL_DIR}/${OFFICIAL_PACK_PATH}"
if [[ ! -f "${OFFICIAL_PACK_DIR}/pack.json" ]]; then
  echo "failed to materialize official standard pack from ${OFFICIAL_APP_PACKS_REF}" >&2
  exit 1
fi

# Preflight the lifecycle declarations BEFORE anything is destroyed.
#
# The assembly below starts by deleting FINAL_DIR, so a validation error raised inside it leaves the
# working tree without the installable pack it had a second earlier — a rejected input taking out a
# good artifact. The derivation's own checks were exactly that until this ran first.
#
# Scoped to what this script derives. A build can still fail after this point for other reasons and
# leave FINAL_DIR half-assembled; making assembly atomic (stage to a temp dir, publish on success)
# is the general fix and is a larger change than this one, tracked separately.
python3 - "${ROOT_DIR}" "${L2_DIR}" <<'PREFLIGHT'
import json
import pathlib
import sys

root_dir = pathlib.Path(sys.argv[1])
l2_dir = pathlib.Path(sys.argv[2])
sys.path.insert(0, str(root_dir / "scripts" / "lib"))
from lifecycle_declaration import declaration_problems

problems = []
for path in sorted((l2_dir / "objects").glob("*.json")):
    payload = json.loads(path.read_text())
    for spec in payload if isinstance(payload, list) else [payload]:
        problems.extend(declaration_problems(spec))

if problems:
    print("lifecycle declaration preflight failed (no files were modified):", file=sys.stderr)
    for problem in problems:
        print(f"  {problem}", file=sys.stderr)
    raise SystemExit(1)
PREFLIGHT

rm -rf "${FINAL_DIR}"
mkdir -p "$(dirname "${FINAL_DIR}")"
cp -R "${OFFICIAL_PACK_DIR}" "${FINAL_DIR}"
rm -rf "${FINAL_DIR}/_extractor"
mkdir -p "${FINAL_DIR}/objects"
find "${FINAL_DIR}/objects" -mindepth 1 -maxdepth 1 -type f -name '*.json' ! -name 'standard_objects_spec.json' -delete

python3 - "${ROOT_DIR}" "${L2_DIR}" "${FINAL_DIR}" "${OFFICIAL_PACK_DIR}/pack.json" "${OFFICIAL_PACK_DIR}" "${OFFICIAL_PACK_PATH}" "${OFFICIAL_APP_PACKS_REPO_NAME}" "${OFFICIAL_APP_PACKS_REF}" "${NEXUSCRM_REF}" "${FLOW_WHITELIST}" "${PREVIOUS_FINAL_LINEAGE}" <<'PY'
import datetime
import hashlib
import json
import pathlib
import re
import shutil
import sys

root_dir = pathlib.Path(sys.argv[1])
sys.path.insert(0, str(root_dir / "scripts" / "lib"))
from record_type_merge import merge_record_type_payloads, record_type_identities

l2_dir = pathlib.Path(sys.argv[2])
final_dir = pathlib.Path(sys.argv[3])
official_pack_meta = pathlib.Path(sys.argv[4])
official_pack_dir = pathlib.Path(sys.argv[5])
official_pack_path = sys.argv[6]
official_pack_repo = sys.argv[7]
official_pack_ref = sys.argv[8]
upstream_ref = sys.argv[9]
flow_whitelist = set(json.loads(sys.argv[10]))
previous_final_lineage_path = pathlib.Path(sys.argv[11])
sys.path.insert(0, str(root_dir / "scripts" / "lib"))
from final_pack_formula_policy import DEFERRED_ACTIVE_FORMULA_FIELDS

reserved_field_names = {
    "id",
    "ownerid",
    "createdbyid",
    "lastmodifiedbyid",
    "createddate",
    "lastmodifieddate",
    "systemmodstamp",
    "isdeleted",
}
unsupported_field_types = {
    "rollupsummary",
}
unsupported_default_expressions = {
    "today()",
    "now()",
}
disabled_field_defaults = {
    ("Contract", "Approve_Status__c"),
    ("Invoice_Collection__c", "Invoice_Status__c"),
    ("Opportunity", "Forecast_Type__c"),
    ("Opportunity", "Risky_contact_in_Powerbase__c"),
    ("Opportunity", "Solution_offered_for_AWS__c"),
    ("Order", "Payment_Term__c"),
    ("PoC__c", "ApprovalStatus__c"),
    ("Revenue__c", "Status__c"),
    ("Quote", "ApproveStatus__c"),
    ("Quote", "Payment_Frequency__c"),
    ("Quote", "Payment_Term__c"),
    ("Quote", "Payment_Type__c"),
}
# Wave 4 active-formula fields (issue #1618). The pinned NexusCRM engine
# supports __r. relationship traversal in active-formula views, so these
# fields are exempt from the relationship-traversal strip downgrade and
# ship as real active-formula fields.
wave4_active_formula_fields = {
    ("Opportunity_Product__c", "Product_Family__c"),
    ("Order", "Sales_Channel_Type__c"),
    ("Revenue__c", "Sales_Channel_Type__c"),
    ("PoC__c", "RecordTypeDeveloperName__c"),
}
# Wave 5 active-formula fields (issues #1760-#1763). These fields are already
# authored in canonical active-formula shape in the checked-in L2 pack. The
# remaining downstream blocker is final-pack assembly stripping any relationship
# traversal formula unless it is explicitly allowlisted here.
wave5_active_formula_fields = {
    ("Invoice_Collection__c", "Account_ID__c"),
    ("Invoice_Collection__c", "Accumulate_revenue_to_Org__c"),
    ("Invoice_Collection__c", "Deal_Registration__c"),
    ("Invoice_Collection__c", "Exchange_Rate__c"),
    ("Invoice_Collection__c", "Order_Number__c"),
    ("Invoice_Collection__c", "Product_Family__c"),
    ("Invoice_Collection__c", "Sales_Channel_Type__c"),
    ("Invoice_Collection__c", "Tax_Rate__c"),
    ("Lead", "Associate_Lead_Company__c"),
    ("Order_Product__c", "Product_Family__c"),
    ("Org__c", "Account_Name__c"),
    ("PoC__c", "Account_Name__c"),
    ("PoC__c", "Approval_PoC_Number__c"),
    ("PoC__c", "Cloud_Credits_Requested__c"),
    ("PoC__c", "Credits_Needed_F__c"),
    ("PoC__c", "Internal_User__c"),
    ("PoC__c", "Region__c"),
    ("PoC__c", "Sales_Manager__c"),
    ("PoC__c", "TiDB_Cloud_Account_ID_to_run_This_PoC__c"),
    ("Quote_Product__c", "Autoapprove__c"),
    ("Revenue__c", "Deal_Registration__c"),
    ("Revenue__c", "Order_Number__c"),
    ("Revenue__c", "Related_RevRec_Records_Date__c"),
    ("Revenue__c", "Tax__c"),
}
# Wave 6 restores the root rollupsummary substrate that now exists upstream on
# current main, then lets dependent scalar formulas read those materialized
# fields instead of relying on the legacy inline child-aggregate formula path.
wave6_root_rollupsummary_fields = {
    ("Account", "Open_Pipeline_ACV__c"),
    ("Opportunity", "Discount__c"),
    ("Opportunity", "Net_Fees__c"),
    ("Order", "Accumulated_Credit_Amount__c"),
    ("Order", "Accumulated_Payable_Amount__c"),
    ("Order", "Accumulated_Revenue_Before_Adjust__c"),
    ("Order", "Cash_Collected_Amount_USD__c"),
    ("Order", "Cash_Collected_Amount__c"),
    ("Order", "Cash_Collected_USD_exc_tax__c"),
    ("Order", "Cash_Collected_exc_tax_Amount__c"),
    ("Order", "Count_Product_Lines__c"),
    ("Order", "Latest_Payable_Date__c"),
    ("Order", "Net_Fees__c"),
    ("Order", "Overall_End_Date__c"),
    ("Order", "Overall_Start_Date__c"),
    ("Order", "Payable_Amount_USD_exc_tax__c"),
    ("Order", "Recognized_Revenue_Amount_USD__c"),
    ("Order", "Recognized_Revenue_Amount__c"),
    ("Order", "TotalAmount"),
    ("Order_Product__c", "Manday__c"),
    ("Quote", "Discount__c"),
    # Submit-gate line-completeness counters (2026-08-13 ACV overhaul): three
    # COUNT rollups read by Quote_Submit_Requires_Complete_Lines. Priced/Termed
    # carry an "<field> IS NOT NULL" filter and the filter is load-bearing: the
    # rollup engine's incremental delta path counts every linked child matching
    # the FILTER and never consults the summary field's NULLness (observed live
    # 2026-08-13 - without the filter both counts equalled the line count and
    # the gate never fired). Do not remove the filters.
    ("Quote", "Line_Item_Count__c"),
    ("Quote", "NetFees__c"),
    ("Quote", "Overall_End_Date__c"),
    ("Quote", "Overall_Start_Date__c"),
    ("Quote", "Priced_Line_Item_Count__c"),
    ("Quote", "TCV_Amount_c__c"),
    ("Quote", "Termed_Line_Item_Count__c"),
    ("Quote", "Total_List_Price__c"),
    # Phase 2 Credits Topup substrate (NX#947 closed via #1125 — runtime
    # acceptance proven by this object's end-to-end tests). Parent rollup
    # of approved Topup amounts; feeds Total_Disbursed__c active formula
    # below, which feeds Cumulative_Disbursed__c on the child for the
    # 3-step gated ProcessDefinition criteria. See poc-credits-refactor.md §3 / §7.
    ("Credits__c", "Approved_Topups__c"),
    # Campaign statistics rollups now authored upstream in
    # salesforce-crm-standard (nexuscrm-app-packs#84, closing #73-#77). The L2
    # overlay no longer redeclares these as plain numeric, so the upstream
    # rollupsummary definitions flow through; whitelist them so assembly never
    # strips them. Resolves pingcap-pack#2375.
    ("Campaign", "NumberOfLeads"),
    ("Campaign", "NumberOfContacts"),
    ("Campaign", "NumberOfResponses"),
    ("Campaign", "NumberOfConvertedLeads"),
    ("Campaign", "NumberOfOpportunities"),
    ("Campaign", "NumberOfWonOpportunities"),
    ("Campaign", "AmountAllOpportunities"),
    ("Campaign", "AmountWonOpportunities"),
}
# Wave 6 dependent formulas become safe once the upstream-backed root rollups
# above are preserved in final-pack assembly.
wave6_active_formula_fields = {
    ("Account", "FY25_PPL_Gap_for_APAC_only__c"),
    ("Invoice_Collection__c", "Order_End_Date__c"),
    ("Order", "Average_Discount__c"),
    ("Opportunity_Product__c", "Product_Name__c"),
    ("Order_Product__c", "Product_Name__c"),
    ("Quote_Product__c", "Product_Name__c"),
    # Phase 2 Credits Topup. Total_Disbursed__c is on Credits__c parent;
    # Cumulative_Disbursed__c is on Credits_Topup__c child and uses
    # relationship traversal (`Credits__r.Total_Disbursed__c`), so it
    # must be allowlisted here even though the build script would
    # otherwise auto-strip relationship-traversal formulas.
    ("Credits__c", "Total_Disbursed__c"),
    ("Credits_Topup__c", "Cumulative_Disbursed__c"),
    # Mirror of parent Credits.Requires_Finance_Approval__c (boolean active
    # formula `Credits__r.Requires_Finance_Approval__c`) so the Topup Finance
    # approval step AND-gates on the flag like the parent flow. Relationship
    # traversal -> must be allowlisted here.
    ("Credits_Topup__c", "Requires_Finance_Approval__c"),
}
preserved_empty_picklist_field_api_names = {
    "currencyisocode",
}
currency_iso_code_default_options = [
    {"label": "Chinese Yuan", "value": "CNY"},
    {"label": "Japanese Yen", "value": "JPY"},
    {"label": "Singapore Dollar", "value": "SGD"},
    {"label": "U.S. Dollar", "value": "USD"},
]
# Catalog App metadata V3 reads L1 AppRelease page_layout components and
# requires a live default layout for each. Filtering official layouts out of
# the merged pack leaves Catalog reconcile with
# "effective page layout X has no default". The old curated-maintenance drift
# shim is retired for that architecture.
disabled_page_layout_business_keys = set()
# Transitional stop-loss: upstream salesforce-crm-standard currently carries a
# search_scope_order payload with label-like names ("Collection", "Org")
# instead of object API names. NexusCRM validates search_scope_order entries
# against owned object API names at pack-load time, so carrying that upstream
# component into the final downstream pack makes the pack invalid. Keep
# filtering the official component until the source pack payload is corrected
# upstream; the downstream PingCAP-specific component uses API names and remains
# enabled.
disabled_component_keys = {
    ("search_scope_order", "crm_search_scope_order"),
}


def sha256_file(path: pathlib.Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def normalize_component(component):
    return dict(component)


def component_key(component):
    return (component.get("kind"), component.get("businessKey"))


def with_component_source(component, source_root: str):
    next_component = normalize_component(component)
    next_component["_source_root"] = source_root
    return next_component


def normalize_final_component_dependencies(component, object_spec_business_key):
    depends_on = component.get("dependsOn")
    if not isinstance(depends_on, list):
        return component
    normalized = []
    seen = set()
    for dependency in depends_on:
        if not isinstance(dependency, dict):
            continue
        next_dependency = dict(dependency)
        if (
            next_dependency.get("kind") == "object_spec"
            and str(next_dependency.get("businessKey", "")).startswith("object:")
        ):
            next_dependency["businessKey"] = object_spec_business_key
        key = (next_dependency.get("kind"), next_dependency.get("businessKey"))
        if key in seen:
            continue
        seen.add(key)
        normalized.append(next_dependency)
    if normalized:
        component["dependsOn"] = normalized
    else:
        component.pop("dependsOn", None)
    return component


def slug_token(value):
    text = str(value or "").strip().lower()
    text = "".join(ch if ch.isalnum() else "_" for ch in text)
    while "__" in text:
        text = text.replace("__", "_")
    return text.strip("_")


def merge_duplicate_field_specs(existing_field, incoming_field):
    merged = json.loads(json.dumps(existing_field))
    incoming = json.loads(json.dumps(incoming_field))

    # Preserve earlier defaults/options when the later duplicate only carries
    # business-facing label/type overrides, but otherwise let the later field win.
    for key, value in incoming.items():
        if key in {"options", "default"}:
            if key not in merged or merged.get(key) in (None, "", []):
                merged[key] = value
            continue
        merged[key] = value

    return merged


def formula_references_removed_dependency(formula, removed_dependency_field_api_names):
    if not isinstance(formula, str) or not formula.strip():
        return False
    for field_api_name in removed_dependency_field_api_names:
        if re.search(rf"(?<![A-Za-z0-9_]){re.escape(field_api_name)}(?![A-Za-z0-9_])", formula):
            return True
    return False


def formula_uses_relationship_traversal(formula):
    return isinstance(formula, str) and (
        "__r." in formula
        or re.search(r"\b[A-Za-z0-9_]+__c\.[A-Za-z0-9_]+", formula) is not None
    )


def referenced_relationship_names(fields):
    names = set()
    pattern = re.compile(r"\b([A-Za-z][A-Za-z0-9_]*__r)\b")
    for field in fields or []:
        formula = field.get("formula")
        formula_mode = str(field.get("formulaMode", "")).strip().lower()
        if not isinstance(formula, str) or not formula.strip() or formula_mode != "active":
            continue
        for match in pattern.finditer(formula):
            names.add(match.group(1))
    return names


# Authoring-time lifecycle declaration (issue #4881). L0's ObjectSpec is a closed struct decoded
# with a plain json.Unmarshal — no DisallowUnknownFields — so an unrecognised key ships, installs
# green and evaporates. Consumed here rather than passed through: the state reaches runtime in the
# generated description prefix, which every surface (direct DB, SOQL, describe, pack file) can
# already read, so the key itself has nothing to do downstream.
lifecycle_declaration_keys = ("lifecycleStatus", "supersededBy", "retiredOn")
_MISSING = object()

# The markers derived from that declaration. Hand-maintaining them is what #4881 is about: on
# origin/main two retired objects had the guard rule and no label suffix, because adding an object
# meant remembering four separate edits. Now the declaration is the edit and these are its output.
sys.path.insert(0, str(pathlib.Path(sys.argv[1]) / "scripts" / "lib"))
from lifecycle_declaration import (
    LIFECYCLE_INSTRUCTION as lifecycle_instruction,
    LIFECYCLE_SUFFIX as lifecycle_suffix,
    declaration_problems,
)
# Matches a previously generated prefix so regeneration REPLACES it rather than stacking a second
# one in front. Without this, changing supersededBy would leave both the old and the new sentence
# in the description, which is worse than either alone.
lifecycle_prefix_re = re.compile(
    r"^(?:DEPRECATED|RETIRED) — superseded by \S+ \(\d{4}-\d{2}-\d{2}\)\. [^\n]+?\.(?:\s+|$)"
)

# Collected on the object pass, consumed on the validation-rule pass (which runs later and has no
# other way to know which objects are retired).
lifecycle_declarations = {}


def derive_lifecycle_markers(sanitized, declaration):
    """Write the label suffix and description prefix implied by a lifecycle declaration.

    Idempotent by construction: sanitize_object_spec runs twice over the same payload (once on
    load, once inside merge_object_spec), and an author may also have left the marker in place by
    hand. Appending unconditionally would have produced "Order Product (Legacy — Retired) (Legacy
    — Retired)" on the second pass.
    """
    # Same contract object the preflight used. Re-checked rather than assumed: the preflight reads
    # the L2 files, this runs on the merged spec, and a divergence between the two is precisely what
    # would delete the final pack and then refuse to rebuild it.
    problems = declaration_problems({**sanitized, **declaration})
    if problems:
        raise SystemExit("; ".join(problems))
    status = declaration["lifecycleStatus"]
    superseded_by = declaration["supersededBy"]
    retired_on = declaration["retiredOn"]
    api_name = str(sanitized.get("apiName", "")).strip()

    suffix = lifecycle_suffix[status]
    other = lifecycle_suffix["deprecated" if status == "retired" else "retired"]
    for key in ("label", "pluralLabel"):
        value = sanitized.get(key)
        # label is guaranteed non-empty by declaration_problems above; pluralLabel is genuinely
        # optional (17 of 79 L2 objects omit it) and is skipped when absent.
        if not isinstance(value, str) or not value.strip():
            continue
        # Strip the other state's suffix first: promoting deprecated -> retired must rename the
        # object, not leave it advertising the state it just left.
        if other != suffix and value.endswith(other):
            value = value[: -len(other)].rstrip()
        if not value.endswith(suffix):
            value = f"{value} {suffix}"
        sanitized[key] = value

    instruction = lifecycle_instruction[status].format(superseded_by=superseded_by)
    prefix = f"{status.upper()} — superseded by {superseded_by} ({retired_on}). {instruction}"
    body = lifecycle_prefix_re.sub("", str(sanitized.get("description", "") or "")).strip()
    sanitized["description"] = f"{prefix} {body}".strip() if body else prefix
    return sanitized


def sanitize_object_spec(spec):
    if not isinstance(spec, dict):
        return spec
    sanitized = json.loads(json.dumps(spec))
    # A sentinel, not None: the keys are STRIPPED here, so after the pop there is no way to tell
    # "absent" from "present and null" — and those must not be treated alike.
    declaration = {key: sanitized.pop(key, _MISSING) for key in lifecycle_declaration_keys}
    supplied = [key for key, value in declaration.items() if value is not _MISSING]
    status = declaration["lifecycleStatus"]
    if supplied and (status is _MISSING or not str(status or "").strip()):
        # Fail the build rather than derive nothing. Deriving on a truthy status alone meant a spec
        # carrying supersededBy/retiredOn with a mistyped or forgotten lifecycleStatus had all three
        # keys silently removed and produced no suffix, no prefix and no guard — an unprotected
        # object shipped by a green build. Silently dropping a marker is the failure this whole
        # epic is about; doing it in the generator would be the third time in one PR series.
        raise SystemExit(
            f"{str(sanitized.get('apiName', '')).strip() or '<unnamed object>'}: lifecycle keys "
            f"{sorted(supplied)} supplied without a usable lifecycleStatus "
            f"(expected deprecated or retired)"
        )
    if supplied:
        declaration = {key: (None if value is _MISSING else value) for key, value in declaration.items()}
        api_name = str(sanitized.get("apiName", "")).strip()
        if api_name:
            lifecycle_declarations[api_name] = declaration
        sanitized = derive_lifecycle_markers(sanitized, declaration)
    object_api_name = str(sanitized.get("apiName", "")).strip()
    removed_field_api_names = set()
    removed_formula_dependency_field_api_names = set()
    if isinstance(sanitized.get("fields"), list):
        initial_fields = []
        for field in sanitized["fields"]:
            api_name = str(field.get("apiName", "")).strip()
            field_type = str(field.get("type", "")).strip().lower()
            # L0 injects these system columns automatically; L1 overlays must not redeclare them.
            if api_name.lower() in reserved_field_names or (
                field_type in unsupported_field_types
                and (object_api_name, api_name) not in wave6_root_rollupsummary_fields
            ):
                if api_name:
                    removed_field_api_names.add(api_name)
                    if field_type in unsupported_field_types:
                        removed_formula_dependency_field_api_names.add(api_name)
                continue
            initial_fields.append(field)
        sanitized["fields"] = initial_fields
    if isinstance(sanitized.get("fields"), list):
        cleaned_fields = []
        formula_relationship_names = referenced_relationship_names(sanitized["fields"])
        for field in sanitized["fields"]:
            if not isinstance(field, dict):
                cleaned_fields.append(field)
                continue
            field = json.loads(json.dumps(field))
            api_name = str(field.get("apiName", "")).strip()
            if (
                not isinstance(field.get("projection"), dict)
                and (object_api_name, api_name) not in wave4_active_formula_fields
                and (object_api_name, api_name) not in wave5_active_formula_fields
                and (object_api_name, api_name) not in wave6_active_formula_fields
                and (
                    (object_api_name, api_name) in DEFERRED_ACTIVE_FORMULA_FIELDS
                    or formula_uses_relationship_traversal(field.get("formula"))
                    or formula_references_removed_dependency(
                        field.get("formula"),
                        removed_formula_dependency_field_api_names,
                    )
                )
            ):
                # Keep the downstream compatibility downgrade narrow. Preserve
                # most source-authored formulas, but continue stripping
                # formulas whose dependencies are removed from final assembly or
                # are explicitly deferred until the runtime/view layer supports
                # relationship traversal and other join shapes end-to-end.
                if str(field.get("type", "")).strip().lower() == "formula":
                    return_type = str(field.get("returnType", "")).strip().lower()
                    if return_type:
                        field["type"] = return_type
                field.pop("formula", None)
                field.pop("formulaMode", None)
                field.pop("returnType", None)
            default_raw = field.get("default")
            default_expr = str(default_raw).strip().lower()
            # Current upstream install path materializes metadata defaults into
            # raw DDL. Strip unsupported expression defaults and pre-quoted
            # string literals that break SQL generation.
            if (object_api_name, api_name) in disabled_field_defaults:
                field.pop("default", None)
            elif default_expr in unsupported_default_expressions:
                field.pop("default", None)
            elif isinstance(default_raw, str) and (
                '"' in default_raw or "'" in default_raw
            ):
                field.pop("default", None)
            field_type = str(field.get("type", "")).strip().lower()
            if field_type == "picklist":
                options = field.get("options")
                if api_name.lower() == "currencyisocode":
                    if not isinstance(options, list) or len(options) == 0:
                        field["options"] = json.loads(json.dumps(currency_iso_code_default_options))
                    if not str(field.get("default", "")).strip():
                        field["default"] = "USD"
                if (
                    (not isinstance(options, list) or len(options) == 0)
                    and api_name.lower() not in preserved_empty_picklist_field_api_names
                ):
                    if api_name:
                        removed_field_api_names.add(api_name)
                    continue
            if len(field.get("referenceTo") or []) > 0:
                derived_relationship_name = ""
                if api_name.endswith("__c"):
                    derived_relationship_name = f"{api_name[:-3]}__r"
                elif api_name.endswith("Id"):
                    derived_relationship_name = api_name[:-2]
                if derived_relationship_name and derived_relationship_name in formula_relationship_names:
                    field["relationshipName"] = derived_relationship_name
            cleaned_fields.append(field)
        sanitized["fields"] = cleaned_fields
    # Downstream overlays can accidentally carry duplicate field rows.
    # Merge duplicate rows by apiName so later business-facing overrides
    # can replace labels/types without losing earlier options/defaults.
    if isinstance(sanitized.get("fields"), list):
        deduped_fields = []
        field_index_by_api_name = {}
        for field in sanitized["fields"]:
            api_name = field.get("apiName")
            if api_name:
                existing_index = field_index_by_api_name.get(api_name)
                if existing_index is not None:
                    deduped_fields[existing_index] = merge_duplicate_field_specs(
                        deduped_fields[existing_index],
                        field,
                    )
                    continue
                field_index_by_api_name[api_name] = len(deduped_fields)
            deduped_fields.append(field)
        sanitized["fields"] = deduped_fields
    if isinstance(sanitized.get("fields"), list):
        seen_relationship_names = set()
        for field in sanitized["fields"]:
            if not isinstance(field, dict):
                continue
            if len(field.get("referenceTo") or []) == 0:
                continue
            relationship_name = str(field.get("relationshipName", "")).strip()
            if not relationship_name and str(field.get("apiName", "")).strip().endswith("Id"):
                relationship_name = str(field.get("apiName", "")).strip()[:-2]
            if not relationship_name:
                continue
            candidate = relationship_name
            if candidate.lower() in seen_relationship_names:
                api_name = str(field.get("apiName", "")).strip()
                api_stub = api_name[:-3] if api_name.lower().endswith("__c") else api_name
                api_stub = api_stub[:-2] if api_stub.lower().endswith("id") else api_stub
                api_stub = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in api_stub).strip("_")
                if not api_stub:
                    api_stub = "Field"
                candidate = f"{relationship_name}_{api_stub}"
                suffix = 2
                while candidate.lower() in seen_relationship_names:
                    candidate = f"{relationship_name}_{api_stub}_{suffix}"
                    suffix += 1
                field["relationshipName"] = candidate
                seen_relationship_names.add(candidate.lower())
            else:
                seen_relationship_names.add(candidate.lower())
    return sanitized


def load_object_spec_payload(path):
    payload = json.loads(path.read_text())
    if isinstance(payload, list):
        if len(payload) != 1 or not isinstance(payload[0], dict):
            raise SystemExit(f"object spec file must contain one object: {path}")
        payload = payload[0]
    if not isinstance(payload, dict):
        raise SystemExit(f"object spec file must contain an object: {path}")
    return sanitize_object_spec(payload)


def merge_object_spec(upstream_spec, downstream_spec):
    if not isinstance(downstream_spec, dict):
        return downstream_spec
    downstream = sanitize_object_spec(downstream_spec)
    merged = json.loads(json.dumps(upstream_spec))
    for key, value in downstream.items():
        if key == "fields":
            continue
        merged[key] = value

    upstream_fields = [
        json.loads(json.dumps(field))
        for field in upstream_spec.get("fields", [])
        if isinstance(field, dict)
    ]
    downstream_fields = [
        json.loads(json.dumps(field))
        for field in downstream.get("fields", [])
        if isinstance(field, dict)
    ]
    downstream_field_map = {
        str(field.get("apiName", "")).strip(): field
        for field in downstream_fields
        if str(field.get("apiName", "")).strip()
    }

    def merged_same_name_field(upstream_field, downstream_field):
        merged_field = json.loads(json.dumps(downstream_field))
        for attr in ("createable", "updateable"):
            if upstream_field.get(attr) is False:
                merged_field[attr] = False
        # An L2 overlay may refine a standard field's tenant-facing shape, but
        # it must not silently weaken an L1 write-protection invariant merely
        # because the overlay predates that metadata attribute.
        if upstream_field.get("writeProtectionPolicy"):
            merged_field["writeProtectionPolicy"] = upstream_field[
                "writeProtectionPolicy"
            ]
        return merged_field

    merged_fields = []
    seen_field_api_names = set()
    for field in upstream_fields:
        api_name = str(field.get("apiName", "")).strip()
        if not api_name:
            merged_fields.append(field)
            continue
        downstream_field = downstream_field_map.get(api_name)
        if downstream_field:
            merged_fields.append(merged_same_name_field(field, downstream_field))
        else:
            merged_fields.append(field)
        seen_field_api_names.add(api_name)
    for field in downstream_fields:
        api_name = str(field.get("apiName", "")).strip()
        if api_name and api_name in seen_field_api_names:
            continue
        merged_fields.append(field)
    merged["fields"] = merged_fields
    # Preserve authoritative standard-object identity when the downstream overlay omits it.
    if (
        upstream_spec.get("tableType") == "standard_object"
        and merged.get("tableType") == "standard_object"
        and upstream_spec.get("keyPrefix")
        and not merged.get("keyPrefix")
    ):
        merged["keyPrefix"] = upstream_spec["keyPrefix"]
    return merged


def sanitize_validation_rule_payload(payload):
    if not isinstance(payload, dict):
        return payload
    rules = payload.get("validationRules")
    if not isinstance(rules, list):
        return payload
    sanitized = json.loads(json.dumps(payload))
    seen_ids = set()
    for rule in sanitized["validationRules"]:
        if not isinstance(rule, dict):
            continue
        current_id = str(rule.get("id", "")).strip()
        if current_id.startswith("vrule_"):
            candidate = current_id
        else:
            seed = slug_token(current_id) or slug_token(rule.get("name")) or slug_token(rule.get("objectApiName")) or "rule"
            candidate = f"vrule_{seed}"
        unique_candidate = candidate
        suffix = 2
        while unique_candidate in seen_ids:
            unique_candidate = f"{candidate}_{suffix}"
            suffix += 1
        rule["id"] = unique_candidate
        seen_ids.add(unique_candidate)
    return sanitized


def validation_rule_key(rule):
    object_api_name = str(rule.get("objectApiName", "")).strip()
    name = str(rule.get("name", "")).strip()
    if object_api_name and name:
        return ("object_name", object_api_name, name)
    rule_id = str(rule.get("id", "")).strip()
    if rule_id:
        return ("id", rule_id)
    return None


retirement_guard_rule_name = "Legacy_Table_Retired_Guard"
# The exemption every hand-written guard already uses. Object permissions cannot enforce this
# (master-detail children inherit parent CRUD) and $Profile gates are unreliable here (environment
# admins carry a NULL Profile.Name), so the escape hatch is namespace-based and prefix-anchored.
retirement_guard_condition = 'NOT(BEGINS(Name, "Staging Inspection"))'


def add_missing_retirement_guards(payload):
    """Write a guard rule for any `retired` object that has none.

    Deliberately ADDITIVE. The three hand-written guards carry 800-1000 characters of
    object-specific history — which cron stopped writing when, how many rows are archived and why
    they must survive — that no template can reproduce, and moving that prose into the object spec
    would relocate the hand-maintenance rather than remove it. What the declaration removes is the
    chance of *forgetting* the rule: retire an object and it is guarded, with wording derived from
    the successor it names.

    Only `retired`. A `deprecated` object's UI is deliberately open, and sealing writes there
    produces a form users can reach, fill in and never save — the dead end #4791 shipped.
    """
    retired = sorted(
        name for name, declaration in lifecycle_declarations.items()
        if declaration["lifecycleStatus"] == "retired"
    )
    if not retired:
        return payload
    if not isinstance(payload, dict) or not isinstance(payload.get("validationRules"), list):
        # Returning the payload untouched would leave every retired object unguarded and say
        # nothing — the guard generator failing open.
        #
        # On the main path merge_validation_rule_payloads rejects a malformed file first, so this
        # is a second line rather than the first: it covers the branch where the merge is SKIPPED
        # (a validation_rule component not sourced from L2, or with no official counterpart), where
        # the payload reaches here unvalidated. Verified by corrupting the file: the merge catches
        # it before this does.
        raise SystemExit(
            "cannot derive retirement guards: validation-rule payload is not "
            f"{{'validationRules': [...]}} but {retired} require a guard"
        )
    rules = payload["validationRules"]
    guarded = {
        str(rule.get("objectApiName", "")).strip()
        for rule in rules
        if isinstance(rule, dict) and str(rule.get("name", "")).strip() == retirement_guard_rule_name
    }
    for api_name, declaration in sorted(lifecycle_declarations.items()):
        if declaration["lifecycleStatus"] != "retired" or api_name in guarded:
            continue
        superseded_by = declaration["supersededBy"]
        message = f"This object is retired. Create the record on {superseded_by} instead."
        if len(message) > 255:
            # _System_ValidationRule.ErrorMessage is varchar(255); an over-long message is invisible
            # at authoring time and surfaces as "Data too long" partway through pack install, which
            # aborts the whole install rather than this rule.
            raise SystemExit(f"{api_name}: generated guard errorMessage exceeds 255 characters")
        # str.rstrip takes a CHARACTER SET, not a suffix: "poc__c".rstrip("_c") strips the trailing
        # c, _, _ and then the c of "poc", leaving "po". "order_product__c" survived it only because
        # a 't' happened to stop the scan, so the bug was invisible on the three objects that
        # already had hand-written rules and would have surfaced on whichever object was retired
        # next. Only a rebuild with a newly-retired object showed it.
        slug = api_name.lower()
        if slug.endswith("__c"):
            slug = slug[:-3]
        rules.append({
            "id": f"vrule_{slug}_retired_guard",
            "objectApiName": api_name,
            "name": retirement_guard_rule_name,
            "description": (
                f"Generated from lifecycleStatus: retired (superseded by {superseded_by}, "
                f"{declaration['retiredOn']}). Writes are blocked; the inspection-name prefix is the "
                "only exemption. Validation rules do not cover deletes or direct-SQL writes — "
                "retirement does not license deletion, and nothing enforces retention."
            ),
            "active": True,
            "condition": retirement_guard_condition,
            "errorDisplayField": None,
            "errorMessage": message,
        })
    return payload


def merge_validation_rule_payloads(official_payload, downstream_payload):
    # Validation rules are a shared L1/L2 component.  A downstream file may
    # override one of its own rules, but must not mask an unrelated rule newly
    # supplied by the official CRM pack.  Match semantic object/name first so a
    # downstream override survives an ID normalization, then fall back to ID.
    if not isinstance(official_payload, dict) or not isinstance(downstream_payload, dict):
        raise SystemExit("validation rule payloads must be JSON objects")
    official_rules = official_payload.get("validationRules")
    downstream_rules = downstream_payload.get("validationRules")
    if not isinstance(official_rules, list) or not isinstance(downstream_rules, list):
        raise SystemExit("validation rule payloads must contain validationRules arrays")

    merged_payload = json.loads(json.dumps(official_payload))
    merged_rules = list(official_rules)
    rule_positions = {}
    for index, rule in enumerate(merged_rules):
        if isinstance(rule, dict):
            key = validation_rule_key(rule)
            if key is not None:
                rule_positions[key] = index

    for rule in downstream_rules:
        key = validation_rule_key(rule) if isinstance(rule, dict) else None
        if key is not None and key in rule_positions:
            merged_rules[rule_positions[key]] = rule
            continue
        if key is not None:
            rule_positions[key] = len(merged_rules)
        merged_rules.append(rule)

    merged_payload["validationRules"] = merged_rules
    return merged_payload


def ui_action_scope(action):
    if action.get("objectApiName"):
        return ("object", action["objectApiName"])
    applies_to = action.get("appliesTo")
    if applies_to:
        return ("appliesTo", tuple(sorted(applies_to)))
    return ("global",)


def ui_action_key(action):
    return (ui_action_scope(action), action.get("name"))


def merge_ui_action_payloads(official_actions, downstream_actions):
    # Additive keyed merge (mirrors merge_object_spec for the actions list):
    # official (L1) standard actions flow through unchanged, the downstream (L2)
    # overlay adds its own actions and overrides on (scope, name) collision, and
    # a downstream entry with "suppress": true drops the matching official action
    # instead of shipping a button. This replaces the previous whole-file
    # override so L1 action changes are no longer masked and L1-only actions are
    # no longer dropped.
    suppressed_names = set()
    suppressed_keys = set()
    real_downstream = []
    for action in downstream_actions:
        if action.get("suppress") is True:
            if action.get("objectApiName") or action.get("appliesTo"):
                suppressed_keys.add(ui_action_key(action))
            else:
                suppressed_names.add(action.get("name"))
            continue
        # A real action must carry its full shape. Catch a malformed entry
        # (e.g. a mistyped "suppress" key that falls through) here instead of
        # shipping a broken button with no label/renderer.
        missing = [field for field in ("name", "label", "type") if not action.get(field)]
        if missing:
            raise SystemExit(
                f"ui_action downstream entry missing required field(s) {missing} "
                f"(a mistyped 'suppress' marker?): {action!r}"
            )
        real_downstream.append(action)

    matched = set()
    # A dict preserves insertion order and de-dups by key: official actions set
    # the position, a later downstream override keeps that position, and new
    # downstream keys append at the end.
    merged = {}
    for action in official_actions:
        name = action.get("name")
        key = ui_action_key(action)
        if name in suppressed_names:
            matched.add(name)
            continue
        if key in suppressed_keys:
            matched.add(key)
            continue
        merged[key] = action
    for action in real_downstream:
        merged[ui_action_key(action)] = action

    unmatched = (suppressed_names | suppressed_keys) - matched
    if unmatched:
        raise SystemExit(
            "ui_action suppress marker matched no official action (stale/typo): "
            f"{sorted(str(item) for item in unmatched)}"
        )
    return list(merged.values())


def merge_invocable_action_payloads(official_actions, downstream_actions):
    """Keep L1 actions unless L2 intentionally overrides the same action name."""
    merged = {}
    for source, actions in (("official", official_actions), ("downstream", downstream_actions)):
        if not isinstance(actions, list):
            raise SystemExit(f"{source} invocable action payload must be a JSON array")
        seen = set()
        for action in actions:
            if not isinstance(action, dict):
                raise SystemExit(f"{source} invocable action entry must be an object")
            name = action.get("name")
            if not isinstance(name, str) or not name or name.strip() != name:
                raise SystemExit(f"{source} invocable action has no canonical name")
            if name in seen:
                raise SystemExit(f"{source} invocable action payload duplicates {name}")
            seen.add(name)
            merged[name] = action
    return list(merged.values())


def merge_component_dependencies(official_component, downstream_component):
    merged = normalize_component(downstream_component)
    dependencies = []
    seen = set()
    for component in (official_component, downstream_component):
        for dependency in component.get("dependsOn", []):
            if not isinstance(dependency, dict):
                continue
            key = (dependency.get("kind"), dependency.get("businessKey"))
            if key in seen:
                continue
            seen.add(key)
            dependencies.append(dict(dependency))
    if dependencies:
        merged["dependsOn"] = dependencies
    else:
        merged.pop("dependsOn", None)
    return merged


def merge_collapsed_object_component_dependencies(official_component, downstream_components):
    """Preserve external prerequisites when many object specs become one component.

    Object-to-object prerequisites become internal to the collapsed standard
    object component and must not turn into a self dependency. Dependencies on
    other component kinds still govern apply order and therefore belong on the
    generated component.
    """
    merged = normalize_component(official_component)
    dependencies = []
    seen = set()
    for component in (official_component, *downstream_components):
        for dependency in component.get("dependsOn", []):
            if not isinstance(dependency, dict):
                continue
            if dependency.get("kind") == "object_spec":
                continue
            key = (dependency.get("kind"), dependency.get("businessKey"))
            if key in seen:
                continue
            seen.add(key)
            dependencies.append(dict(dependency))
    if dependencies:
        merged["dependsOn"] = dependencies
    else:
        merged.pop("dependsOn", None)
    return merged


l2_pack = json.loads((l2_dir / "pack.json").read_text())
upstream_pack = json.loads(official_pack_meta.read_text())

upstream_object_components = [component for component in upstream_pack.get("components", []) if component.get("kind") == "object_spec"]
if len(upstream_object_components) != 1:
    raise SystemExit(f"expected exactly one upstream object_spec component, got {len(upstream_object_components)}")

upstream_object_component = normalize_component(upstream_object_components[0])
if upstream_object_component.get("path") != "objects/standard_objects_spec.json":
    raise SystemExit(
        "upstream object_spec path changed; expected objects/standard_objects_spec.json, "
        f"got {upstream_object_component.get('path')}"
    )

l2_object_components = [
    normalize_component(component)
    for component in l2_pack.get("components", [])
    if component.get("kind") == "object_spec"
]
collapsed_object_component = merge_collapsed_object_component_dependencies(
    upstream_object_component,
    l2_object_components,
)

standard_spec_path = final_dir / upstream_object_component["path"]
if not standard_spec_path.exists():
    raise SystemExit(f"missing upstream standard object spec: {standard_spec_path}")

standard_spec_payload = json.loads(standard_spec_path.read_text())
if not isinstance(standard_spec_payload, list):
    raise SystemExit("upstream standard_objects_spec.json must be a JSON array")

l2_object_payloads = {}
for path in sorted((l2_dir / "objects").glob("*.json"), key=lambda p: p.name):
    payload = load_object_spec_payload(path)
    api_name = payload.get("apiName")
    if not api_name:
        raise SystemExit(f"missing apiName in {path}")
    l2_object_payloads[api_name] = payload

merged_specs = []
seen_api_names = set()
for spec in standard_spec_payload:
    api_name = spec.get("apiName")
    if not api_name:
        continue
    downstream_spec = l2_object_payloads.get(api_name)
    merged_specs.append(merge_object_spec(spec, downstream_spec) if downstream_spec else spec)
    seen_api_names.add(api_name)
for api_name, payload in l2_object_payloads.items():
    if api_name in seen_api_names:
        continue
    merged_specs.append(payload)
standard_spec_path.write_text(json.dumps(merged_specs, indent=2) + "\n")

l2_non_object_components = [
    normalize_component(component)
    for component in l2_pack.get("components", [])
    if component.get("kind") != "object_spec"
]
l2_component_map = {component_key(component): component for component in l2_non_object_components}
l2_component_order = [component_key(component) for component in l2_non_object_components]
upstream_component_map = {
    component_key(component): component
    for component in upstream_pack.get("components", [])
    if component.get("kind") != "object_spec"
}

final_components = [collapsed_object_component]
used_l2_keys = set()
overridden_component_paths = set()
upstream_record_type_identities = []

for upstream_component in upstream_pack.get("components", []):
    kind = upstream_component.get("kind")
    if kind == "object_spec":
        continue
    if component_key(upstream_component) in disabled_component_keys:
        continue
    if kind == "standard_flow" and upstream_component.get("businessKey") not in flow_whitelist:
        continue
    if kind == "page_layout" and upstream_component.get("businessKey") in disabled_page_layout_business_keys:
        continue

    key = component_key(upstream_component)
    if key in l2_component_map:
        upstream_path = upstream_component.get("path")
        l2_path = l2_component_map[key].get("path")
        if upstream_path and upstream_path != l2_path:
            overridden_component_paths.add(upstream_path)
        selected_component = l2_component_map[key]
        if kind == "invocable_action":
            selected_component = merge_component_dependencies(
                upstream_component, selected_component,
            )
        final_components.append(with_component_source(selected_component, "l2"))
        used_l2_keys.add(key)
    else:
        final_components.append(with_component_source(upstream_component, "official"))

for key in l2_component_order:
    if key in used_l2_keys:
        continue
    component = l2_component_map[key]
    if component_key(component) in disabled_component_keys:
        continue
    if component.get("kind") == "standard_flow" and component.get("businessKey") not in flow_whitelist:
        continue
    if component.get("kind") == "page_layout" and component.get("businessKey") in disabled_page_layout_business_keys:
        continue
    final_components.append(with_component_source(component, "l2"))

for component in final_components:
    normalize_final_component_dependencies(component, upstream_object_component["businessKey"])

seen = set()
deduped_components = []
for component in final_components:
    key = (component.get("kind"), component.get("businessKey"), component.get("path"))
    if key in seen:
        continue
    seen.add(key)
    deduped_components.append(component)

for component in deduped_components:
    if component.get("kind") == "object_spec":
        continue
    source_root = component.get("_source_root")
    if source_root == "l2":
        source_candidates = [l2_dir / component["path"], official_pack_dir / component["path"]]
    else:
        source_candidates = [official_pack_dir / component["path"], l2_dir / component["path"]]
    source_path = next((path for path in source_candidates if path.exists()), None)
    if source_path is None:
        raise SystemExit(f"component payload missing for {component.get('kind')} {component.get('businessKey')}: {component.get('path')}")
    target_path = final_dir / component["path"]
    target_path.parent.mkdir(parents=True, exist_ok=True)
    if component.get("kind") == "validation_rule":
        payload = json.loads(source_path.read_text())
        if source_root == "l2":
            official_validation_path = official_pack_dir / component["path"]
            if official_validation_path.exists():
                payload = merge_validation_rule_payloads(
                    json.loads(official_validation_path.read_text()),
                    payload,
                )
        payload = add_missing_retirement_guards(payload)
        target_path.write_text(json.dumps(sanitize_validation_rule_payload(payload), indent=2) + "\n")
    elif component.get("kind") == "ui_action" and source_root == "l2":
        # Additively merge the downstream (L2) actions onto the official (L1)
        # actions instead of replacing the file wholesale.
        official_actions_path = official_pack_dir / component["path"]
        official_actions = (
            json.loads(official_actions_path.read_text())
            if official_actions_path.exists()
            else []
        )
        merged_actions = merge_ui_action_payloads(official_actions, json.loads(source_path.read_text()))
        target_path.write_text(json.dumps(merged_actions, indent=2) + "\n")
    elif component.get("kind") == "invocable_action" and source_root == "l2":
        official_actions_path = official_pack_dir / component["path"]
        official_actions = (
            json.loads(official_actions_path.read_text())
            if official_actions_path.exists()
            else []
        )
        merged_actions = merge_invocable_action_payloads(
            official_actions, json.loads(source_path.read_text()),
        )
        target_path.write_text(json.dumps(merged_actions, indent=2) + "\n")
    elif component.get("kind") == "record_type" and source_root == "l2":
        official_record_type_component = upstream_component_map.get(component_key(component))
        official_record_types_path = (
            official_pack_dir / official_record_type_component["path"]
            if official_record_type_component is not None
            and official_record_type_component.get("path")
            else None
        )
        if official_record_types_path is None or not official_record_types_path.exists():
            raise SystemExit(
                "L2 record_type component cannot be composed without its matching "
                f"official payload: {official_record_types_path}"
            )
        official_record_types = json.loads(official_record_types_path.read_text())
        merged_record_types = merge_record_type_payloads(
            official_record_types,
            json.loads(source_path.read_text()),
        )
        upstream_record_type_identities = record_type_identities(
            official_record_types,
            "official",
        )
        merged_identities = set(record_type_identities(merged_record_types, "merged"))
        missing_upstream = sorted(set(upstream_record_type_identities) - merged_identities)
        if missing_upstream:
            raise SystemExit(
                "record_type semantic merge dropped official identities: "
                + ", ".join(missing_upstream)
            )
        target_path.write_text(json.dumps(merged_record_types, indent=2) + "\n")
    else:
        shutil.copy2(source_path, target_path)

for component in deduped_components:
    component.pop("_source_root", None)

disabled_page_layout_paths = set()
for component in upstream_pack.get("components", []) + l2_pack.get("components", []):
    if component.get("kind") != "page_layout":
        continue
    if component.get("businessKey") not in disabled_page_layout_business_keys:
        continue
    path = component.get("path")
    if path:
        disabled_page_layout_paths.add(path)

for relative_path in disabled_page_layout_paths | overridden_component_paths:
    target_path = final_dir / relative_path
    if target_path.exists():
        target_path.unlink()

frontend_overlay_dir = l2_dir / "frontend"
frontend_overlay_file_count = 0
if frontend_overlay_dir.exists():
    if not frontend_overlay_dir.is_dir():
        raise SystemExit(f"frontend overlay path is not a directory: {frontend_overlay_dir}")
    for source_path in sorted(frontend_overlay_dir.rglob("*"), key=lambda path: path.as_posix()):
        if not source_path.is_file():
            continue
        relative_path = source_path.relative_to(frontend_overlay_dir)
        target_path = final_dir / "frontend" / relative_path
        target_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source_path, target_path)
        frontend_overlay_file_count += 1

def frontend_package_export_key(entrypoint: str) -> str:
    without_extension = re.sub(r"\.(ts|tsx)$", "", entrypoint)
    without_src = without_extension.removeprefix("src/")
    if without_src.endswith("/index"):
        without_src = without_src[: -len("/index")]
    return f"./{without_src}"

def sync_frontend_overlay_source_ownership():
    src_overlay_dir = frontend_overlay_dir / "src"
    if not src_overlay_dir.exists():
        return
    source_files = []
    for source_path in sorted(src_overlay_dir.rglob("*"), key=lambda path: path.as_posix()):
        if not source_path.is_file() or source_path.suffix not in {".ts", ".tsx"}:
            continue
        if ".test." in source_path.name:
            continue
        source_files.append(source_path.relative_to(frontend_overlay_dir).as_posix())
    if not source_files:
        return

    frontend_dir = final_dir / "frontend"
    manifest_path = frontend_dir / "source-manifest.json"
    package_path = frontend_dir / "package.json"
    if not manifest_path.exists() or not package_path.exists():
        return

    manifest = json.loads(manifest_path.read_text())
    package_build = manifest.setdefault("packageBuild", {})
    entrypoints = package_build.setdefault("entrypoints", [])
    for source_file in source_files:
        if source_file not in entrypoints:
            entrypoints.append(source_file)
    entrypoints.sort()
    manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")

    package_json = json.loads(package_path.read_text())
    exports = package_json.setdefault("exports", {})
    for source_file in source_files:
        exports[frontend_package_export_key(source_file)] = f"./{source_file}"
    package_path.write_text(json.dumps(package_json, indent=2) + "\n")

if frontend_overlay_file_count:
    sync_frontend_overlay_source_ownership()

l2_host_requirements = l2_pack.get("hostRequirements", [])
upstream_host_requirements = upstream_pack.get("hostRequirements", [])
merged_host_requirements = []
seen_host_requirements = set()
for requirement in upstream_host_requirements + l2_host_requirements:
    normalized = {
        "kind": requirement.get("kind"),
        "name": requirement.get("name"),
    }
    key = (normalized["kind"], normalized["name"])
    if not normalized["kind"] or not normalized["name"] or key in seen_host_requirements:
        continue
    seen_host_requirements.add(key)
    merged_host_requirements.append(normalized)

upstream_pack["hostRequirements"] = merged_host_requirements
upstream_pack["components"] = deduped_components
(final_dir / "pack.json").write_text(json.dumps(upstream_pack, indent=2) + "\n")

lineage = {
    "stage": final_dir.name,
    "generatedAt": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
    "officialPack": {
        "repo": official_pack_repo,
        "ref": official_pack_ref,
        "packPath": official_pack_path,
        "manifestSha256": sha256_file(official_pack_meta),
        "objectSpecBusinessKey": upstream_object_component.get("businessKey"),
        "objectSpecPath": upstream_object_component.get("path"),
    },
    "upstreamRuntime": {
        "repo": "pingcap-inc/NexusCRM",
        "ref": upstream_ref,
    },
    "downstream": {
        "repo": "pingcap-inc/nexuscrm-pingcap-pack",
        # The containing Git commit is the downstream identity. Embedding HEAD
        # here would always become stale when this generated file is committed.
        "stackLockSha256": sha256_file(root_dir / "stack.lock.json"),
        "intermediatePackPath": str(l2_dir.relative_to(root_dir)),
        "intermediatePackManifestSha256": sha256_file(l2_dir / "pack.json"),
    },
    "assembly": {
        "finalPackPath": str(final_dir.relative_to(root_dir)),
        "flowWhitelist": sorted(flow_whitelist),
        "mergedObjectCount": len(merged_specs),
        "componentCount": len(deduped_components),
        "recordTypeUpstreamIdentities": sorted(upstream_record_type_identities),
    },
}

if frontend_overlay_file_count:
    lineage["downstream"]["frontendOverlayPath"] = str(frontend_overlay_dir.relative_to(root_dir))
    lineage["downstream"]["frontendOverlayFileCount"] = frontend_overlay_file_count

l2_lineage_path = l2_dir / ".build-lineage.json"
if l2_lineage_path.exists():
    lineage["downstream"]["intermediateBuildLineagePath"] = str(l2_lineage_path.relative_to(root_dir))
    lineage["downstream"]["intermediateBuildLineageSha256"] = sha256_file(l2_lineage_path)

final_lineage_path = final_dir / ".build-lineage.json"
previous_lineage_candidate = previous_final_lineage_path if previous_final_lineage_path.exists() else final_lineage_path
if previous_lineage_candidate.exists():
    try:
        existing_lineage = json.loads(previous_lineage_candidate.read_text())
    except json.JSONDecodeError:
        existing_lineage = None
    if isinstance(existing_lineage, dict):
        existing_without_generated_at = dict(existing_lineage)
        existing_without_generated_at.pop("generatedAt", None)
        current_without_generated_at = dict(lineage)
        current_without_generated_at.pop("generatedAt", None)
        if existing_without_generated_at == current_without_generated_at:
            lineage["generatedAt"] = existing_lineage.get("generatedAt", lineage["generatedAt"])

serialized_lineage = json.dumps(lineage, indent=2) + "\n"
if not final_lineage_path.exists() or final_lineage_path.read_text() != serialized_lineage:
    final_lineage_path.write_text(serialized_lineage)
PY

cat >"${FINAL_DIR}/README.md" <<EOF
# $(basename "${FINAL_DIR}")

Generated final merged pack for local NexusCRM bootstrap.

Inputs:
- official \`${OFFICIAL_PACK_PATH}\` from \`${OFFICIAL_APP_PACKS_REPO_NAME}\`
- checked-in intermediate pack \`${L2_DIR#${ROOT_DIR}/}\`

Build lineage:
- \`.build-lineage.json\`

Reproduce the committed final pack from its validated build lineage:

\`\`\`bash
bash scripts/build_pingcap_salesforce_sample_pack.sh --reset --from-final-pack-lineage
\`\`\`
EOF

echo "wrote ${L2_DIR}"
echo "wrote ${FINAL_DIR}"
python3 "${ROOT_DIR}/scripts/audit_final_pack_metadata.py"
