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

ROOT="${AGENTTEAM_MESSAGE_DIRECT_NO_OUTBOX_GUARDRAIL_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
cd "$ROOT"

failures=0

fail() {
  printf 'FAIL: %s\n' "$1" >&2
  failures=$((failures + 1))
}

# Keep every production Go root in the no-outbox inventory. Restricting this
# guard to the packages that happen to own today's exceptions would let a new
# service, command, desktop, or mesh producer bypass the allowlist merely by
# living in another package.
PRODUCTION_GO_ROOTS=(internal cmd desktop)

require_file() {
  local file="$1"
  [[ -f "$file" ]] || fail "missing $file"
}

require_contains() {
  local file="$1"
  local needle="$2"
  local label="${3:-$file must contain $needle}"
  if [[ ! -f "$file" ]]; then
    fail "missing $file"
    return
  fi
  if ! grep -Fq -- "$needle" "$file"; then
    fail "$label"
  fi
}

require_not_contains() {
  local file="$1"
  local needle="$2"
  local label="${3:-$file must not contain $needle}"
  if [[ ! -f "$file" ]]; then
    fail "missing $file"
    return
  fi
  if grep -Fq -- "$needle" "$file"; then
    fail "$label"
  fi
}

is_allowed_file() {
  local candidate="$1"
  shift
  local allowed
  for allowed in "$@"; do
    [[ "$candidate" == "$allowed" ]] && return 0
  done
  return 1
}

check_allowed_files() {
  local label="$1"
  local pattern="$2"
  shift 2
  local allowed_files=()
  while (( $# > 0 )); do
    [[ "$1" == "--" ]] && { shift; break; }
    allowed_files+=("$1")
    shift
  done
  local search_paths=("$@")
  local matches=()
  local match
  while IFS= read -r match; do
    [[ -n "$match" ]] || continue
    matches+=("$match")
  done < <(rg -l -P --glob '*.go' --glob '!**/*_test.go' -- "$pattern" "${search_paths[@]}" 2>/dev/null || true)
  local file
  if (( ${#matches[@]} > 0 )); then
    for file in "${matches[@]}"; do
      if ! is_allowed_file "$file" "${allowed_files[@]}"; then
        fail "$label: unexpected production match in $file"
        rg -n -P --glob '*.go' --glob '!**/*_test.go' -- "$pattern" "$file" >&2 || true
      fi
    done
  fi
}

print_inventory() {
  if [[ -f guardrails/message/direct-no-outbox-baseline.md ]]; then
    cat guardrails/message/direct-no-outbox-baseline.md
  else
    printf 'No direct/no-outbox allowlist found.\n'
  fi
  printf '\n## Current production no-outbox profile references\n'
  rg -n --glob '*.go' --glob '!**/*_test.go' -- 'MessagePlanSideEffectProfileNoOutbox|SideEffectProfile:[[:space:]]*[^\n]*MessagePlanSideEffectProfileNoOutbox' "${PRODUCTION_GO_ROOTS[@]}" 2>/dev/null || printf '(none)\n'
  printf '\n## Current production MessagePlan applier sinks\n'
  rg -n -P --glob '*.go' --glob '!**/*_test.go' -- 'ApplyMessagePlanTx(?:WithSidecarWriter)?\(' "${PRODUCTION_GO_ROOTS[@]}" 2>/dev/null || printf '(none)\n'
  printf '\n## Current production direct reaction/rewind/projection-only emitters\n'
  rg -n --glob '*.go' --glob '!**/*_test.go' -- 'DeliverReaction(Added|Removed)\(|publishReactionChangedEvent\(|publishConversationRewoundEvent\(|PublishConversationRewoundEvent\(|publishRewindNotice\(|EmitCommittedMessageProjectionOnly\(' "${PRODUCTION_GO_ROOTS[@]}" 2>/dev/null || printf '(none)\n'
}

run_checks() {
  require_file guardrails/message/direct-no-outbox-baseline.md
  require_file guardrails/message/direct-no-outbox.sh
  require_file guardrails/message/README.md
  require_file specs/message/reaction.md
  require_file specs/message/scope-model.md
  require_file specs/message/scope-implementation-design.md
  require_file specs/message/delivery-runtime.md
  require_file specs/collaboration/source-projection-delivery.md
  require_file internal/store/message/scope_planner.go
  require_file internal/store/message/scope_applier.go
  require_file internal/store/privateagent/conversation_write.go
  require_file internal/store/taskboard/task_scope_message.go
  require_file internal/store/taskboard/detail_mutation.go
  require_file internal/store/app/service_event_provider_webhook_handler.go
  require_file internal/store/app/service_event_task_delivery.go
  require_file internal/store/app/channel_member_events.go
  require_file internal/store/app/agent_lifecycle.go
  require_file internal/store/app/committed_message_fanout.go
  require_file internal/api/channel/api_channel_membership.go
  require_file internal/api/im/reactions_handler.go
  require_file internal/api/im/stream_events_projector.go
  require_file internal/api/im/rewind_handler.go
  require_file internal/api/im/fanout/fanout.go
  require_file internal/api/im/fanout/stream_events.go
  require_file internal/api/agent/reaction_flow.go

  require_contains guardrails/message/README.md './direct-no-outbox.sh' 'message guardrail README must link the enforcing direct/no-outbox guardrail'
  require_contains guardrails/message/README.md 'realtime / no-outbox exception allowlist' 'message guardrail README must describe direct/no-outbox as enforcing'

  require_contains guardrails/message/direct-no-outbox-baseline.md 'enforcing allowlist' 'direct/no-outbox document must be an enforcing allowlist, not a passive baseline'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'no-outbox path by editing code alone' 'direct/no-outbox allowlist must prohibit code-only bypass additions'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'internal/api/im/reactions_handler.go' 'direct/no-outbox allowlist must include browser reaction API path'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'internal/api/agent/reaction_flow.go' 'direct/no-outbox allowlist must include agent reaction API path'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'internal/api/im/rewind_handler.go' 'direct/no-outbox allowlist must include rewind API path'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'publishRewindNotice' 'direct/no-outbox allowlist must include the direct rewind system notice projection'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'channel_member_events.go' 'direct/no-outbox allowlist must include the current channel member system-message boundary file'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'internal/service/channeladmin/service.go' 'direct/no-outbox allowlist must include the Agent Exec channel member service path'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'agent_lifecycle.go' 'direct/no-outbox allowlist must include initial agent channel memberships'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'CreateWorkspaceAgentInTx' 'direct/no-outbox allowlist must document the caller-owned agent-create transaction handoff'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'appendServiceEventTaskDiscussionTx' 'direct/no-outbox allowlist must document the canonical service-event task delivery producer'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'anchorTaskDescriptionAttachments' 'direct/no-outbox allowlist must include the hidden task description attachment anchor'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'ApplyMessagePlanTxWithSidecarWriter' 'direct/no-outbox allowlist must include the sidecar-writing MessagePlan sink'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'ordinary task discussion creation' 'direct/no-outbox allowlist must keep ordinary task discussion as a non-exception'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'ordinary channel/DM/reply-thread message sends' 'direct/no-outbox allowlist must keep ordinary conversation sends as non-exceptions'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'exact function-local count' 'ordinary conversation applier permission must be structurally narrower than a file allowlist'
  require_contains guardrails/message/direct-no-outbox-baseline.md 'message.applyMessagePlanTx' 'ordinary conversation applier inventory must close the final lowercase physical seam'

  require_contains specs/message/reaction.md 'direct post-commit reaction fanout exception' 'reaction spec must document current direct fanout exception explicitly'
  require_contains specs/message/reaction.md '不为 reaction fanout 写入 `scope_fanout_outbox`' 'reaction spec must not claim reaction fanout writes outbox today'
  require_contains specs/message/reaction.md 'direct/no-outbox allowlist' 'reaction spec must point to the direct/no-outbox allowlist'
  require_not_contains specs/message/reaction.md 'Reaction 写路径只在事务内写持久事实和 outbox intent' 'reaction spec must not retain the old reaction-outbox write contract'
  require_not_contains specs/message/reaction.md 'fanout 副作用只通过 outbox 执行' 'reaction spec invariant must not claim reaction fanout only uses outbox'
  require_contains specs/message/scope-model.md 'direct/no-outbox allowlist' 'scope model must document direct/no-outbox exceptions beside the outbox model'
  require_contains specs/message/scope-model.md 'ordinary message creation 的 runtime wake' 'scope model must scope outbox-only fanout wording to ordinary message creation'
  require_contains specs/message/scope-model.md 'rewind system notice' 'scope model must name the rewind notice as an intentional no-notify exception'
  require_contains specs/message/scope-model.md 'channel member system message projection' 'scope model must name the channel member system-message no-outbox exception'
  require_contains specs/message/scope-model.md 'internal/service/channeladmin' 'scope model must document the Agent Exec channel member no-outbox owner'
  require_contains specs/message/scope-model.md 'CreateWorkspaceAgentInTx' 'scope model must document caller-owned agent-create projection handoffs'
  require_contains specs/collaboration/source-projection-delivery.md 'regular Agent creation, Agent duplication, or role' 'collaboration source/projection spec must cover all agent-create variants'
  require_contains specs/collaboration/source-projection-delivery.md 'discard on' 'collaboration source/projection spec must document rollback disposal of transaction-bound projections'
  require_contains specs/message/scope-model.md 'hidden task-description attachment anchor' 'scope model must name the hidden task-description attachment no-outbox exception'
  require_contains specs/message/scope-implementation-design.md 'direct/no-outbox allowlist' 'scope implementation design must document direct/no-outbox exceptions'
  require_contains specs/message/scope-implementation-design.md 'ordinary / outbox-backed producer 必须在事务内写 durable outbox' 'scope implementation design must require an outbox and explicit owner for ordinary/outbox-backed producers'
  require_contains specs/message/scope-implementation-design.md 'named spec/guardrail' 'scope implementation design must require named spec/guardrail for direct/no-outbox exceptions'
  require_contains specs/message/scope-implementation-design.md 'Reaction direct fanout 是 direct/no-outbox allowlist 中的显式例外' 'scope implementation design must not require reaction fanout outbox status'
  require_contains specs/message/scope-implementation-design.md 'direct/no-outbox allowlist 不被误纳入普通 outbox worker' 'scope implementation design must cover direct/no-outbox allowlist ratchet'
  require_not_contains specs/message/scope-implementation-design.md '业务代码不能绕过 outbox 直接执行 fanout side effect' 'scope implementation design must not retain broad outbox bypass wording without allowlist carve-out'
  require_not_contains specs/message/scope-implementation-design.md 'runtime wake、receive consume、visible reply、runtime error、reaction activity 都必须写 durable outbox status' 'scope implementation design must not retain broad reaction outbox-status wording'
  require_not_contains specs/message/scope-implementation-design.md 'runtime wake、receive、reply、failure、reaction signal 均有 durable outbox status' 'scope implementation design must not retain broad reaction signal outbox-status wording'
  require_not_contains specs/message/scope-implementation-design.md 'reaction signal outbox status/error' 'scope implementation design must not require reaction signal outbox status'

  require_contains internal/store/message/scope_planner.go 'MessagePlanSideEffectProfileNoOutbox' 'message planner must keep the typed no-outbox side-effect profile'
  require_contains internal/store/message/scope_applier.go 'MessagePlanSideEffectProfileNoOutbox' 'message applier must validate the typed no-outbox side-effect profile'
  require_contains internal/store/message/scope_applier.go 'must not include outbox events' 'no-outbox profile must reject accidental outbox events'
  require_contains internal/store/message/scope_applier.go 'requires a %q outbox event' 'ordinary scope fanout profile must require an outbox event'

  require_contains internal/store/privateagent/conversation_write.go 'MessagePlanSideEffectProfileScopeFanout' 'ordinary conversation send must declare scope fanout profile'
  require_contains internal/store/privateagent/conversation_write.go 'BuildScopeMessageFanoutOutboxRecord' 'ordinary conversation send must build a scope fanout outbox record'
  require_contains internal/store/taskboard/task_scope_message.go 'MessagePlanSideEffectProfileScopeFanout' 'ordinary task discussion must declare scope fanout profile'
  require_contains internal/store/taskboard/task_scope_message.go 'BuildScopeMessageFanoutOutboxRecord' 'ordinary task discussion must build a scope fanout outbox record'
  require_not_contains internal/store/app/service_event_provider_webhook_handler.go 'MessagePlanSideEffectProfileNoOutbox' 'service-event provider webhook pipeline must not inline the task-target no-outbox policy'
  require_contains internal/store/app/service_event_task_delivery.go 'appendServiceEventTaskDiscussionTx' 'service-event task delivery entrypoint must stay explicit by name'
  require_contains internal/store/app/service_event_task_delivery.go 'projectTaskScopeDiscussionDeliveryTx' 'service-event task delivery must append through the canonical task discussion delivery seam'
  require_contains internal/store/app/service_event_task_delivery.go 'MessagePlanSideEffectProfileScopeFanout' 'service-event task delivery must use the canonical scope-fanout outbox profile'
  require_contains internal/store/app/service_event_task_delivery.go 'BuildScopeMessageFanoutOutboxRecord' 'service-event task delivery must build a canonical scope fanout outbox record'
  require_not_contains internal/store/app/service_event_task_delivery.go 'MessagePlanSideEffectProfileNoOutbox' 'service-event task delivery must not use the retired silent no-outbox profile'
  require_contains internal/store/app/channel_member_events.go 'appendChannelMemberEventMessage' 'channel member event no-outbox entrypoint must stay explicit by name'
  require_contains internal/store/app/channel_member_events.go 'MessagePlanSideEffectProfileNoOutbox' 'channel member system-message no-outbox path must use the typed no-outbox profile'
  require_contains internal/store/app/agent_lifecycle.go 'emitCreatedAgentChannelMemberEvents' 'initial agent memberships must retain a named post-commit projection helper'
  require_contains internal/store/app/agent_lifecycle.go 'EmitCommittedMessageProjectionOnly' 'initial agent memberships must use the explicit presentation-only post-commit projection'
  require_not_contains internal/store/app/agent_lifecycle.go 'EmitCommittedHumanMessage' 'initial agent memberships must not enter ordinary human unread/notification fanout'
  require_not_contains internal/store/app/agent_lifecycle.go 'fanout.DeliverCommittedMessage' 'initial agent memberships must not enter ordinary unread/notification fanout'
  require_contains internal/api/channel/api_channel_membership.go 'EmitCommittedMessageProjectionOnly' 'channel member event must use the explicit presentation-only post-commit projection'
  require_not_contains internal/api/channel/api_channel_membership.go 'EmitCommittedHumanMessage' 'channel member event must not enter ordinary unread/notification fanout'
  require_contains internal/service/channeladmin/service.go 'EmitCommittedMessageProjectionOnly' 'Agent Exec channel member mutation must use the explicit presentation-only post-commit projection'
  require_not_contains internal/service/channeladmin/service.go 'EmitCommittedHumanMessage' 'Agent Exec channel member mutation must not enter ordinary unread/notification fanout'
  require_not_contains internal/service/channeladmin/service.go 'fanout.DeliverCommittedMessage' 'Agent Exec channel member mutation must not enter ordinary unread/notification fanout'
  require_contains internal/api/im/fanout/fanout.go 'EmitCommittedMessageProjectionOnly' 'shared fanout must retain the named projection-only channel member policy'
  require_contains internal/store/taskboard/detail_mutation.go 'anchorTaskDescriptionAttachments' 'task description attachment anchor must stay isolated in a named helper'
  require_contains internal/store/taskboard/detail_mutation.go 'MessagePlanSideEffectProfileNoOutbox' 'hidden task description attachment anchor must use the typed no-outbox profile'
  require_contains internal/store/taskboard/detail_mutation.go 'ApplyMessagePlanTxWithSidecarWriter' 'hidden task description attachment anchor must retain the reviewed sidecar-writing MessagePlan sink'
  require_contains internal/api/im/rewind_handler.go 'publishRewindNotice' 'rewind notice projection must stay isolated in a named helper'
  require_contains internal/api/im/rewind_handler.go 'publishMessageCreatedEvent' 'rewind notice must retain its direct post-commit message projection'

  check_allowed_files \
    'No-outbox side-effect profile references' \
    'MessagePlanSideEffectProfileNoOutbox' \
    internal/store/message/scope_planner.go \
    internal/store/message/scope_applier.go \
    internal/store/app/channel_member_events.go \
    internal/store/taskboard/detail_mutation.go \
    -- "${PRODUCTION_GO_ROOTS[@]}"

  check_allowed_files \
    'No-outbox MessagePlan call sites' \
    'SideEffectProfile:[[:space:]]*[^\n]*MessagePlanSideEffectProfileNoOutbox' \
    internal/store/app/channel_member_events.go \
    internal/store/taskboard/detail_mutation.go \
    -- "${PRODUCTION_GO_ROOTS[@]}"

  check_allowed_files \
    'MessagePlan applier production call sites' \
    'ApplyMessagePlanTx(?:WithSidecarWriter)?\(' \
    internal/store/message/scope_applier.go \
    internal/store/privateagent/conversation_write.go \
    internal/store/message/workflow_scope.go \
    internal/store/taskboard/task_scope_message.go \
    internal/store/taskboard/detail_mutation.go \
    internal/store/app/service_event_task_delivery.go \
    internal/store/app/channel_member_events.go \
    -- "${PRODUCTION_GO_ROOTS[@]}"

  check_allowed_files \
    'Direct reaction fanout/publish callers' \
    'DeliverReaction(Added|Removed)\(|publishReactionChangedEvent\(' \
    internal/api/im/reactions_handler.go \
    internal/api/im/stream_events_projector.go \
    internal/api/im/fanout/fanout.go \
    internal/api/im/fanout/stream_events.go \
    internal/api/agent/reaction_flow.go \
    -- "${PRODUCTION_GO_ROOTS[@]}"

  check_allowed_files \
    'Legacy direct reaction realtime emitters' \
    'EmitReaction(Added|Removed|Notification)\(' \
    -- "${PRODUCTION_GO_ROOTS[@]}"

  check_allowed_files \
    'Direct rewind projection publishers' \
    'publishConversationRewoundEvent\(|PublishConversationRewoundEvent\(|publishRewindNotice\(' \
    internal/api/im/rewind_handler.go \
    internal/api/im/stream_events_projector.go \
    internal/api/im/fanout/stream_events.go \
    -- "${PRODUCTION_GO_ROOTS[@]}"

  check_allowed_files \
    'Projection-only committed message callers' \
    'EmitCommittedMessageProjectionOnly\(' \
    internal/api/channel/api_channels.go \
    internal/api/channel/api_channel_membership.go \
    internal/api/im/fanout/fanout.go \
    internal/store/app/agent_lifecycle.go \
    internal/store/app/committed_message_fanout.go \
    internal/service/channeladmin/service.go \
    -- "${PRODUCTION_GO_ROOTS[@]}"
}

case "${1:-}" in
  --inventory)
    print_inventory
    exit 0
    ;;
  --check-only|"")
    run_checks
    ;;
  *)
    printf 'Usage: %s [--check-only|--inventory]\n' "$0" >&2
    exit 2
    ;;
esac

if (( failures > 0 )); then
  printf '\ndirect/no-outbox guardrail failed with %d issue(s).\n' "$failures" >&2
  exit 1
fi

printf 'direct/no-outbox guardrail passed.\n'
