#!/bin/bash

# ==============================================================================
# Script Name: scrp.sh
# Description: OTel Exporter Config & Gateway Hosts Management CLI
# Dependencies: curl, jq
# ==============================================================================

# --- Defaults ---
DEFAULT_CM_BASE_URL="http://localhost:7180"
DEFAULT_CM_USER="admin"
DEFAULT_CM_PASS="admin"

PARAM_EXPORTER="otelcol_gateway_external_metrics_exporter"
PARAM_PROCESSOR="otelcol_processors"
PARAM_SERVICE="otelcol_gateway_metrics_service"
PARAM_EXTENSION="otelcol_extensions"
PARAM_CONNECTORS="otelcol_connectors"

FANOUT_CONNECTOR="routing/metrics-fanout"
GATEWAY_LB_CONNECTOR="roundrobin/gateway-lb"
GATEWAY_LB_PIPELINE="metrics/gateway-lb"
ROUTING_PIPELINE="metrics/routing"
CUSTOMER_PIPELINE_RECEIVER="[${FANOUT_CONNECTOR}]"
FANOUT_GATEWAY_TARGET="$GATEWAY_LB_PIPELINE"

DEBUG=false

# Temp file for normalized YAML (created by normalize_yaml_indentation, cleaned on exit)
NORMALIZED_CONFIG=""
trap 'rm -f "$NORMALIZED_CONFIG"' EXIT

# Print only when --debug is passed
log() { [[ "$DEBUG" == true ]] && echo "$*"; }

# Pipe help text through a pager when available
_pager() {
    if command -v less >/dev/null 2>&1; then
        less -R
    elif command -v more >/dev/null 2>&1; then
        more
    else
        cat
    fi
}

# ==============================================================================
# HELP FUNCTIONS
# ==============================================================================

# Global script name used by all help functions
CMD=$(basename "$0")

# Shared --url / --user / --pass / --debug block printed in every command help page
_print_common_opts() {
    printf '
       --url (string)

          Cloudera Manager base URL.
          Default: %s

       --user (string)

          Cloudera Manager username for API authentication.
          Default: %s

       --pass (string)

          Cloudera Manager password for API authentication.
          Default: %s

       --debug (boolean)

          Turn on debug logging. Prints step-by-step progress messages.

' "$DEFAULT_CM_BASE_URL" "$DEFAULT_CM_USER" "$DEFAULT_CM_PASS"
}

usage() {
    cat <<EOF | _pager

${CMD}()                                                              ${CMD}()

NAME
       ${CMD} -

DESCRIPTION
       A command-line tool to manage OpenTelemetry (OTel) collector gateway
       configuration in Cloudera Manager. It allows you to view and update
       gateway hosts and exporter pipeline configurations stored in CM's
       global config properties.

SYNOPSIS

          ${CMD} [options] <command> [parameters]

       Use ${CMD} <command> --help for information on a specific command.

OPTIONS
       --url (string)

       Cloudera Manager base URL used for all API calls.
       Default: $DEFAULT_CM_BASE_URL

       --user (string)

       Cloudera Manager username for API authentication.
       Default: $DEFAULT_CM_USER

       --pass (string)

       Cloudera Manager password for API authentication.
       Default: $DEFAULT_CM_PASS

       --debug (boolean)

       Turn on debug logging. Progress messages are printed for each step
       (YAML validation, API payloads, pipeline changes). Without this flag
       only errors are printed.

       --help | -h

       Display this help message and exit.

AVAILABLE COMMANDS

   Gateway Host Commands:

       o  get-gateway-hosts

       o  list-hosts

       o  add-gateway-host

       o  remove-gateway-host

   Exporter Commands:

       o  get-metric-configs

       o  list-exporters

       o  add-exporter

       o  update-exporter

       o  remove-exporter

   Processor Commands:

       o  list-processors

       o  add-processor

       o  update-processor

       o  remove-processor

   Extension Commands:

       o  list-extensions

       o  add-extension

       o  update-extension

       o  remove-extension

EOF
    exit 0
}

usage_get_gateway_hosts() {
    {
        cat <<EOF

NAME
       get-gateway-hosts - Display current OTel gateway hosts.

DESCRIPTION
       Display the current value of the otelcol_gateway_hosts configuration
       property. This is the comma-separated list of hostnames registered as
       OTel collector gateway nodes in Cloudera Manager.

SYNOPSIS

          get-gateway-hosts
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} get-gateway-hosts

       ${CMD} get-gateway-hosts --url http://myhost:7180 --user admin --pass secret

EOF
    } | _pager
    exit 0
}

usage_list_hosts() {
    {
        cat <<EOF

NAME
       list-hosts - List all cluster hosts with gateway status.

DESCRIPTION
       List all hosts registered in Cloudera Manager. For each host the
       output shows the hostname, IP address, and whether the host is
       currently configured as an OTel gateway host.

SYNOPSIS

          list-hosts
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} list-hosts

       ${CMD} list-hosts --url http://myhost:7180

EOF
    } | _pager
    exit 0
}

usage_get_metric_configs() {
    {
        cat <<EOF

NAME
       get-metric-configs - Display current OTel exporter and service pipeline configs.

DESCRIPTION
       Display the raw YAML content of the OTel metric configuration
       properties stored in Cloudera Manager global config:

       otelcol_gateway_external_metrics_exporter
              Defines named exporter blocks — endpoint, authentication,
              retry settings, and other per-exporter options.

       otelcol_gateway_metrics_service
              Defines OTel collector service pipelines for gateway metrics,
              including SaaS round-robin exporters and customer fan-out paths.

       otelcol_connectors
              Defines OTel connectors such as roundrobin, loadbalancing, and
              routing/metrics-fanout used for gateway metric duplication.

SYNOPSIS

          get-metric-configs
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} get-metric-configs

       ${CMD} get-metric-configs --url http://myhost:7180

EOF
    } | _pager
    exit 0
}

usage_list_exporters() {
    {
        cat <<EOF

NAME
       list-exporters - List all configured OTel exporters with their type.

DESCRIPTION
       List all exporters defined in otelcol_gateway_external_metrics_exporter
       along with their type:

       system (read-only)
              Managed by Cloudera. Names start with the prefix
              prometheusremotewrite/gateway-. These cannot be added,
              updated, or removed via this tool.

       customer
              Added and managed by you. These can be modified using
              add-exporter, update-exporter, and remove-exporter.

SYNOPSIS

          list-exporters
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} list-exporters

       ${CMD} list-exporters --url http://myhost:7180

EOF
    } | _pager
    exit 0
}

usage_add_gateway_host() {
    {
        cat <<EOF

NAME
       add-gateway-host - Add one or more hosts to the OTel gateway hosts list.

DESCRIPTION
       Add one or more hostnames to the otelcol_gateway_hosts configuration
       property in Cloudera Manager. The property is updated with a single
       PUT to /api/v31/cm/allHosts/config containing the full comma-separated
       list of hostnames (existing + new). Hosts already present in the list
       are silently skipped to prevent duplicates.

SYNOPSIS

          add-gateway-host
        --host <value>
        [--host <value> ...]
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --host (string)

          Hostname to add as a gateway node. Repeat this option to add
          multiple hosts in a single call.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} add-gateway-host --host ccycloud-2.example.com

       ${CMD} add-gateway-host --host ccycloud-2.example.com \\
              --host ccycloud-3.example.com

EOF
    } | _pager
    exit 0
}

usage_remove_gateway_host() {
    {
        cat <<EOF

NAME
       remove-gateway-host - Remove one or more hosts from the OTel gateway hosts list.

DESCRIPTION
       Remove one or more hostnames from the otelcol_gateway_hosts
       configuration property in Cloudera Manager. The property is updated
       with a single PUT containing the remaining comma-separated hostnames.
       Hosts not found in the current list are silently skipped.

SYNOPSIS

          remove-gateway-host
        --host <value>
        [--host <value> ...]
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --host (string)

          Hostname to remove from the gateway nodes list. Repeat this
          option to remove multiple hosts in a single call.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} remove-gateway-host --host ccycloud-2.example.com

       ${CMD} remove-gateway-host --host ccycloud-2.example.com \\
              --host ccycloud-3.example.com

EOF
    } | _pager
    exit 0
}

usage_add_exporter() {
    {
        cat <<EOF

NAME
       add-exporter - Add new exporter(s) from a YAML file.

DESCRIPTION
       Add one or more new exporter blocks from a YAML file into the
       otelcol_gateway_external_metrics_exporter property and register
       each exporter name in otelcol_gateway_metrics_service.

       Metrics are duplicated (fan-out) to every destination: the internal
       SaaS gateway path keeps round-robin load balancing across
       prometheusremotewrite/gateway-* exporters, while each customer
       exporter receives 100% of gateway metrics via routing/metrics-fanout.

       This command also maintains otelcol_connectors (adds
       routing/metrics-fanout and a dedicated roundrobin/gateway-lb connector)
       and wires the service pipelines: metrics/routing (consumes roundrobin,
       exports to the fan-out connector), metrics/gateway-lb (fan-out ->
       roundrobin/gateway-lb), and repoints metrics/gateway-<N> to receive
       from roundrobin/gateway-lb. This avoids a cyclic connector dependency.

       The following rules are enforced before any change is made:

       o  File must use spaces only — tab characters are not allowed.

       o  Root-level keys must be bare exporter names (key: with no value
          on the same line). Child properties must be indented below them.

       o  Exporter names starting with prometheusremotewrite/gateway-
          are reserved for system use and will be rejected.

       o  Exporter names that already exist in the config will be rejected.
          Use update-exporter to modify an existing exporter.

SYNOPSIS

          add-exporter
        --file <value>
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --file (string)

          Path to the YAML file containing one or more exporter block(s).
          Each root-level key in the file is treated as an exporter name.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} add-exporter --file /path/to/my-exporter.yaml

       ${CMD} add-exporter --file /path/to/my-exporter.yaml --debug

YAML FILE FORMAT
       Each root-level key is the exporter name. Child properties must be
       indented below it using spaces only.

          prometheusremotewrite/my-exporter:
            endpoint: "https://receive.example.com/api/v1/receive"
            retry_on_failure:
              enabled: true
              initial_interval: 10s
              max_interval: 40s
              max_elapsed_time: 300s
            auth:
              authenticator: cdpauth/gateway

EOF
    } | _pager
    exit 0
}

usage_update_exporter() {
    {
        cat <<EOF

NAME
       update-exporter - Update the configuration of an existing exporter.

DESCRIPTION
       Replace the YAML block of one or more existing exporters with updated
       content from a file. The exporter name(s) in the file must already
       exist in otelcol_gateway_external_metrics_exporter. The service
       pipeline is not changed since the exporter names remain the same.

       System exporters (names starting with prometheusremotewrite/gateway-)
       cannot be updated.

SYNOPSIS

          update-exporter
        --file <value>
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --file (string)

          Path to the YAML file containing updated exporter block(s). Each
          root-level key must match an exporter that already exists in CM.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} update-exporter --file /path/to/updated-exporter.yaml

YAML FILE FORMAT
       Same format as add-exporter. Root-level key must match an existing
       exporter name in CM.

          prometheusremotewrite/my-exporter:
            endpoint: "https://new-endpoint.example.com/api/v1/receive"
            auth:
              authenticator: cdpauth/gateway

EOF
    } | _pager
    exit 0
}

usage_remove_exporter() {
    {
        cat <<EOF

NAME
       remove-exporter - Remove one or more exporters from the OTel pipeline.

DESCRIPTION
       Remove one or more exporters from otelcol_gateway_external_metrics_exporter
       and delete their corresponding entries from otelcol_gateway_metrics_service.

       The exporter pipeline is also removed from the routing/metrics-fanout
       connector table in otelcol_connectors. The SaaS gateway path
       (metrics/gateway-lb and round-robin gateway exporters) is preserved.

       System exporters (names starting with prometheusremotewrite/gateway-)
       cannot be removed.

SYNOPSIS

          remove-exporter
        --exporter <value>
        [--exporter <value> ...]
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --exporter (string)

          Name of the exporter to remove. Repeat this option to remove
          multiple exporters in a single call.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} remove-exporter --exporter prometheusremotewrite/priv

       ${CMD} remove-exporter --exporter prometheusremotewrite/priv \\
              --exporter otlp/custom

EOF
    } | _pager
    exit 0
}

usage_list_processors() {
    {
        cat <<EOF

NAME
       list-processors - List all configured OTel processors.

DESCRIPTION
       Display all processor definitions from the otelcol_processors
       configuration property in Cloudera Manager, along with which
       customer pipeline(s) each processor is referenced in.

SYNOPSIS

          list-processors
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} list-processors

       ${CMD} list-processors --url http://myhost:7180

EOF
    } | _pager
    exit 0
}

usage_add_processor() {
    {
        cat <<EOF

NAME
       add-processor - Define a new processor or link an existing one to an exporter pipeline.

DESCRIPTION
       This command supports three modes of operation:

       Mode A — Define only (--file)
              Adds one or more processor block(s) to otelcol_processors.
              No pipeline is updated.

       Mode B — Define and link (--file + --exporter)
              Adds processor block(s) to otelcol_processors AND registers
              each processor in the specified exporter's pipeline
              (metrics/<exporter-name>). The exporter pipeline must already
              exist (run add-exporter first).

       Mode C — Link existing (--processor + --exporter)
              Links an already-defined processor to one or more exporter
              pipelines. No file is needed.

SYNOPSIS

          add-processor --file <value> [--exporter <value> ...]
          add-processor --processor <value> [--processor <value> ...] --exporter <value> [--exporter <value> ...]

OPTIONS
       --file (string)

          Path to the YAML file containing one or more processor block(s).
          Cannot be combined with --processor.

       --processor (string)

          Name of an already-defined processor to link to a pipeline.
          Requires at least one --exporter. Cannot be combined with --file.
          Repeat to link multiple processors in a single call.

       --exporter (string)

          Name of the exporter whose pipeline should include this processor.
          Repeat to link to multiple exporter pipelines.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       Mode A — Define processor only:

          ${CMD} add-processor --file /path/to/batch.yaml

       Mode B — Define and link to one or more exporters:

          ${CMD} add-processor --file /path/to/batch.yaml \\
                 --exporter prometheusremotewrite/priv \\
                 --exporter otlp/custom

       Mode C — Link an already-defined processor to a new exporter:

          ${CMD} add-processor --processor batch/customer \\
                 --exporter otlp/custom

YAML FILE FORMAT
       Each root-level key is the processor name.

          batch/customer:
            timeout: 5s
            send_batch_size: 1000

EOF
    } | _pager
    exit 0
}

usage_update_processor() {
    {
        cat <<EOF

NAME
       update-processor - Update the configuration of an existing processor.

DESCRIPTION
       Replace the YAML block of one or more existing processors with
       updated content from a file. The processor name(s) in the file must
       already exist in otelcol_processors. The service pipeline is
       unchanged since the processor names remain the same.

SYNOPSIS

          update-processor
        --file <value>
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --file (string)

          Path to the YAML file containing updated processor block(s). Each
          root-level key must match a processor that already exists in CM.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} update-processor --file /path/to/updated-batch.yaml

YAML FILE FORMAT
       Same format as add-processor. Root-level key must match an existing
       processor name in CM.

          batch/customer:
            timeout: 10s
            send_batch_size: 2000

EOF
    } | _pager
    exit 0
}

usage_remove_processor() {
    {
        cat <<EOF

NAME
       remove-processor - Remove or unlink a processor from pipelines.

DESCRIPTION
       This command supports two modes of operation:

       Mode A — Delete definition (--processor only)
              Removes the processor block from otelcol_processors AND
              removes its entry from every customer pipeline that references it.

       Mode B — Unlink from specific pipeline (--processor + --exporter)
              Removes the processor only from the specified exporter's
              pipeline. The processor definition in otelcol_processors is kept.

SYNOPSIS

          remove-processor --processor <value> [--processor <value> ...]
          remove-processor --processor <value> --exporter <value> [--exporter <value> ...]

OPTIONS
       --processor (string)

          Name of the processor to remove or unlink.
          Repeat to target multiple processors in a single call.

       --exporter (string)

          When provided, only unlinks the processor from this exporter's
          pipeline. The definition in otelcol_processors is kept.
          Repeat to unlink from multiple exporter pipelines.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       Mode A — Fully delete processor and remove from all pipelines:

          ${CMD} remove-processor --processor batch/customer

          ${CMD} remove-processor --processor batch/customer \\
                 --processor resourcedetection/custom

       Mode B — Unlink from one exporter's pipeline only (keep definition):

          ${CMD} remove-processor --processor batch/customer \\
                 --exporter otlp/custom \\
                 --exporter prometheusremotewrite/priv

EOF
    } | _pager
    exit 0
}

usage_list_extensions() {
    {
        cat <<EOF

NAME
       list-extensions - List all configured OTel extensions.

DESCRIPTION
       Display all extension definitions from the otelcol_extensions
       configuration property in Cloudera Manager.

SYNOPSIS

          list-extensions
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} list-extensions

       ${CMD} list-extensions --url http://myhost:7180

EOF
    } | _pager
    exit 0
}

usage_add_extension() {
    {
        cat <<EOF

NAME
       add-extension - Add one or more extension blocks from a YAML file.

DESCRIPTION
       Add extension blocks from a YAML file into the otelcol_extensions
       property. Extension names starting with cdpauth/thanos- are reserved
       for system use and will be rejected.

SYNOPSIS

          add-extension
        --file <value>
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --file (string)

          Path to the YAML file containing one or more extension block(s).
          Root-level keys are treated as extension names.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} add-extension --file /path/to/oauth2.yaml

YAML FILE FORMAT
       Each root-level key is the extension name.

          oauth2client/partner:
            client_id: "..."
            client_secret: "..."
            token_url: "https://idp.example/oauth2/token"

EOF
    } | _pager
    exit 0
}

usage_update_extension() {
    {
        cat <<EOF

NAME
       update-extension - Update the configuration of an existing extension.

DESCRIPTION
       Replace the YAML block of one or more existing extensions with
       updated content from a file. The extension name(s) in the file must
       already exist in otelcol_extensions.

SYNOPSIS

          update-extension
        --file <value>
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --file (string)

          Path to the YAML file containing updated extension block(s). Each
          root-level key must match an extension that already exists in CM.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} update-extension --file /path/to/updated-extension.yaml

YAML FILE FORMAT
       Same format as add-extension. Root-level key must match an existing
       extension name in CM.

          oauth2client/partner:
            client_id: "..."
            token_url: "https://idp.example/oauth2/token"

EOF
    } | _pager
    exit 0
}

usage_remove_extension() {
    {
        cat <<EOF

NAME
       remove-extension - Remove one or more extensions.

DESCRIPTION
       Remove named extension blocks from otelcol_extensions.

SYNOPSIS

          remove-extension
        --extension <value>
        [--extension <value> ...]
        [--url <value>]
        [--user <value>]
        [--pass <value>]
        [--debug]

OPTIONS
       --extension (string)

          Name of the extension to remove. Repeat to remove multiple
          extensions in a single call.

EOF
        _print_common_opts
        cat <<EOF
EXAMPLES
       ${CMD} remove-extension --extension oauth2client/partner

       ${CMD} remove-extension --extension oauth2client/partner \\
              --extension oauth2client/other

EOF
    } | _pager
    exit 0
}

# Fetch /api/v31/cm/allHosts/config into RAW_RESPONSE
fetch_global_config() {
    RAW_RESPONSE=$(curl -k -s -u "$CM_USER:$CM_PASS" \
        "$CM_BASE_URL/api/v31/cm/allHosts/config?view=full")
    if [[ -z "$RAW_RESPONSE" ]]; then
        echo "❌ Error: Empty response from Cloudera Manager."
        echo "   Check if '$CM_BASE_URL' is reachable and credentials are correct."
        exit 1
    fi
}

require_auth() {
    if [[ -z "$CM_USER" || -z "$CM_PASS" ]]; then
        echo "❌ Error: --user and --pass are required."
        exit 1
    fi
}

urlencode() {
    jq -rn --arg v "$1" '$v|@uri'
}

api_get() {
    local path="$1"
    local out
    out=$(curl -k -s -u "$CM_USER:$CM_PASS" "${CM_BASE_URL}/api/v31${path}")
    if [[ -z "$out" ]]; then
        echo "❌ Error: Empty response for GET ${path}"
        exit 1
    fi
    echo "$out"
}

get_config_value() {
    local param_name="$1"
    echo "$RAW_RESPONSE" | jq -r --arg T "$param_name" \
        '.items[] | select(.name == $T) | if .value != null then .value else .default end' 2>/dev/null
}

# Send a POST /api/v15/batch payload; prints HTTP code
send_batch() {
    local payload="$1"
    local tmp_file
    tmp_file=$(mktemp)
    local http_code
    http_code=$(curl -k -s -o "$tmp_file" -w "%{http_code}" -u "$CM_USER:$CM_PASS" \
        -X POST "$CM_BASE_URL/api/v15/batch" \
        -H "Content-Type: application/json" \
        -d "$payload")

    if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
        log "✅ Success! (HTTP $http_code)"
    else
        echo "❌ Failed. (HTTP $http_code)"
        cat "$tmp_file"
        rm -f "$tmp_file"
        exit 1
    fi
    rm -f "$tmp_file"
}

# Validate YAML indentation of a config file against the existing root indent
# Sets FILE_ROOT_INDENT on success; exits on error
# ---------------------------------------------------------------------------
# normalize_yaml_indentation FILE
#
# Auto-converts the file's indentation to 2 spaces per level:
#   - Tab-only indentation  → 2 spaces per tab level
#   - 4-space (or N-space)  → re-scaled to 2 spaces per level
#   - Already 2-space       → no change
#   - Mixed tabs+spaces     → error (ambiguous structure)
#   - Inconsistent spacing  → error
#
# Writes the normalized content to a temp file and sets the global
# NORMALIZED_CONFIG to its path.
# ---------------------------------------------------------------------------
normalize_yaml_indentation() {
    local file="$1"
    local content
    content=$(cat "$file")

    # ── Detect mixed tabs + spaces (leading whitespace) ──────────────────────
    local tab_lines space_lines
    tab_lines=$(printf '%s\n' "$content" | grep -c $'^\t' || true)
    space_lines=$(printf '%s\n' "$content" | grep -c '^  ' || true)
    if [[ "$tab_lines" -gt 0 && "$space_lines" -gt 0 ]]; then
        echo "❌ Error: Mixed tab and space indentation detected in '$file'."
        echo "   Please use spaces only (2 or 4 spaces per level)."
        exit 1
    fi

    # ── Convert pure-tab indentation → 2 spaces per tab ─────────────────────
    if [[ "$tab_lines" -gt 0 ]]; then
        log "ℹ️  Converting tab indentation → 2 spaces per level."
        content=$(printf '%s\n' "$content" | awk '{
            line = $0; tabs = 0
            while (substr(line, tabs+1, 1) == "\t") tabs++
            rest = substr(line, tabs+1)
            spaces = tabs * 2
            printf "%*s%s\n", spaces, "", rest
        }')
    fi

    # ── Detect minimum non-zero indentation unit ─────────────────────────────
    # Use awk for indent counting — portable across macOS (BSD) and Linux (GNU).
    # The sed+wc-c approach undercounts on macOS because BSD sed doesn't emit a
    # trailing newline when input has none (printf '%s' produces no newline).
    local min_indent=0
    while IFS= read -r line; do
        [[ -z "${line// }" ]] && continue
        local indent
        indent=$(printf '%s' "$line" | awk '{n=0; while(substr($0,n+1,1)==" ") n++; print n}')
        if [[ $indent -gt 0 ]]; then
            if [[ $min_indent -eq 0 || $indent -lt $min_indent ]]; then
                min_indent=$indent
            fi
        fi
    done <<< "$content"

    # ── No indentation found at all → flat YAML, cannot determine hierarchy ──
    if [[ $min_indent -eq 0 ]]; then
        echo "❌ Error: No indentation found in config file."
        echo "   The immediate child properties of each root key must be indented"
        echo "   with 2 spaces, 4 spaces, or tabs."
        echo ""
        echo "   Example (2-space):"
        echo "     prometheusremotewrite/my-exporter:"
        echo "       endpoint: \"https://...\""
        echo "       retry_on_failure:"
        echo "         enabled: true"
        exit 1
    fi

    # ── Already 2-space ───────────────────────────────────────────────────────
    if [[ $min_indent -eq 2 ]]; then
        NORMALIZED_CONFIG=$(mktemp /tmp/otelcli_norm.XXXXXX)
        printf '%s\n' "$content" > "$NORMALIZED_CONFIG"
        return
    fi

    # ── Validate all indentation is a consistent multiple of min_indent ───────
    local bad_line=""
    while IFS= read -r line; do
        [[ -z "${line// }" ]] && continue
        local indent
        indent=$(printf '%s' "$line" | awk '{n=0; while(substr($0,n+1,1)==" ") n++; print n}')
        if [[ $indent -gt 0 && $((indent % min_indent)) -ne 0 ]]; then
            bad_line="$line"
            break
        fi
    done <<< "$content"

    if [[ -n "$bad_line" ]]; then
        echo "❌ Error: Inconsistent indentation detected."
        echo "   Expected multiples of ${min_indent} space(s), but found: '${bad_line}'"
        echo "   Please fix the indentation and retry."
        exit 1
    fi

    # ── Re-scale to 2 spaces per level ───────────────────────────────────────
    log "ℹ️  Normalizing indentation from ${min_indent}-space → 2-space per level."
    content=$(printf '%s\n' "$content" | awk -v unit="$min_indent" '{
        line = $0; spaces = 0
        while (substr(line, spaces+1, 1) == " ") spaces++
        rest = substr(line, spaces+1)
        new_spaces = int(spaces / unit) * 2
        printf "%*s%s\n", new_spaces, "", rest
    }')

    NORMALIZED_CONFIG=$(mktemp /tmp/otelcli_norm.XXXXXX)
    printf '%s\n' "$content" > "$NORMALIZED_CONFIG"
}

validate_yaml_indentation() {
    local file="$1"
    local existing_root_indent="$2"

    log "🔎 Normalizing and validating YAML indentation..."

    # Normalize first (tabs → 2-space, 4-space → 2-space, etc.)
    normalize_yaml_indentation "$file"

    # All further validation works on the normalized copy
    local norm="$NORMALIZED_CONFIG"

    if grep -q ' $' "$norm"; then
        log "⚠️  Warning: Config file has trailing spaces on some lines."
    fi

    FILE_ROOT_INDENT=$(grep -m1 '[^ ]' "$norm" | sed 's/^\( *\).*/\1/' | wc -c)
    FILE_ROOT_INDENT=$((FILE_ROOT_INDENT - 1))

    if [[ "$FILE_ROOT_INDENT" -ne "$existing_root_indent" ]]; then
        echo "❌ Error: Indentation mismatch."
        echo "   Existing config uses $existing_root_indent space(s) at root level."
        echo "   Your file uses $FILE_ROOT_INDENT space(s) at root level (after normalization)."
        exit 1
    fi

    local indent_errors=0
    while IFS= read -r line; do
        [[ -z "${line// }" ]] && continue
        local line_indent stripped
        line_indent=$(echo "$line" | sed 's/^\( *\).*/\1/' | wc -c)
        line_indent=$((line_indent - 1))
        stripped=$(echo "$line" | sed 's/^[[:space:]]*//')

        if [[ "$line_indent" -lt "$FILE_ROOT_INDENT" ]]; then
            echo "❌ Error: Line under-indented: '$line'"
            indent_errors=$((indent_errors + 1))
        elif [[ "$line_indent" -eq "$FILE_ROOT_INDENT" ]]; then
            # Root-level line must be a bare key (exporter/processor name) — no value after ':'
            if ! echo "$stripped" | grep -qE '^[^[:space:]]+:[[:space:]]*$'; then
                echo "❌ Error: Child property at root indentation level (must be indented under key name):"
                echo "         '$line'"
                indent_errors=$((indent_errors + 1))
            fi
        fi
    done < "$norm"

    if [[ "$indent_errors" -gt 0 ]]; then
        echo "❌ $indent_errors indentation error(s) found. Please fix and retry."
        exit 1
    fi

    # Detect siblings with inconsistent indentation: if a line has a scalar value
    # (key: value) and the next line is MORE indented, the next line would be
    # parsed as a child of a scalar-valued key — which is invalid YAML structure.
    # Example of what this catches:
    #   name:
    #    child1: some_value   <- scalar, indent 1
    #     child2: true        <- indent 2 > 1: child2 appears nested under child1
    #
    # YAML sequences are handled by computing an "effective indent" that skips
    # the leading "- " list marker(s), so sibling keys of a list-item map (which
    # align after the dash) are not mistaken for children of a scalar. Example
    # of valid YAML that must NOT be flagged:
    #   actions:
    #     - key: environment   <- effective indent points at "key"
    #       value: testing     <- sibling key of the same list-item map
    local _pyi=-1 _pys=false _pyl=""
    while IFS= read -r line; do
        [[ -z "${line// }" ]] && continue
        local _ci _sv _eff _content
        _ci=$(echo "$line" | awk '{n=0; while(substr($0,n+1,1)==" ") n++; print n}')
        _sv=$(echo "$line" | sed 's/^[[:space:]]*//')

        # Strip leading "- " sequence marker(s) and advance the effective indent
        # to the column where the map content actually begins.
        _content="$_sv"
        _eff="$_ci"
        while [[ "$_content" == "- "* || "$_content" == "-" ]]; do
            if [[ "$_content" == "-" ]]; then
                _content=""
                _eff=$((_eff + 1))
            else
                _content="${_content#- }"
                _eff=$((_eff + 2))
            fi
        done

        if [[ "$_pys" == true && "$_eff" -gt "$_pyi" ]]; then
            echo "❌ Error: Indentation inconsistency detected."
            echo "   '${_pyl}' has a value but the next line is more indented:"
            echo "   '${line}'"
            echo "   Properties at the same level must use consistent indentation."
            exit 1
        fi
        # Has scalar value: key: value — but not block scalars (|, >) or bare keys (key:)
        if echo "$_content" | grep -qE '^[^:]+:[[:space:]]+[^|>[:space:]]'; then
            _pys=true
        else
            _pys=false
        fi
        _pyi="$_eff"
        _pyl="$line"
    done < "$norm"

    log "✅ YAML indentation valid (normalized to 2-space)."
}

# Parse all exporter names from a config string — prints one name per line to stdout.
# Bash 3-compatible (no nameref). Callers capture with a while read loop.
parse_exporter_names() {
    local config="$1"
    local root_indent="$2"

    while IFS= read -r line; do
        [[ -z "${line// }" ]] && continue
        local line_indent
        line_indent=$(echo "$line" | sed 's/^\( *\).*/\1/' | wc -c)
        line_indent=$((line_indent - 1))
        if [[ "$line_indent" -eq "$root_indent" ]] && echo "$line" | grep -q ':'; then
            local name
            name=$(echo "$line" | sed 's/:[[:space:]]*$//' | sed 's/^[[:space:]]*//')
            [[ -n "$name" ]] && echo "$name"
        fi
    done <<< "$config"
}

# Remove a named YAML block by name from a config string
remove_named_block() {
    local config="$1"
    local target_name="$2"
    local root_indent="$3"

    echo "$config" | awk \
        -v target="$target_name" \
        -v rindent="$root_indent" \
        'BEGIN { skip=0 }
        {
            indent = 0
            line = $0
            while (substr(line, indent+1, 1) == " ") indent++
            if (indent == rindent && $0 !~ /^[[:space:]]*$/) {
                name = $0
                gsub(/^[[:space:]]+/, "", name)
                gsub(/:[[:space:]]*$/, "", name)
                skip = (name == target) ? 1 : 0
            }
            if (!skip) print $0
        }'
}

# Remove an exporter name from the service pipeline exporters list
remove_from_service() {
    local service_val="$1"
    local name="$2"
    local escaped
    escaped=$(printf '%s\n' "$name" | sed 's/[[\.*^$()+?{|]/\\&/g')
    echo "$service_val" \
        | sed "s|, ${escaped}||g" \
        | sed "s|${escaped}, ||g" \
        | sed "s|\[${escaped}\]|\[\]|g"
}

is_system_exporter() {
    echo "$1" | grep -q '^prometheusremotewrite/gateway-'
}

is_system_extension() {
    echo "$1" | grep -q '^cdpauth/thanos-'
}

# For configs that have a top-level wrapper key (e.g. "processors:"),
# return the indentation level of the first named child key beneath it.
# This is needed because otelcol_processors starts with "processors:\n  name:"
# so the real names live at indent 2, not indent 0.
get_child_indent() {
    local config="$1"
    local wrapper_key="$2"
    echo "$config" | awk -v key="$wrapper_key" '
        $0 ~ "^[[:space:]]*" key "[[:space:]]*$" { found=1; next }
        found && /^[[:space:]]+[^[:space:]]/ {
            spaces = 0
            line = $0
            while (substr(line, spaces+1, 1) == " ") spaces++
            if (substr(line, spaces+1, 1) == "#") next
            print spaces
            exit
        }
    '
}

# Indent every non-empty line of a block by N spaces.
indent_block() {
    local spaces="$1"
    local block="$2"
    local pad="" i=0
    while [[ $i -lt $spaces ]]; do pad="${pad} "; i=$((i+1)); done
    while IFS= read -r line; do
        if [[ -z "$line" ]]; then echo ""; else echo "${pad}${line}"; fi
    done <<< "$block"
}

# Derive OTel pipeline name from an exporter name.
# prometheusremotewrite/priv  →  metrics/prometheusremotewrite-priv
exporter_to_pipeline() {
    echo "metrics/$(echo "$1" | tr '/' '-')"
}

# Check if a named pipeline block exists in the service YAML.
pipeline_exists() {
    local service_val="$1"
    local pipeline_name="$2"
    echo "$service_val" | grep -qF "${pipeline_name}:"
}

# Return 0 if VALUE is an element of the bracketed list FIELD (e.g. processors)
# within the named pipeline block, else 1. Scoped to that single pipeline so a
# link in a different pipeline does not produce a false positive.
pipeline_field_contains() {
    local service_val="$1"
    local pipeline_name="$2"
    local field="$3"
    local value="$4"
    local list
    list=$(echo "$service_val" | awk \
        -v pl="${pipeline_name}:" \
        -v field="${field}:" \
        'BEGIN { in_p=0 }
         index($0, pl) && in_p==0 { in_p=1; next }
         in_p && index($0, field) {
             if (match($0, /\[.*\]/)) { print substr($0, RSTART+1, RLENGTH-2) }
             in_p=0
         }
         in_p && /exporters:/ { in_p=0 }')
    local IFS=','
    local item
    for item in $list; do
        item="${item#"${item%%[![:space:]]*}"}"
        item="${item%"${item##*[![:space:]]}"}"
        [[ "$item" == "$value" ]] && return 0
    done
    return 1
}

# Add a value to a bracketed list field inside a named pipeline block.
# Handles both empty ( [] ) and non-empty ( [a, b] ) lists.
# If the field key does not exist yet in the pipeline, it is inserted
# before the exporters: line (used when adding the first processor).
add_to_pipeline_field() {
    local service_val="$1"
    local pipeline_name="$2"
    local field="$3"
    local name="$4"
    local safe_name
    safe_name=$(printf '%s' "$name" | sed 's/[&\]/\\&/g')
    echo "$service_val" | awk \
        -v pipeline="${pipeline_name}:" \
        -v field="$field:" \
        -v name="$safe_name" \
        'BEGIN { in_pipe=0; found_field=0 }
        {
            if (index($0, pipeline) && in_pipe==0) { in_pipe=1 }
            if (in_pipe) {
                trimmed = $0
                gsub(/^[[:space:]]+/, "", trimmed)
                # Found the target field — append the name to its list
                if (index(trimmed, field) == 1) {
                    if ($0 ~ /\[\]/) {
                        sub(/\[\]/, "[" name "]")
                    } else {
                        sub(/\]$/, ", " name "]")
                    }
                    found_field=1
                    in_pipe=0
                }
                # Reached exporters: without having seen the field — insert it first
                if (!found_field && index(trimmed, "exporters:") == 1) {
                    indent_str = $0
                    sub(/[^ ].*/, "", indent_str)
                    print indent_str field " [" name "]"
                    in_pipe=0
                }
            }
            print
        }'
}

# Remove a value from a bracketed list field inside a named pipeline block.
remove_from_pipeline_field() {
    local service_val="$1"
    local pipeline_name="$2"
    local field="$3"
    local name="$4"
    local escaped
    escaped=$(printf '%s\n' "$name" | sed 's/[[\.*^$()+?{|]/\\&/g')
    echo "$service_val" | awk \
        -v pipeline="${pipeline_name}:" \
        -v field="$field:" \
        -v esc="$escaped" \
        'BEGIN { in_pipe=0 }
        {
            if (index($0, pipeline) && in_pipe==0) { in_pipe=1 }
            if (in_pipe) {
                trimmed = $0
                gsub(/^[[:space:]]+/, "", trimmed)
                if (index(trimmed, field) == 1) {
                    gsub(", " esc, "")
                    gsub(esc ", ", "")
                    gsub("\\[" esc "\\]", "[]")
                    in_pipe=0
                }
            }
            print
        }'
}

# Default otelcol_connectors when CM has no value configured.
default_connectors_yaml() {
    cat <<'EOF'
connectors:
  roundrobin:
  loadbalancing:
EOF
}

# Return connectors YAML, using CM default when unset.
get_connectors_or_default() {
    local connectors_val="$1"
    if [[ -z "$connectors_val" || "$connectors_val" == "null" ]]; then
        default_connectors_yaml
    else
        printf '%s\n' "$connectors_val"
    fi
}

# Ensure a bare connector key (e.g. roundrobin/gateway-lb:) exists under
# the connectors: header. Idempotent; preserves existing keys/blocks.
ensure_connector_key() {
    local connectors_val="$1"
    local key="$2"
    if echo "$connectors_val" | grep -qE "^[[:space:]]*${key}:[[:space:]]*$"; then
        printf '%s\n' "$connectors_val"
        return
    fi
    local indent child
    indent=$(echo "$connectors_val" | grep -m1 '[^ ]' | sed 's/^\( *\).*/\1/')
    child="${indent}  "
    echo "$connectors_val" | awk -v key="${child}${key}:" '
        { print }
        /^[[:space:]]*connectors:[[:space:]]*$/ && !done { print key; done=1 }
    '
}

# Ensure routing/metrics-fanout and the roundrobin/gateway-lb connector exist,
# seeding the gateway load-balance target in the fan-out table.
ensure_fanout_connector() {
    local connectors_val
    connectors_val=$(get_connectors_or_default "$1")
    connectors_val=$(ensure_connector_key "$connectors_val" "$GATEWAY_LB_CONNECTOR")

    if echo "$connectors_val" | grep -qF "${FANOUT_CONNECTOR}:"; then
        fanout_add_pipeline "$connectors_val" "$FANOUT_GATEWAY_TARGET"
        return
    fi

    local indent child grand great pipe_key pipe_item
    indent=$(echo "$connectors_val" | grep -m1 '[^ ]' | sed 's/^\( *\).*/\1/')
    child="${indent}  "
    grand="${child}  "
    great="${grand}  "
    pipe_key="${great}  "
    pipe_item="${pipe_key}  "

    printf '%s\n%s%s:\n%serror_mode: ignore\n%stable:\n%s- condition: "true"\n%spipelines:\n%s- %s\n' \
        "$connectors_val" \
        "$child" "$FANOUT_CONNECTOR" \
        "$grand" \
        "$grand" \
        "$great" \
        "$pipe_key" \
        "$pipe_item" "$FANOUT_GATEWAY_TARGET"
}

# Add a pipeline name to the condition:"true" fan-out table (idempotent).
fanout_add_pipeline() {
    local connectors_val="$1"
    local pipeline_name="$2"

    echo "$connectors_val" | awk \
        -v fanout="${FANOUT_CONNECTOR}:" \
        -v pipeline="$pipeline_name" \
        'BEGIN {
            in_fanout=0
            in_table=0
            in_entry=0
            in_pipelines=0
            found=0
            added=0
            fanout_indent=0
            pipe_indent=0
        }
        {
            line = $0
            trimmed = line
            sub(/^[[:space:]]+/, "", trimmed)
            spaces = length(line) - length(trimmed)

            if (trimmed == fanout) {
                in_fanout=1
                fanout_indent=spaces
                in_table=0
                in_entry=0
                in_pipelines=0
            } else if (in_fanout && spaces <= fanout_indent && trimmed != "" && trimmed !~ /^#/) {
                if (in_pipelines && !added && !found) {
                    printf "%*s- %s\n", pipe_indent, "", pipeline
                    added=1
                }
                in_fanout=0
                in_table=0
                in_entry=0
                in_pipelines=0
            }

            if (in_fanout && trimmed == "table:") {
                in_table=1
            }
            if (in_fanout && in_table && trimmed ~ /^- condition:/ && trimmed ~ /true/) {
                in_entry=1
            }
            if (in_fanout && in_entry && trimmed ~ /^pipelines:/) {
                in_pipelines=1
                pipe_indent=spaces+2
            }
            if (in_pipelines && trimmed ~ /^- /) {
                item = trimmed
                sub(/^- /, "", item)
                if (item == pipeline) found=1
            }
            if (in_pipelines && spaces <= fanout_indent+4 && trimmed !~ /^- / && trimmed !~ /^pipelines:/ && trimmed != "") {
                if (!added && !found) {
                    printf "%*s- %s\n", pipe_indent, "", pipeline
                    added=1
                }
                in_pipelines=0
            }

            print line
        }
        END {
            if (in_pipelines && !added && !found) {
                printf "%*s- %s\n", pipe_indent, "", pipeline
            }
        }'
}

# Remove a pipeline from the fan-out table; gateway load-balance target is retained.
fanout_remove_pipeline() {
    local connectors_val="$1"
    local pipeline_name="$2"

    echo "$connectors_val" | awk \
        -v fanout="${FANOUT_CONNECTOR}:" \
        -v pipeline="$pipeline_name" \
        -v keep="$FANOUT_GATEWAY_TARGET" \
        'BEGIN { in_fanout=0; fanout_indent=0 }
        {
            line = $0
            trimmed = line
            sub(/^[[:space:]]+/, "", trimmed)
            spaces = length(line) - length(trimmed)

            if (trimmed == fanout) {
                in_fanout=1
                fanout_indent=spaces
                print line
                next
            }
            if (in_fanout && spaces <= fanout_indent && trimmed != "" && trimmed !~ /^#/) {
                in_fanout=0
            }
            if (in_fanout && trimmed ~ /^- /) {
                item = trimmed
                sub(/^- /, "", item)
                if (item == pipeline && item != keep) next
            }
            print line
        }'
}

# Append a service pipeline with explicit receivers/exporters lists.
append_service_pipeline() {
    local service_val="$1"
    local pipeline_name="$2"
    local receivers="$3"
    local exporters="$4"
    local indent
    indent=$(echo "$service_val" | grep -m1 '[^ ]' | sed 's/^\( *\).*/\1/')
    printf '%s\n%s%s:\n%s  receivers: %s\n%s  exporters: %s' \
        "$service_val" \
        "$indent" "$pipeline_name" \
        "$indent" "$receivers" \
        "$indent" "$exporters"
}

# Ensure metrics/routing exists: consumes the primary roundrobin connector
# (fed by the default ingest metrics/gateway pipeline) and fans out via
# routing/metrics-fanout. The default ingest pipeline is left untouched.
ensure_routing_pipeline() {
    local service_val="$1"
    if pipeline_exists "$service_val" "$ROUTING_PIPELINE"; then
        echo "$service_val"
        return
    fi
    log "ℹ️  Created pipeline '$ROUTING_PIPELINE' feeding $FANOUT_CONNECTOR."
    append_service_pipeline "$service_val" "$ROUTING_PIPELINE" "[roundrobin]" "[$FANOUT_CONNECTOR]"
}

# Repoint the SaaS gateway pipelines (metrics/gateway-<suffix>) from the primary
# roundrobin connector to the dedicated roundrobin/gateway-lb connector so the
# fan-out path (roundrobin -> routing -> fanout -> gateway-lb) is acyclic.
# Matches any metrics/gateway-<suffix> (e.g. the CM placeholder
# metrics/gateway-<rr_connector_count> or expanded metrics/gateway-1) whose
# receivers are exactly [roundrobin]. The ingest (metrics/gateway),
# metrics/gateway-lb (receivers [routing/metrics-fanout]), and metrics/routing
# pipelines are left untouched.
repoint_gateway_pipelines_to_lb() {
    local service_val="$1"
    echo "$service_val" | awk \
        -v newrecv="[${GATEWAY_LB_CONNECTOR}]" '
        BEGIN { in_gw=0 }
        {
            line = $0
            trimmed = line
            sub(/^[[:space:]]+/, "", trimmed)

            if (trimmed ~ /^metrics\/gateway-.+:$/) {
                in_gw=1
                print line
                next
            } else if (trimmed ~ /^[a-z][a-z0-9]*\/[^:]+:$/) {
                in_gw=0
            }

            if (in_gw && trimmed ~ /^receivers:[[:space:]]*\[roundrobin\][[:space:]]*$/) {
                indent = line
                sub(/[^ ].*/, "", indent)
                print indent "receivers: " newrecv
                next
            }
            print line
        }'
}

# Ensure the intermediate SaaS load-balance pipeline exists. It receives the
# duplicated stream from routing/metrics-fanout and load-balances across the
# gateway exporters via the dedicated roundrobin/gateway-lb connector.
ensure_gateway_lb_pipeline() {
    local service_val="$1"
    if pipeline_exists "$service_val" "$GATEWAY_LB_PIPELINE"; then
        echo "$service_val"
        return
    fi
    log "ℹ️  Created pipeline '$GATEWAY_LB_PIPELINE' for SaaS round-robin path."
    append_service_pipeline "$service_val" "$GATEWAY_LB_PIPELINE" "$CUSTOMER_PIPELINE_RECEIVER" "[${GATEWAY_LB_CONNECTOR}]"
}

# Wire fan-out routing infrastructure in the service config.
ensure_fanout_service_wiring() {
    local service_val="$1"
    service_val=$(ensure_routing_pipeline "$service_val")
    service_val=$(repoint_gateway_pipelines_to_lb "$service_val")
    service_val=$(ensure_gateway_lb_pipeline "$service_val")
    echo "$service_val"
}

# Extract the receivers list from the first system pipeline in the service YAML.
# Falls back to an empty list if nothing can be detected.
get_system_receiver() {
    local service_val="$1"
    echo "$service_val" | awk '
        /metrics\/gateway-/ { in_sys=1; next }
        in_sys && /receivers:/ {
            match($0, /\[.*\]/)
            if (RSTART > 0) { print substr($0, RSTART, RLENGTH); exit }
        }
        in_sys && /^[[:space:]]*[a-z]/ && !/receivers:/ && !/processors:/ && !/exporters:/ { in_sys=0 }
    '
}

# Append a new customer pipeline block to the service YAML.
# Customer pipelines receive duplicated metrics from routing/metrics-fanout.
append_customer_pipeline() {
    local service_val="$1"
    local pipeline_name="$2"
    local exporter_name="$3"
    local indent
    indent=$(echo "$service_val" | grep -m1 '[^ ]' | sed 's/^\( *\).*/\1/')
    printf '%s\n%s%s:\n%s  receivers: %s\n%s  exporters: [%s]' \
        "$service_val" \
        "$indent" "$pipeline_name" \
        "$indent" "$CUSTOMER_PIPELINE_RECEIVER" \
        "$indent" "$exporter_name"
}

# Remove an entire named pipeline block from the service YAML.
remove_customer_pipeline() {
    local service_val="$1"
    local pipeline_name="$2"
    local indent
    indent=$(echo "$service_val" | grep -m1 '[^ ]' | sed 's/^\( *\).*/\1/')
    local indent_len=${#indent}
    echo "$service_val" | awk \
        -v pipeline="${pipeline_name}:" \
        -v ilen="$indent_len" \
        'BEGIN { skip=0 }
        {
            # Count leading spaces
            line = $0; spaces = 0
            while (substr(line, spaces+1, 1) == " ") spaces++
            trimmed = substr(line, spaces+1)

            if (trimmed == pipeline) { skip=1; next }
            if (skip) {
                # Stop skipping when we hit another key at the same indent level
                if (spaces == ilen && trimmed != "" && trimmed !~ /^#/) {
                    skip=0
                } else {
                    next
                }
            }
            print
        }'
}

# ==============================================================================
# PARSE COMMAND & OPTIONS
# ==============================================================================

# Show per-command help, falling back to main help for unknown commands
show_command_help() {
    case "$1" in
        get-gateway-hosts)   usage_get_gateway_hosts ;;
        list-hosts)          usage_list_hosts ;;
        get-metric-configs)  usage_get_metric_configs ;;
        list-exporters)      usage_list_exporters ;;
        add-gateway-host)    usage_add_gateway_host ;;
        remove-gateway-host) usage_remove_gateway_host ;;
        add-exporter)        usage_add_exporter ;;
        update-exporter)     usage_update_exporter ;;
        remove-exporter)     usage_remove_exporter ;;
        list-processors)     usage_list_processors ;;
        add-processor)       usage_add_processor ;;
        update-processor)    usage_update_processor ;;
        remove-processor)    usage_remove_processor ;;
        list-extensions)     usage_list_extensions ;;
        add-extension)       usage_add_extension ;;
        update-extension)    usage_update_extension ;;
        remove-extension)    usage_remove_extension ;;
        *)
            echo ""
            echo "❌ Unknown command: '$1'"
            echo ""
            echo "Run '$(basename "$0") --help' for the list of available commands."
            echo ""
            exit 1
            ;;
    esac
}

# ==============================================================================
# EXECUTE COMMAND
# ==============================================================================

# send_config_update MESSAGE name1 value1 [name2 value2 ...]
# Sends a single PUT to /cm/allHosts/config with all name/value pairs in one batch request.
# Params whose value is a multi-line YAML snippet. The CM agent concatenates
# these snippets when generating the collector config, so each must end with a
# trailing newline. Command substitution ($(...)) strips trailing newlines from
# captured values, so we restore a single trailing newline before writing to
# avoid gluing the next snippet onto the last line of these params.
is_yaml_block_param() {
    case "$1" in
        "$PARAM_EXPORTER"|"$PARAM_PROCESSOR"|"$PARAM_SERVICE"|"$PARAM_EXTENSION"|"$PARAM_CONNECTORS")
            return 0 ;;
        *)
            return 1 ;;
    esac
}

send_config_update() {
    local message="$1"
    shift
    local items_json="[]"
    while [[ $# -ge 2 ]]; do
        local _name="$1" _value="$2"
        if is_yaml_block_param "$_name" && [[ -n "$_value" && "$_value" != *$'\n' ]]; then
            _value="${_value}"$'\n'
        fi
        items_json=$(echo "$items_json" | jq --arg n "$_name" --arg v "$_value" '. + [{name:$n,value:$v}]')
        shift 2
    done
    local body payload
    body=$(jq -n --argjson items "$items_json" '{items:$items}')
    payload=$(jq -n \
        --arg url "/api/v31/cm/allHosts/config?message=${message}" \
        --argjson body "$body" \
        '{items:[{method:"PUT",url:$url,body:$body,contentType:"application/json"}]}')
    send_batch "$payload"
}

require_config_file() {
    local cmd_name="$1"
    if [[ -z "$CONFIG_FILE" ]]; then
        echo "❌ Error: --file is required."
        echo "   Usage: $0 ${cmd_name} --file <path>"
        exit 1
    fi
    if [[ ! -f "$CONFIG_FILE" ]]; then
        echo "❌ Error: File '$CONFIG_FILE' not found."
        exit 1
    fi
}

get_exporter_root_indent() {
    local exporter_val="$1"
    local indent
    indent=$(echo "$exporter_val" | grep -m1 '[^ ]' | sed 's/^\( *\).*/\1/' | wc -c)
    echo $((indent - 1))
}

parse_names_from_normalized_config() {
    local root_indent="$1"
    parse_exporter_names "$(cat "$NORMALIZED_CONFIG")" "$root_indent"
}

handle_get_gateway_hosts() {
    [[ "$SHOW_HELP" == true ]] && usage_get_gateway_hosts
    fetch_global_config
    CURR_GATEWAY_HOSTS=$(get_config_value "otelcol_gateway_hosts")
    echo ""
    echo "otelcol_gateway_hosts:"
    if [[ -z "$CURR_GATEWAY_HOSTS" || "$CURR_GATEWAY_HOSTS" == "null" ]]; then
        echo "  <none configured>"
    else
        echo "$CURR_GATEWAY_HOSTS" | tr ',' '\n' | while IFS= read -r h; do
            echo "  - $h"
        done
    fi
    echo ""
}

handle_list_hosts() {
    [[ "$SHOW_HELP" == true ]] && usage_list_hosts
    fetch_global_config
    CURR_GATEWAY_HOSTS=$(get_config_value "otelcol_gateway_hosts")

    ALL_HOSTS_RESPONSE=$(curl -k -s -u "$CM_USER:$CM_PASS" "$CM_BASE_URL/api/v40/hosts")
    if [[ -z "$ALL_HOSTS_RESPONSE" ]]; then
        echo "❌ Error: Could not fetch hosts list."
        exit 1
    fi

    echo ""
    printf "%-50s %-18s %s\n" "HOSTNAME" "IP" "GATEWAY HOST"
    printf "%-50s %-18s %s\n" "--------" "--" "------------"
    while IFS= read -r entry; do
        hostname=$(echo "$entry" | jq -r '.hostname')
        ip=$(echo "$entry" | jq -r '.ipAddress')
        if echo "$CURR_GATEWAY_HOSTS" | grep -qF "$hostname"; then
            status="✅ yes"
        else
            status="  no"
        fi
        printf "%-50s %-18s %s\n" "$hostname" "$ip" "$status"
    done < <(echo "$ALL_HOSTS_RESPONSE" | jq -c '.items[]')
    echo ""
}

handle_list_exporters() {
    [[ "$SHOW_HELP" == true ]] && usage_list_exporters
    fetch_global_config
    CURR_VAL_EXPORTER=$(get_config_value "$PARAM_EXPORTER")

    if [[ -z "$CURR_VAL_EXPORTER" || "$CURR_VAL_EXPORTER" == "null" ]]; then
        log "⚠️  No exporter config found."
        exit 0
    fi

    EXISTING_ROOT_INDENT=$(get_exporter_root_indent "$CURR_VAL_EXPORTER")

    ALL_NAMES=()
    while IFS= read -r name; do
        ALL_NAMES+=("$name")
    done < <(parse_exporter_names "$CURR_VAL_EXPORTER" "$EXISTING_ROOT_INDENT")

    echo ""
    printf "%-55s %s\n" "EXPORTER NAME" "TYPE"
    printf "%-55s %s\n" "-------------" "----"
    for name in "${ALL_NAMES[@]}"; do
        if is_system_exporter "$name"; then
            printf "%-55s %s\n" "$name" "system (read-only)"
        else
            printf "%-55s %s\n" "$name" "customer"
        fi
    done
    echo ""
    echo "Total: ${#ALL_NAMES[@]} exporter(s)"
    echo ""
}

handle_get_metric_configs() {
    [[ "$SHOW_HELP" == true ]] && usage_get_metric_configs
    fetch_global_config
    CURR_VAL_EXPORTER=$(get_config_value "$PARAM_EXPORTER")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")
    CURR_VAL_CONNECTORS=$(get_config_value "$PARAM_CONNECTORS")
    echo ""
    echo "=== $PARAM_EXPORTER ==="
    echo "${CURR_VAL_EXPORTER:-<not configured>}"
    echo ""
    echo "=== $PARAM_SERVICE ==="
    echo "${CURR_VAL_SERVICE:-<not configured>}"
    echo ""
    echo "=== $PARAM_CONNECTORS ==="
    echo "$(get_connectors_or_default "$CURR_VAL_CONNECTORS")"
    echo ""
}

handle_add_gateway_host() {
    [[ "$SHOW_HELP" == true ]] && usage_add_gateway_host
    if [[ ${#HOSTS[@]} -eq 0 ]]; then
        echo "❌ Error: At least one --host is required."
        echo "   Usage: $0 add-gateway-host --host <hostname> [--host <hostname> ...]"
        exit 1
    fi

    fetch_global_config
    CURR_GATEWAY_HOSTS=$(get_config_value "otelcol_gateway_hosts")

    ALREADY_PRESENT=()
    NEW_HOSTS=()
    for host in "${HOSTS[@]}"; do
        if echo "$CURR_GATEWAY_HOSTS" | grep -qF "$host"; then
            ALREADY_PRESENT+=("$host")
        else
            NEW_HOSTS+=("$host")
        fi
    done

    [[ ${#ALREADY_PRESENT[@]} -gt 0 ]] && log "⚠️  Already present (skipping): ${ALREADY_PRESENT[*]}"

    if [[ ${#NEW_HOSTS[@]} -eq 0 ]]; then
        log "⚠️  No changes needed. All specified hosts are already configured."
        exit 0
    fi

    ADDED=$(IFS=','; echo "${NEW_HOSTS[*]}")
    if [[ -n "$CURR_GATEWAY_HOSTS" && "$CURR_GATEWAY_HOSTS" != "null" ]]; then
        NEW_GATEWAY_HOSTS="$CURR_GATEWAY_HOSTS,$ADDED"
    else
        NEW_GATEWAY_HOSTS="$ADDED"
    fi

    log "⬆️  Adding: $ADDED"
    log "ℹ️  New otelcol_gateway_hosts: $NEW_GATEWAY_HOSTS"

    send_config_update "Modified%20OpenTelemetry%20Collector%20Gateway%20Hosts" "otelcol_gateway_hosts" "$NEW_GATEWAY_HOSTS"
}

handle_remove_gateway_host() {
    [[ "$SHOW_HELP" == true ]] && usage_remove_gateway_host
    if [[ ${#HOSTS[@]} -eq 0 ]]; then
        echo "❌ Error: At least one --host is required."
        echo "   Usage: $0 remove-gateway-host --host <hostname> [--host <hostname> ...]"
        exit 1
    fi

    fetch_global_config
    CURR_GATEWAY_HOSTS=$(get_config_value "otelcol_gateway_hosts")

    if [[ -z "$CURR_GATEWAY_HOSTS" || "$CURR_GATEWAY_HOSTS" == "null" ]]; then
        log "⚠️  No gateway hosts configured. Nothing to remove."
        exit 0
    fi

    NOT_FOUND=()
    NEW_GATEWAY_HOSTS="$CURR_GATEWAY_HOSTS"
    for host in "${HOSTS[@]}"; do
        if ! echo "$CURR_GATEWAY_HOSTS" | grep -qF "$host"; then
            NOT_FOUND+=("$host")
        else
            NEW_GATEWAY_HOSTS=$(echo "$NEW_GATEWAY_HOSTS" | tr ',' '\n' | grep -vF "$host" | paste -sd ',' -)
        fi
    done

    [[ ${#NOT_FOUND[@]} -gt 0 ]] && log "⚠️  Not found (skipping): ${NOT_FOUND[*]}"

    REMOVED=$(IFS=','; echo "${HOSTS[*]}")
    log "⬆️  Removing: $REMOVED"
    log "ℹ️  New otelcol_gateway_hosts: ${NEW_GATEWAY_HOSTS:-<empty>}"

    send_config_update "Modified%20OpenTelemetry%20Collector%20Gateway%20Hosts" "otelcol_gateway_hosts" "$NEW_GATEWAY_HOSTS"
}

handle_add_exporter() {
    [[ "$SHOW_HELP" == true ]] && usage_add_exporter
    require_config_file "add-exporter"

    fetch_global_config
    CURR_VAL_EXPORTER=$(get_config_value "$PARAM_EXPORTER")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")
    CURR_VAL_CONNECTORS=$(get_config_value "$PARAM_CONNECTORS")

    if [[ -z "$CURR_VAL_EXPORTER" || "$CURR_VAL_EXPORTER" == "null" ]]; then
        echo "❌ Error: '$PARAM_EXPORTER' not found."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_exporter_root_indent "$CURR_VAL_EXPORTER")
    validate_yaml_indentation "$CONFIG_FILE" 0  # user file always has root keys at indent 0

    FILE_EXPORTER_NAMES=()
    while IFS= read -r name; do
        FILE_EXPORTER_NAMES+=("$name")
    done < <(parse_names_from_normalized_config 0)

    if [[ ${#FILE_EXPORTER_NAMES[@]} -eq 0 ]]; then
        echo "❌ Error: Could not parse any exporter names from the config file."
        exit 1
    fi
    log "ℹ️  Found ${#FILE_EXPORTER_NAMES[@]} exporter(s): ${FILE_EXPORTER_NAMES[*]}"

    DUPLICATE_NAMES=()
    SYSTEM_NAMES=()
    for name in "${FILE_EXPORTER_NAMES[@]}"; do
        if is_system_exporter "$name"; then
            SYSTEM_NAMES+=("$name")
        elif echo "$CURR_VAL_EXPORTER" | grep -qF "$name"; then
            DUPLICATE_NAMES+=("$name")
        fi
    done

    if [[ ${#SYSTEM_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: System exporters cannot be modified:"
        for n in "${SYSTEM_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    if [[ ${#DUPLICATE_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: Exporter(s) already exist (use update-exporter to modify):"
        for n in "${DUPLICATE_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    local scaled_exporter
    scaled_exporter=$(indent_block "$EXISTING_ROOT_INDENT" "$(cat "$NORMALIZED_CONFIG")")
    UPDATED_EXPORTER_VAL="$CURR_VAL_EXPORTER
$scaled_exporter"

    UPDATED_SERVICE_VAL="$CURR_VAL_SERVICE"
    UPDATED_CONNECTORS_VAL=$(get_connectors_or_default "$CURR_VAL_CONNECTORS")
    UPDATED_CONNECTORS_VAL=$(ensure_fanout_connector "$UPDATED_CONNECTORS_VAL")
    UPDATED_SERVICE_VAL=$(ensure_fanout_service_wiring "$UPDATED_SERVICE_VAL")

    for name in "${FILE_EXPORTER_NAMES[@]}"; do
        is_system_exporter "$name" && continue
        pipeline_name=$(exporter_to_pipeline "$name")
        if ! pipeline_exists "$UPDATED_SERVICE_VAL" "$pipeline_name"; then
            UPDATED_SERVICE_VAL=$(append_customer_pipeline "$UPDATED_SERVICE_VAL" "$pipeline_name" "$name")
            UPDATED_CONNECTORS_VAL=$(fanout_add_pipeline "$UPDATED_CONNECTORS_VAL" "$pipeline_name")
            log "ℹ️  Created pipeline '$pipeline_name' for exporter '$name' (fan-out duplicate)."
        fi
    done

    log "⬆️  Sending exporter/service/connectors update..."
    send_config_update "Modified%20OTel%20Metrics" \
        "$PARAM_EXPORTER" "$UPDATED_EXPORTER_VAL" \
        "$PARAM_SERVICE" "$UPDATED_SERVICE_VAL" \
        "$PARAM_CONNECTORS" "$UPDATED_CONNECTORS_VAL"
}

handle_update_exporter() {
    [[ "$SHOW_HELP" == true ]] && usage_update_exporter
    require_config_file "update-exporter"

    fetch_global_config
    CURR_VAL_EXPORTER=$(get_config_value "$PARAM_EXPORTER")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")

    if [[ -z "$CURR_VAL_EXPORTER" || "$CURR_VAL_EXPORTER" == "null" ]]; then
        echo "❌ Error: '$PARAM_EXPORTER' not found."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_exporter_root_indent "$CURR_VAL_EXPORTER")
    validate_yaml_indentation "$CONFIG_FILE" 0  # user file always has root keys at indent 0

    FILE_EXPORTER_NAMES=()
    while IFS= read -r name; do
        FILE_EXPORTER_NAMES+=("$name")
    done < <(parse_names_from_normalized_config 0)

    if [[ ${#FILE_EXPORTER_NAMES[@]} -eq 0 ]]; then
        echo "❌ Error: Could not parse any exporter names from the config file."
        exit 1
    fi
    log "ℹ️  Found ${#FILE_EXPORTER_NAMES[@]} exporter(s): ${FILE_EXPORTER_NAMES[*]}"

    SYSTEM_NAMES=()
    NOT_FOUND_NAMES=()
    for name in "${FILE_EXPORTER_NAMES[@]}"; do
        if is_system_exporter "$name"; then
            SYSTEM_NAMES+=("$name")
        elif ! echo "$CURR_VAL_EXPORTER" | grep -qF "$name"; then
            NOT_FOUND_NAMES+=("$name")
        fi
    done

    if [[ ${#SYSTEM_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: System exporters cannot be modified:"
        for n in "${SYSTEM_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    if [[ ${#NOT_FOUND_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: Exporter(s) not found (use add-exporter to add new ones):"
        for n in "${NOT_FOUND_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    UPDATED_EXPORTER_VAL="$CURR_VAL_EXPORTER"
    for name in "${FILE_EXPORTER_NAMES[@]}"; do
        UPDATED_EXPORTER_VAL=$(remove_named_block "$UPDATED_EXPORTER_VAL" "$name" "$EXISTING_ROOT_INDENT")
        log "ℹ️  Replaced block for '$name'."
    done
    local scaled_exporter
    scaled_exporter=$(indent_block "$EXISTING_ROOT_INDENT" "$(cat "$NORMALIZED_CONFIG")")
    UPDATED_EXPORTER_VAL="$UPDATED_EXPORTER_VAL
$scaled_exporter"

    log "ℹ️  Service pipeline unchanged (exporter names are the same)."
    log "⬆️  Sending exporter update..."
    send_config_update "Modified%20OTel%20Metrics" "$PARAM_EXPORTER" "$UPDATED_EXPORTER_VAL" "$PARAM_SERVICE" "$CURR_VAL_SERVICE"
}

handle_remove_exporter() {
    [[ "$SHOW_HELP" == true ]] && usage_remove_exporter
    if [[ ${#EXPORTERS[@]} -eq 0 ]]; then
        echo "❌ Error: At least one --exporter is required."
        echo "   Usage: $0 remove-exporter --exporter <name> [--exporter <name> ...]"
        exit 1
    fi

    fetch_global_config
    CURR_VAL_EXPORTER=$(get_config_value "$PARAM_EXPORTER")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")
    CURR_VAL_CONNECTORS=$(get_config_value "$PARAM_CONNECTORS")

    if [[ -z "$CURR_VAL_EXPORTER" || "$CURR_VAL_EXPORTER" == "null" ]]; then
        echo "❌ Error: '$PARAM_EXPORTER' not found."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_exporter_root_indent "$CURR_VAL_EXPORTER")

    SYSTEM_NAMES=()
    NOT_FOUND_NAMES=()
    for name in "${EXPORTERS[@]}"; do
        if is_system_exporter "$name"; then
            SYSTEM_NAMES+=("$name")
        elif ! echo "$CURR_VAL_EXPORTER" | grep -qF "$name"; then
            NOT_FOUND_NAMES+=("$name")
        fi
    done

    if [[ ${#SYSTEM_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: System exporters cannot be removed:"
        for n in "${SYSTEM_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    if [[ ${#NOT_FOUND_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: Exporter(s) not found in current config:"
        for n in "${NOT_FOUND_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    CURR_VAL_PROCESSOR=$(get_config_value "$PARAM_PROCESSOR")
    PROC_ROOT_INDENT=$(get_child_indent "$CURR_VAL_PROCESSOR" "processors:")

    UPDATED_EXPORTER_VAL="$CURR_VAL_EXPORTER"
    UPDATED_SERVICE_VAL="$CURR_VAL_SERVICE"
    UPDATED_PROCESSOR_VAL="$CURR_VAL_PROCESSOR"
    UPDATED_CONNECTORS_VAL=$(get_connectors_or_default "$CURR_VAL_CONNECTORS")
    for name in "${EXPORTERS[@]}"; do
        UPDATED_EXPORTER_VAL=$(remove_named_block "$UPDATED_EXPORTER_VAL" "$name" "$EXISTING_ROOT_INDENT")
        pipeline_name=$(exporter_to_pipeline "$name")

        pipeline_processors=$(echo "$CURR_VAL_SERVICE" | awk \
            -v pl="${pipeline_name}:" \
            'BEGIN{in_p=0}
             index($0,pl){in_p=1;next}
             in_p && /processors:/{
                 match($0,/\[.*\]/); if(RSTART>0){print substr($0,RSTART,RLENGTH)}; in_p=0
             }
             in_p && /exporters:/{in_p=0}' \
            | tr -d '[]' | tr ',' '\n' | sed 's/^[[:space:]]*//' | sed '/^$/d')

        UPDATED_SERVICE_VAL=$(remove_customer_pipeline "$UPDATED_SERVICE_VAL" "$pipeline_name")
        if echo "$UPDATED_CONNECTORS_VAL" | grep -qF "${FANOUT_CONNECTOR}:"; then
            UPDATED_CONNECTORS_VAL=$(fanout_remove_pipeline "$UPDATED_CONNECTORS_VAL" "$pipeline_name")
        fi
        log "ℹ️  Removed exporter '$name' and pipeline '$pipeline_name'."

        if [[ -n "$pipeline_processors" ]]; then
            while IFS= read -r pname; do
                [[ -z "$pname" ]] && continue
                still_used=$(echo "$UPDATED_SERVICE_VAL" | grep -cF "$pname" || true)
                if [[ "$still_used" -eq 0 ]]; then
                    UPDATED_PROCESSOR_VAL=$(remove_named_block "$UPDATED_PROCESSOR_VAL" "$pname" "$PROC_ROOT_INDENT")
                    log "ℹ️  Removed processor '$pname' (no longer referenced by any pipeline)."
                else
                    log "ℹ️  Kept processor '$pname' (still used in $still_used other pipeline(s))."
                fi
            done <<< "$pipeline_processors"
        fi
    done

    # Only co-write otelcol_processors when a processor was actually removed.
    # Re-writing an unchanged processor value would strip its trailing newline
    # (via command substitution) and corrupt the param, so skip it otherwise.
    if [[ "$UPDATED_PROCESSOR_VAL" != "$CURR_VAL_PROCESSOR" ]]; then
        log "⬆️  Sending exporter/processor/service/connectors update..."
        send_config_update "Modified%20OTel%20Metrics" \
            "$PARAM_EXPORTER" "$UPDATED_EXPORTER_VAL" \
            "$PARAM_SERVICE" "$UPDATED_SERVICE_VAL" \
            "$PARAM_PROCESSOR" "$UPDATED_PROCESSOR_VAL" \
            "$PARAM_CONNECTORS" "$UPDATED_CONNECTORS_VAL"
    else
        log "⬆️  Sending exporter/service/connectors update (processors unchanged)..."
        send_config_update "Modified%20OTel%20Metrics" \
            "$PARAM_EXPORTER" "$UPDATED_EXPORTER_VAL" \
            "$PARAM_SERVICE" "$UPDATED_SERVICE_VAL" \
            "$PARAM_CONNECTORS" "$UPDATED_CONNECTORS_VAL"
    fi
}

handle_list_processors() {
    [[ "$SHOW_HELP" == true ]] && usage_list_processors
    fetch_global_config
    CURR_VAL_PROCESSOR=$(get_config_value "$PARAM_PROCESSOR")

    if [[ -z "$CURR_VAL_PROCESSOR" || "$CURR_VAL_PROCESSOR" == "null" ]]; then
        echo "No processors configured."
        exit 0
    fi

    EXISTING_ROOT_INDENT=$(get_child_indent "$CURR_VAL_PROCESSOR" "processors:")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")
    echo ""
    printf "%-45s %-12s %s\n" "PROCESSOR NAME" "TYPE" "LINKED PIPELINES"
    printf "%-45s %-12s %s\n" "--------------" "----" "----------------"
    while IFS= read -r pname; do
        linked_pipelines=""
        current_pipeline=""
        while IFS= read -r sline; do
            if echo "$sline" | grep -qE '^[[:space:]]+metrics/[^[:space:]]+:'; then
                current_pipeline=$(echo "$sline" | sed 's/:[[:space:]]*$//' | sed 's/^[[:space:]]*//')
            fi
            if echo "$sline" | grep -qE '^[[:space:]]+processors:' && echo "$sline" | grep -qF "$pname"; then
                if [[ -n "$linked_pipelines" ]]; then
                    linked_pipelines="$linked_pipelines, $current_pipeline"
                else
                    linked_pipelines="$current_pipeline"
                fi
            fi
        done <<< "$CURR_VAL_SERVICE"

        if echo "$pname" | grep -qE '^(filter|pidfilter|metricstransform)/'; then
            ptype="system"
        else
            ptype="customer"
        fi
        printf "%-45s %-12s %s\n" "$pname" "$ptype" "${linked_pipelines:-(not linked)}"
    done < <(parse_exporter_names "$CURR_VAL_PROCESSOR" "$EXISTING_ROOT_INDENT")
    echo ""
}

handle_add_processor() {
    [[ "$SHOW_HELP" == true ]] && usage_add_processor

    if [[ -z "$CONFIG_FILE" && ${#PROCESSORS[@]} -eq 0 ]]; then
        echo "❌ Error: Provide --file to define a new processor, or"
        echo "          --processor <name> --exporter <name> to link an existing one."
        echo "   Usage: $0 add-processor --help"
        exit 1
    fi
    if [[ -n "$CONFIG_FILE" && ${#PROCESSORS[@]} -gt 0 ]]; then
        echo "❌ Error: --file and --processor cannot be used together."
        echo "   Use --file to define a new processor, or"
        echo "   --processor to link an already-defined one."
        exit 1
    fi
    if [[ ${#PROCESSORS[@]} -gt 0 && ${#EXPORTERS[@]} -eq 0 ]]; then
        echo "❌ Error: --exporter is required when using --processor."
        exit 1
    fi
    if [[ -n "$CONFIG_FILE" && ! -f "$CONFIG_FILE" ]]; then
        echo "❌ Error: File '$CONFIG_FILE' not found."
        exit 1
    fi

    fetch_global_config
    CURR_VAL_PROCESSOR=$(get_config_value "$PARAM_PROCESSOR")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")

    if [[ -z "$CURR_VAL_PROCESSOR" || "$CURR_VAL_PROCESSOR" == "null" ]]; then
        echo "❌ Error: '$PARAM_PROCESSOR' not found in CM config."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_child_indent "$CURR_VAL_PROCESSOR" "processors:")
    UPDATED_PROCESSOR_VAL="$CURR_VAL_PROCESSOR"
    UPDATED_SERVICE_VAL="$CURR_VAL_SERVICE"

    if [[ -n "$CONFIG_FILE" ]]; then
        # File has root-level keys (0 indent); we indent them under processors: when appending
        validate_yaml_indentation "$CONFIG_FILE" 0

        FILE_PROCESSOR_NAMES=()
        while IFS= read -r pname; do
            FILE_PROCESSOR_NAMES+=("$pname")
        done < <(parse_names_from_normalized_config "$FILE_ROOT_INDENT")

        if [[ ${#FILE_PROCESSOR_NAMES[@]} -eq 0 ]]; then
            echo "❌ Error: Could not parse any processor names from the config file."
            exit 1
        fi
        log "ℹ️  Found ${#FILE_PROCESSOR_NAMES[@]} processor(s): ${FILE_PROCESSOR_NAMES[*]}"

        DUPLICATE_PROC=()
        for pname in "${FILE_PROCESSOR_NAMES[@]}"; do
            if echo "$CURR_VAL_PROCESSOR" | grep -qF "$pname"; then
                DUPLICATE_PROC+=("$pname")
            fi
        done
        if [[ ${#DUPLICATE_PROC[@]} -gt 0 ]]; then
            echo "❌ Error: Processor(s) already exist (use update-processor to modify):"
            for n in "${DUPLICATE_PROC[@]}"; do echo "   - $n"; done
            exit 1
        fi

        local indented_procs
        indented_procs=$(indent_block "$EXISTING_ROOT_INDENT" "$(cat "$NORMALIZED_CONFIG")")
        UPDATED_PROCESSOR_VAL="$CURR_VAL_PROCESSOR
$indented_procs"

        if [[ ${#EXPORTERS[@]} -gt 0 ]]; then
            for pname in "${FILE_PROCESSOR_NAMES[@]}"; do
                for exporter_name in "${EXPORTERS[@]}"; do
                    pl=$(exporter_to_pipeline "$exporter_name")
                    if ! pipeline_exists "$UPDATED_SERVICE_VAL" "$pl"; then
                        echo "❌ Error: No pipeline for exporter '$exporter_name'. Run add-exporter first."
                        exit 1
                    fi
                    UPDATED_SERVICE_VAL=$(add_to_pipeline_field "$UPDATED_SERVICE_VAL" "$pl" "processors" "$pname")
                    log "ℹ️  Linked '$pname' to pipeline '$pl'."
                done
            done
        else
            log "ℹ️  Processor definition added to otelcol_processors (not linked to any pipeline yet)."
        fi
    fi

    if [[ ${#PROCESSORS[@]} -gt 0 ]]; then
        NOT_FOUND_PROC=()
        for pname in "${PROCESSORS[@]}"; do
            if ! echo "$CURR_VAL_PROCESSOR" | grep -qF "$pname"; then
                NOT_FOUND_PROC+=("$pname")
            fi
        done
        if [[ ${#NOT_FOUND_PROC[@]} -gt 0 ]]; then
            echo "❌ Error: Processor(s) not found in otelcol_processors:"
            for n in "${NOT_FOUND_PROC[@]}"; do echo "   - $n"; done
            echo "   Use add-processor --file <path> to define them first."
            exit 1
        fi

        for pname in "${PROCESSORS[@]}"; do
            for exporter_name in "${EXPORTERS[@]}"; do
                pl=$(exporter_to_pipeline "$exporter_name")
                if ! pipeline_exists "$UPDATED_SERVICE_VAL" "$pl"; then
                    echo "❌ Error: No pipeline for exporter '$exporter_name'. Run add-exporter first."
                    exit 1
                fi
                if pipeline_field_contains "$UPDATED_SERVICE_VAL" "$pl" "processors" "$pname"; then
                    echo "⚠️  Processor '$pname' is already linked to pipeline '$pl'. Skipping."
                    continue
                fi
                UPDATED_SERVICE_VAL=$(add_to_pipeline_field "$UPDATED_SERVICE_VAL" "$pl" "processors" "$pname")
                log "ℹ️  Linked existing processor '$pname' to pipeline '$pl'."
            done
        done
    fi

    log "⬆️  Sending processor/service update..."
    send_config_update "Modified%20OTel%20Processors" "$PARAM_PROCESSOR" "$UPDATED_PROCESSOR_VAL" "$PARAM_SERVICE" "$UPDATED_SERVICE_VAL"
}

handle_update_processor() {
    [[ "$SHOW_HELP" == true ]] && usage_update_processor
    require_config_file "update-processor"

    fetch_global_config
    CURR_VAL_PROCESSOR=$(get_config_value "$PARAM_PROCESSOR")

    if [[ -z "$CURR_VAL_PROCESSOR" || "$CURR_VAL_PROCESSOR" == "null" ]]; then
        echo "❌ Error: '$PARAM_PROCESSOR' not found."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_child_indent "$CURR_VAL_PROCESSOR" "processors:")
    # File has root-level keys (0 indent); we indent them under processors: when appending
    validate_yaml_indentation "$CONFIG_FILE" 0

    FILE_PROCESSOR_NAMES=()
    while IFS= read -r pname; do
        FILE_PROCESSOR_NAMES+=("$pname")
    done < <(parse_names_from_normalized_config "$FILE_ROOT_INDENT")

    if [[ ${#FILE_PROCESSOR_NAMES[@]} -eq 0 ]]; then
        echo "❌ Error: Could not parse any processor names from the config file."
        exit 1
    fi

    NOT_FOUND_PROC=()
    for pname in "${FILE_PROCESSOR_NAMES[@]}"; do
        if ! echo "$CURR_VAL_PROCESSOR" | grep -qF "$pname"; then
            NOT_FOUND_PROC+=("$pname")
        fi
    done
    if [[ ${#NOT_FOUND_PROC[@]} -gt 0 ]]; then
        echo "❌ Error: Processor(s) not found (use add-processor to add new ones):"
        for n in "${NOT_FOUND_PROC[@]}"; do echo "   - $n"; done
        exit 1
    fi

    UPDATED_PROCESSOR_VAL="$CURR_VAL_PROCESSOR"
    for pname in "${FILE_PROCESSOR_NAMES[@]}"; do
        UPDATED_PROCESSOR_VAL=$(remove_named_block "$UPDATED_PROCESSOR_VAL" "$pname" "$EXISTING_ROOT_INDENT")
        log "ℹ️  Replaced block for processor '$pname'."
    done
    local indented_procs
    indented_procs=$(indent_block "$EXISTING_ROOT_INDENT" "$(cat "$NORMALIZED_CONFIG")")
    UPDATED_PROCESSOR_VAL="$UPDATED_PROCESSOR_VAL
$indented_procs"

    log "ℹ️  Service pipeline unchanged (processor names are the same)."
    log "⬆️  Sending processor update..."
    send_config_update "Modified%20OTel%20Processors" "$PARAM_PROCESSOR" "$UPDATED_PROCESSOR_VAL"
}

handle_remove_processor() {
    [[ "$SHOW_HELP" == true ]] && usage_remove_processor
    if [[ ${#PROCESSORS[@]} -eq 0 ]]; then
        echo "❌ Error: At least one --processor is required."
        echo "   Usage: $0 remove-processor --help"
        exit 1
    fi

    fetch_global_config
    CURR_VAL_PROCESSOR=$(get_config_value "$PARAM_PROCESSOR")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")

    if [[ -z "$CURR_VAL_PROCESSOR" || "$CURR_VAL_PROCESSOR" == "null" ]]; then
        echo "❌ Error: '$PARAM_PROCESSOR' not found."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_child_indent "$CURR_VAL_PROCESSOR" "processors:")

    NOT_FOUND_PROC=()
    for pname in "${PROCESSORS[@]}"; do
        if ! echo "$CURR_VAL_PROCESSOR" | grep -qF "$pname"; then
            NOT_FOUND_PROC+=("$pname")
        fi
    done
    if [[ ${#NOT_FOUND_PROC[@]} -gt 0 ]]; then
        echo "❌ Error: Processor(s) not found in current config:"
        for n in "${NOT_FOUND_PROC[@]}"; do echo "   - $n"; done
        exit 1
    fi

    if [[ ${#EXPORTERS[@]} -gt 0 ]]; then
        for exporter_name in "${EXPORTERS[@]}"; do
            pl=$(exporter_to_pipeline "$exporter_name")
            if ! pipeline_exists "$CURR_VAL_SERVICE" "$pl"; then
                echo "❌ Error: No pipeline found for exporter '$exporter_name'."
                exit 1
            fi
        done
    fi

    UPDATED_PROCESSOR_VAL="$CURR_VAL_PROCESSOR"
    UPDATED_SERVICE_VAL="$CURR_VAL_SERVICE"

    for pname in "${PROCESSORS[@]}"; do
        if [[ ${#EXPORTERS[@]} -gt 0 ]]; then
            for exporter_name in "${EXPORTERS[@]}"; do
                pl=$(exporter_to_pipeline "$exporter_name")
                UPDATED_SERVICE_VAL=$(remove_from_pipeline_field "$UPDATED_SERVICE_VAL" "$pl" "processors" "$pname")
                log "ℹ️  Unlinked '$pname' from pipeline '$pl' (definition kept)."
            done
        else
            UPDATED_PROCESSOR_VAL=$(remove_named_block "$UPDATED_PROCESSOR_VAL" "$pname" "$EXISTING_ROOT_INDENT")
            while IFS= read -r line; do
                if echo "$line" | grep -qE '^[[:space:]]+metrics/[^[:space:]]+:'; then
                    pl=$(echo "$line" | sed 's/:[[:space:]]*$//' | sed 's/^[[:space:]]*//')
                    UPDATED_SERVICE_VAL=$(remove_from_pipeline_field "$UPDATED_SERVICE_VAL" "$pl" "processors" "$pname")
                fi
            done <<< "$CURR_VAL_SERVICE"
            log "ℹ️  Deleted '$pname' definition and removed from all pipelines."
        fi
    done

    log "⬆️  Sending processor/service update..."
    send_config_update "Modified%20OTel%20Processors" "$PARAM_PROCESSOR" "$UPDATED_PROCESSOR_VAL" "$PARAM_SERVICE" "$UPDATED_SERVICE_VAL"
}

# ==============================================================================
# EXTENSION COMMANDS (section-only: otelcol_gateway_extensions)
# ==============================================================================
handle_list_extensions() {
    [[ "$SHOW_HELP" == true ]] && usage_list_extensions
    fetch_global_config
    CURR_VAL_EXTENSION=$(get_config_value "$PARAM_EXTENSION")

    if [[ -z "$CURR_VAL_EXTENSION" || "$CURR_VAL_EXTENSION" == "null" ]]; then
        echo "No extensions configured."
        exit 0
    fi

    EXISTING_ROOT_INDENT=$(get_child_indent "$CURR_VAL_EXTENSION" "extensions:")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")
    echo ""
    printf "%-45s %-12s %s\n" "EXTENSION NAME" "TYPE" "LINKED PIPELINES"
    printf "%-45s %-12s %s\n" "--------------" "----" "----------------"
    while IFS= read -r pname; do
        if echo "$pname" | grep -qE '^(filter|pidfilter|metricstransform)/'; then
            ptype="system"
        else
            ptype="customer"
        fi
        printf "%-45s %-12s %s\n" "$pname" "$ptype" "${linked_pipelines:-(not linked)}"
    done < <(parse_exporter_names "$CURR_VAL_EXTENSION" "$EXISTING_ROOT_INDENT")
    echo ""
}

handle_add_extension() {
    [[ "$SHOW_HELP" == true ]] && usage_add_extension
    require_config_file "add-extension"

    fetch_global_config
    CURR_VAL_EXTENSION=$(get_config_value "$PARAM_EXTENSION")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")

    if [[ -z "$CURR_VAL_EXTENSION" || "$CURR_VAL_EXTENSION" == "null" ]]; then
        echo "❌ Error: '$PARAM_EXTENSION' not found."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_child_indent "$CURR_VAL_EXTENSION" "extensions:")
    # File has root-level keys (0 indent); we indent them under extensions: when appending
    validate_yaml_indentation "$CONFIG_FILE" 0

    FILE_EXTENSION_NAMES=()
    while IFS= read -r name; do
        FILE_EXTENSION_NAMES+=("$name")
    done < <(parse_names_from_normalized_config "$FILE_ROOT_INDENT")

    if [[ ${#FILE_EXTENSION_NAMES[@]} -eq 0 ]]; then
        echo "❌ Error: Could not parse any extension names from the config file."
        exit 1
    fi
    log "ℹ️  Found ${#FILE_EXTENSION_NAMES[@]} extension(s): ${FILE_EXTENSION_NAMES[*]}"

    DUPLICATE_NAMES=()
    SYSTEM_NAMES=()
    for name in "${FILE_EXTENSION_NAMES[@]}"; do
        if is_system_extension "$name"; then
            SYSTEM_NAMES+=("$name")
        elif echo "$CURR_VAL_EXTENSION" | grep -qF "$name"; then
            DUPLICATE_NAMES+=("$name")
        fi
    done

    if [[ ${#SYSTEM_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: System extensions cannot be modified:"
        for n in "${SYSTEM_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    if [[ ${#DUPLICATE_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: Extension(s) already exist (use update-extension to modify):"
        for n in "${DUPLICATE_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    local indented_exts
    indented_exts=$(indent_block "$EXISTING_ROOT_INDENT" "$(cat "$NORMALIZED_CONFIG")")
    UPDATED_EXTENSION_VAL="$CURR_VAL_EXTENSION
$indented_exts"

    send_config_update "Modified%20OTel%20Metrics" "$PARAM_EXTENSION" "$UPDATED_EXTENSION_VAL"
}

handle_update_extension() {
    [[ "$SHOW_HELP" == true ]] && usage_update_extension
    require_config_file "update-extension"

    fetch_global_config
    CURR_VAL_EXTENSION=$(get_config_value "$PARAM_EXTENSION")

    if [[ -z "$CURR_VAL_EXTENSION" || "$CURR_VAL_EXTENSION" == "null" ]]; then
        echo "❌ Error: '$PARAM_EXTENSION' not found."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_child_indent "$CURR_VAL_EXTENSION" "extensions:")
    # File has root-level keys (0 indent); we indent them under extensions: when appending
    validate_yaml_indentation "$CONFIG_FILE" 0

    FILE_EXTENSION_NAMES=()
    while IFS= read -r pname; do
        FILE_EXTENSION_NAMES+=("$pname")
    done < <(parse_names_from_normalized_config "$FILE_ROOT_INDENT")

    if [[ ${#FILE_EXTENSION_NAMES[@]} -eq 0 ]]; then
        echo "❌ Error: Could not parse any extension names from the config file."
        exit 1
    fi

    NOT_FOUND_NAMES=()
    for pname in "${FILE_EXTENSION_NAMES[@]}"; do
        if ! echo "$CURR_VAL_EXTENSION" | grep -qF "$pname"; then
            NOT_FOUND_NAMES+=("$pname")
        fi
    done
    if [[ ${#NOT_FOUND_NAMES[@]} -gt 0 ]]; then
        echo "❌ Error: Extension(s) not found (use add-extension to add new ones):"
        for n in "${NOT_FOUND_NAMES[@]}"; do echo "   - $n"; done
        exit 1
    fi

    UPDATED_EXTENSION_VAL="$CURR_VAL_EXTENSION"
    for pname in "${FILE_EXTENSION_NAMES[@]}"; do
        UPDATED_EXTENSION_VAL=$(remove_named_block "$UPDATED_EXTENSION_VAL" "$pname" "$EXISTING_ROOT_INDENT")
        log "ℹ️  Replaced block for extension '$pname'."
    done
    local indented_exts
    indented_exts=$(indent_block "$EXISTING_ROOT_INDENT" "$(cat "$NORMALIZED_CONFIG")")
    UPDATED_EXTENSION_VAL="$UPDATED_EXTENSION_VAL
$indented_exts"

    log "ℹ️  Service pipeline unchanged (extension names are the same)."
    log "⬆️  Sending extension update..."
    send_config_update "Modified%20OTel%20Processors" "$PARAM_EXTENSION" "$UPDATED_EXTENSION_VAL"
}

handle_remove_extension() {
    [[ "$SHOW_HELP" == true ]] && usage_remove_extension
    if [[ ${#EXTENSIONS[@]} -eq 0 ]]; then
        echo "❌ Error: At least one --extension is required."
        echo "   Usage: $0 remove-extension --help"
        exit 1
    fi

    fetch_global_config
    CURR_VAL_EXTENSION=$(get_config_value "$PARAM_EXTENSION")
    CURR_VAL_SERVICE=$(get_config_value "$PARAM_SERVICE")

    if [[ -z "$CURR_VAL_EXTENSION" || "$CURR_VAL_EXTENSION" == "null" ]]; then
        echo "❌ Error: '$PARAM_EXTENSION' not found."
        exit 1
    fi

    EXISTING_ROOT_INDENT=$(get_child_indent "$CURR_VAL_EXTENSION" "extensions:")

    NOT_FOUND_PROC=()
    for pname in "${EXTENSIONS[@]}"; do
        if ! echo "$CURR_VAL_EXTENSION" | grep -qF "$pname"; then
            NOT_FOUND_PROC+=("$pname")
        fi
    done
    if [[ ${#NOT_FOUND_PROC[@]} -gt 0 ]]; then
        echo "❌ Error: Processor(s) not found in current config:"
        for n in "${NOT_FOUND_PROC[@]}"; do echo "   - $n"; done
        exit 1
    fi

    if [[ ${#EXPORTERS[@]} -gt 0 ]]; then
        for exporter_name in "${EXPORTERS[@]}"; do
            pl=$(exporter_to_pipeline "$exporter_name")
            if ! pipeline_exists "$CURR_VAL_SERVICE" "$pl"; then
                echo "❌ Error: No pipeline found for exporter '$exporter_name'."
                exit 1
            fi
        done
    fi

    UPDATED_EXTENSION_VAL="$CURR_VAL_EXTENSION"
    UPDATED_SERVICE_VAL="$CURR_VAL_SERVICE"

    for pname in "${EXTENSIONS[@]}"; do
        if [[ ${#EXPORTERS[@]} -gt 0 ]]; then
            for exporter_name in "${EXPORTERS[@]}"; do
                pl=$(exporter_to_pipeline "$exporter_name")
                UPDATED_SERVICE_VAL=$(remove_from_pipeline_field "$UPDATED_SERVICE_VAL" "$pl" "extensions" "$pname")
                log "ℹ️  Unlinked '$pname' from pipeline '$pl' (definition kept)."
            done
        else
            UPDATED_EXTENSION_VAL=$(remove_named_block "$UPDATED_EXTENSION_VAL" "$pname" "$EXISTING_ROOT_INDENT")
            while IFS= read -r line; do
                if echo "$line" | grep -qE '^[[:space:]]+metrics/[^[:space:]]+:'; then
                    pl=$(echo "$line" | sed 's/:[[:space:]]*$//' | sed 's/^[[:space:]]*//')
                    UPDATED_SERVICE_VAL=$(remove_from_pipeline_field "$UPDATED_SERVICE_VAL" "$pl" "extensions" "$pname")
                fi
            done <<< "$CURR_VAL_SERVICE"
            log "ℹ️  Deleted '$pname' definition and removed from all pipelines."
        fi
    done

    log "⬆️  Sending extension/service update..."
    send_config_update "Modified%20OTel%20Processors" "$PARAM_EXTENSION" "$UPDATED_EXTENSION_VAL" "$PARAM_SERVICE" "$UPDATED_SERVICE_VAL"
}


# When sourced as a library (e.g. by tests), stop here: all functions are
# defined above, but no argument parsing or command dispatch should run.
if [[ -n "${OBS_CLI_LIBRARY_MODE:-}" ]]; then
    return 0 2>/dev/null || exit 0
fi

if [[ $# -eq 0 ]]; then usage; fi

COMMAND=""
CM_BASE_URL="$DEFAULT_CM_BASE_URL"
CM_USER="$DEFAULT_CM_USER"
CM_PASS="$DEFAULT_CM_PASS"
HOSTS=()
EXPORTERS=()
PROCESSORS=()
EXTENSIONS=()
CONFIG_FILE=""
SHOW_HELP=false

while [[ $# -gt 0 ]]; do
    case "$1" in
        --url)       CM_BASE_URL="$2";   shift 2 ;;
        --user)      CM_USER="$2";       shift 2 ;;
        --pass)      CM_PASS="$2";       shift 2 ;;
        --host)      HOSTS+=("$2");      shift 2 ;;
        --exporter)  EXPORTERS+=("$2");  shift 2 ;;
        --processor) PROCESSORS+=("$2"); shift 2 ;;
        --extension) EXTENSIONS+=("$2"); shift 2 ;;
        --file)      CONFIG_FILE="$2";   shift 2 ;;
        --debug)     DEBUG=true;         shift   ;;
        --help|-h)   SHOW_HELP=true;     shift   ;;
        -*)
            echo ""
            echo "❌ Unknown option: '$1'"
            echo ""
            if [[ -n "$COMMAND" ]]; then
                show_command_help "$COMMAND"
            else
                usage
            fi
            ;;
        *)
            if [[ -z "$COMMAND" ]]; then
                COMMAND="$1"; shift
            else
                echo ""
                echo "❌ Unexpected argument: '$1'"
                echo ""
                show_command_help "$COMMAND"
            fi
            ;;
    esac
done

# No command given — show main help or error
if [[ -z "$COMMAND" ]]; then
    if [[ "$SHOW_HELP" == true ]]; then
        usage
    else
        usage
    fi
fi

case "$COMMAND" in

    # ── get-gateway-hosts ─────────────────────────────────────────────────────
    get-gateway-hosts)
        handle_get_gateway_hosts
        ;;

    # ── list-hosts ────────────────────────────────────────────────────────────
    list-hosts)
        handle_list_hosts
        ;;

    # ── list-exporters ────────────────────────────────────────────────────────
    list-exporters)
        handle_list_exporters
        ;;

    # ── get-metric-configs ────────────────────────────────────────────────────
    get-metric-configs)
        handle_get_metric_configs
        ;;

    # ── add-gateway-host ──────────────────────────────────────────────────────
    add-gateway-host)
        handle_add_gateway_host
        ;;

    # ── remove-gateway-host ───────────────────────────────────────────────────
    remove-gateway-host)
        handle_remove_gateway_host
        ;;

    # ── add-exporter ──────────────────────────────────────────────────────────
    add-exporter)
        handle_add_exporter
        ;;

    # ── update-exporter ───────────────────────────────────────────────────────
    update-exporter)
        handle_update_exporter
        ;;

    # ── remove-exporter ───────────────────────────────────────────────────────
    remove-exporter)
        handle_remove_exporter
        ;;

    # ── list-processors ───────────────────────────────────────────────────────
    list-processors)
        handle_list_processors
        ;;

    # ── add-processor ─────────────────────────────────────────────────────────
    add-processor)
        handle_add_processor
        ;;

    # ── update-processor ──────────────────────────────────────────────────────
    update-processor)
        handle_update_processor
        ;;

    # ── remove-processor ──────────────────────────────────────────────────────
    remove-processor)
        handle_remove_processor
        ;;

    list-extensions)
      handle_list_extensions
      ;;

    add-extension)
      handle_add_extension
      ;;

    update-extension)
      handle_update_extension
      ;;

    remove-extension)
      handle_remove_extension
      ;;
    *)

        show_command_help "$COMMAND"
        ;;
esac