#!/bin/bash
# Local five-second no-audio release. Run only on a chosen dialer, not AMD server.
# Does not change campaigns, firewall, SSH, credentials or provider allowlists.
set -euo pipefail
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
AMD_WS_URL="${AMD_WS_URL:-ws://144.76.101.53:2700}"
AGI_DIR="${AGI_DIR:-/var/lib/asterisk/agi-bin}"
EXTCONF="${AMD_EXTCONF:-/etc/asterisk/extensions.conf}"
EXT="${AMD_EXT:-8371}"
CTX="${AMD_CTX:-default}"
PREROLL="${AMD_PREROLL_FILE:-sip-silence}"
PYBIN="${AMD_PYTHON:-/usr/bin/python3}"
SCRIPT_ONLY=0
CHECK_ONLY=0
for option in "$@"; do
  case "$option" in
    --script-only) SCRIPT_ONLY=1 ;;
    --check) CHECK_ONLY=1 ;;
    *) echo "Usage: bash install_amd_client.sh [--check] [--script-only]" >&2; exit 2 ;;
  esac
done
die(){ echo "[amd-install] ERROR: $*" >&2; exit 1; }
log(){ echo "[amd-install] $*"; }
[[ "$(id -u)" == 0 ]] || die "Run as root on the dialer."
[[ -x "$PYBIN" && -f "$EXTCONF" && -d "$AGI_DIR" ]] || die "Check interpreter, extensions.conf and AGI_DIR."
[[ ! -L "$EXTCONF" && ! -L "$AGI_DIR/amd_agi_client.py" ]] || die "Symlink targets require manual review."
command -v asterisk >/dev/null || die "Asterisk is not installed."
command -v flock >/dev/null || die "flock is required to serialize installations."
exec 9>/run/lock/amd-client-install.lock
flock -n 9 || die "Another AMD client installation is running."
[[ "$PYBIN" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "AMD_PYTHON must be an absolute path without spaces."
# The client shebang uses /usr/bin/python3; do not silently validate another Python.
[[ "$PYBIN" == /usr/bin/python3 ]] || die "This release's shebang requires /usr/bin/python3."
AST_USER="$(ps -o user= -C asterisk 2>/dev/null | head -1 | tr -d ' ')"
[[ -n "$AST_USER" && "$AST_USER" =~ ^[A-Za-z0-9_-]+$ ]] || die "Cannot identify the running Asterisk user."
# Preserve installed dependencies. No package manager, pip upgrade or bootstrap runs.
su -s /bin/sh -c "$PYBIN -c 'import asterisk.agi; from websocket import create_connection, ABNF, WebSocketTimeoutException'" "$AST_USER" \
  || die "Missing Python dependencies for the Asterisk user: pyst2 and websocket-client. Install compatible versions through your approved package-management process, then retry."
"$PYBIN" -c 'import sys; compile(open(sys.argv[1], "rb").read(), sys.argv[1], "exec")' "$SELF_DIR/amd_agi_client.py"
if [[ "$SCRIPT_ONLY" == 0 ]]; then
  # Do not fabricate or download a sound asset silently. Use the dialer's asset.
  SETTINGS="$(asterisk -rx 'core show settings')"
  DATA_DIR="$(printf '%s\n' "$SETTINGS" | sed -n 's/^[[:space:]]*Data directory:[[:space:]]*//p' | head -1)"
  SOUNDS_DIR="${AST_SOUNDS_DIR:-${DATA_DIR:-/var/lib/asterisk}/sounds}"
  "$PYBIN" - "$SOUNDS_DIR" "$PREROLL" <<'PY'
import glob, os, sys
directory, name = sys.argv[1:]
extensions = {'.wav','.WAV','.gsm','.ulaw','.alaw','.sln','.sln16','.g722','.g729'}
paths = glob.glob(os.path.join(directory, name)+'.*') + glob.glob(os.path.join(directory, '*', name)+'.*')
if not any(os.path.isfile(p) and os.path.splitext(p)[1] in extensions for p in paths):
    sys.exit('Required pre-roll sound not found. Set AST_SOUNDS_DIR / AMD_PREROLL_FILE correctly; nothing installed.')
PY
fi
BACKUP_DIR="$(mktemp -d /var/tmp/amd-client-backup.XXXXXXXX)"
chmod 700 "$BACKUP_DIR"
mkdir "$BACKUP_DIR/staged"
ARGS=(--source "$SELF_DIR/amd_agi_client.py" --extensions "$EXTCONF" --stage "$BACKUP_DIR/staged"
      --endpoint "$AMD_WS_URL" --extension "$EXT" --context "$CTX" --agi-dir "$AGI_DIR" --preroll "$PREROLL")
[[ "$SCRIPT_ONLY" == 0 ]] || ARGS+=(--script-only)
"$PYBIN" - "${ARGS[@]}" <<'AMD_INSTALL_PY'
#!/usr/bin/env python3
"""Validate and stage an install. Python 3.4+; no live configuration writes."""
import argparse
import os
import re
from urllib.parse import urlsplit

BEGIN = '; --- BEGIN self-hosted AMD (usad2/gru) — managed by install_amd_client.sh, do not edit ---'
END = '; --- END self-hosted AMD ---'


def validate(endpoint, extension, context, agi_dir, preroll):
    parsed = urlsplit(endpoint)
    if (parsed.scheme not in ('ws', 'wss') or not parsed.hostname or
            parsed.username or parsed.password or parsed.fragment or parsed.query or
            any(c.isspace() for c in endpoint)):
        raise ValueError('AMD_WS_URL must be ws:// or wss:// without credentials/query/fragment')
    if not re.match(r'^[0-9]+$', extension):
        raise ValueError('AMD_EXT must contain digits only')
    if not re.match(r'^[A-Za-z0-9_-]+$', context):
        raise ValueError('AMD_CTX must be a simple context name')
    if context == 'amd-selfhosted':
        raise ValueError('AMD_CTX cannot include itself')
    if not re.match(r'^/[A-Za-z0-9_./-]+$', agi_dir) or '..' in agi_dir.split('/'):
        raise ValueError('AGI_DIR must be an absolute path without spaces or parent traversal')
    if not re.match(r'^[A-Za-z0-9_/-]+$', preroll) or preroll.startswith('/') or '..' in preroll:
        raise ValueError('AMD_PREROLL_FILE must be a relative sound name without an extension')


def render_dialplan(original, extension, context, agi_dir, preroll):
    """Replace only our exact managed block; preserve all other lines verbatim."""
    lines = original.splitlines(True)
    starts = [i for i, line in enumerate(lines) if line.rstrip('\r\n') == BEGIN]
    ends = [i for i, line in enumerate(lines) if line.rstrip('\r\n') == END]
    if len(starts) != len(ends) or len(starts) > 1:
        raise ValueError('Malformed/duplicate managed markers; refusing to edit')
    if starts:
        first, last = starts[0], ends[0]
        if last <= first:
            raise ValueError('Managed markers out of order')
        block = ''.join(lines[first:last+1])
        old_exts = set(re.findall(r'^exten\s*=>\s*(\d+)\s*,', block, re.M))
        headers = re.findall(r'^\s*\[([^]]+)\]', block, re.M)
        if old_exts != {extension} or headers != ['amd-selfhosted']:
            raise ValueError('Managed block has a different extension/context; review manually')
        outside = ''.join(lines[:first]+lines[last+1:])
    else:
        outside = original
    if re.search(r'^\s*\[amd-selfhosted\]', outside, re.M):
        raise ValueError('Unmanaged amd-selfhosted context exists; use --script-only or review manually')
    if re.search(r'^\s*; --- (?:BEGIN |END )?self-hosted AMD', outside, re.M):
        raise ValueError('Unrecognized legacy managed block; review manually')
    if re.search(r'^[^;\n]*amd_agi_client\.py', outside, re.M):
        raise ValueError('Client is referenced outside our block; use --script-only and update playback manually')
    active = None
    context_count = 0
    include_contexts = []
    for line in outside.splitlines():
        header = re.match(r'^\s*\[([^]]+)\]', line)
        if header:
            active = header.group(1)
            context_count += active == context
        if re.match(r'^\s*include\s*=>\s*amd-selfhosted\s*(?:;.*)?$', line):
            include_contexts.append(active)
        if active == context and re.match(r'^\s*exten\s*=>\s*'+re.escape(extension)+r'\s*,', line):
            raise ValueError('Target extension already exists in parent context')
    if context_count != 1:
        raise ValueError('Expected exactly one parent context ['+context+']')
    if include_contexts and include_contexts != [context]:
        raise ValueError('Existing includes would change reachability; review manually')
    newline = '\r\n' if '\r\n' in original else '\n'
    block_lines = [BEGIN, '[amd-selfhosted]',
        'exten => '+extension+',1,AGI(agi://127.0.0.1:4577/call_log)',
        'exten => '+extension+',n,Answer()',
        'exten => '+extension+',n,Playback('+preroll+')',
        'exten => '+extension+',n,NoOp(AMD pre-roll status: ${PLAYBACKSTATUS})',
        'exten => '+extension+',n,EAGI('+agi_dir+'/amd_agi_client.py)',
        'exten => '+extension+',n,AGI(VD_amd.agi,${EXTEN})',
        'exten => '+extension+',n,AGI(agi-VDAD_ALL_outbound.agi,NORMAL-----LB-----${CONNECTEDLINE(name)})',
        'exten => '+extension+',n,Hangup()', END]
    new_block = newline.join(block_lines)+newline
    if starts:
        # Markers alone do not authorize discarding custom routing inside them.
        # Accept only the published predecessor or this release's exact body.
        existing_body = [line.strip() for line in block.splitlines() if line.strip()]
        legacy_body = [line for line in block_lines
                       if ',n,Playback(' not in line and ',n,NoOp(' not in line]
        if existing_body not in (block_lines, legacy_body):
            raise ValueError('Customized managed block; use --script-only or review manually')
        result = ''.join(lines[:first])+new_block+''.join(lines[last+1:])
    else:
        result = original + ('' if original.endswith('\n') else newline) + newline + new_block
    if not include_contexts:
        pattern = r'(^\s*\['+re.escape(context)+r'\][^\r\n]*)(\r?\n|$)'
        result = re.sub(pattern, lambda m: m.group(1)+newline+
                        'include => amd-selfhosted   ; self-hosted AMD (install_amd_client.sh)'+newline,
                        result, count=1, flags=re.M)
    return result


def main():
    p = argparse.ArgumentParser()
    p.add_argument('--source', required=True)
    p.add_argument('--extensions', required=True)
    p.add_argument('--stage', required=True)
    p.add_argument('--endpoint', required=True)
    p.add_argument('--extension', required=True)
    p.add_argument('--context', required=True)
    p.add_argument('--agi-dir', required=True)
    p.add_argument('--preroll', default='sip-silence')
    p.add_argument('--script-only', action='store_true')
    a = p.parse_args()
    validate(a.endpoint, a.extension, a.context, a.agi_dir, a.preroll)
    with open(a.source, encoding='utf-8') as f:
        source = f.read()
    source, n = re.subn(r'^WS_ENDPOINT = .*$',
                        lambda m: 'WS_ENDPOINT = os.environ.get("AMD_WS_URL", '+repr(a.endpoint)+')',
                        source, flags=re.M)
    if n != 1:
        raise ValueError('Expected exactly one WS_ENDPOINT assignment')
    compile(source, a.source, 'exec')
    with open(a.extensions, encoding='utf-8', newline='') as f:
        original = f.read()
    result = original if a.script_only else render_dialplan(original, a.extension, a.context, a.agi_dir, a.preroll)
    for name, content in [('amd_agi_client.py', source), ('extensions.conf', result)]:
        with open(os.path.join(a.stage, name), 'x', encoding='utf-8', newline='') as f:
            f.write(content)


if __name__ == '__main__':
    main()
AMD_INSTALL_PY
log "Validated candidate staged at $BACKUP_DIR/staged"
if [[ "$CHECK_ONLY" == 1 ]]; then
  log "CHECK ONLY: no installed client, dialplan or service was changed."
  exit 0
fi
cp -p "$EXTCONF" "$BACKUP_DIR/extensions.conf.before"
HAD_CLIENT=0
if [[ -e "$AGI_DIR/amd_agi_client.py" ]]; then
  cp -p "$AGI_DIR/amd_agi_client.py" "$BACKUP_DIR/amd_agi_client.py.before"
  HAD_CLIENT=1
fi
rollback(){
  local result=$?
  trap - ERR
  set +e
  log "Install failed; restoring saved files from $BACKUP_DIR"
  if [[ "$HAD_CLIENT" == 1 ]]; then
    cp -p "$BACKUP_DIR/amd_agi_client.py.before" "$AGI_DIR/amd_agi_client.py"
  elif [[ -f "$AGI_DIR/amd_agi_client.py" ]]; then
    mv "$AGI_DIR/amd_agi_client.py" "$BACKUP_DIR/amd_agi_client.py.failed"
  fi
  if [[ "$SCRIPT_ONLY" == 0 ]]; then
    cat "$BACKUP_DIR/extensions.conf.before" > "$EXTCONF"
    asterisk -rx 'dialplan reload'
  fi
  exit "$result"
}
trap rollback ERR
install -m 0755 "$BACKUP_DIR/staged/amd_agi_client.py" "$AGI_DIR/amd_agi_client.py"
if [[ "$SCRIPT_ONLY" == 0 ]]; then
  cat "$BACKUP_DIR/staged/extensions.conf" > "$EXTCONF"
  asterisk -rx 'dialplan reload'
  DIALPLAN="$(asterisk -rx "dialplan show $EXT@$CTX")"
  printf '%s\n' "$DIALPLAN" | grep -F 'amd_agi_client.py' >/dev/null
  printf '%s\n' "$DIALPLAN" | grep -F "Playback($PREROLL)" >/dev/null
fi
trap - ERR
log "Installed. Backups: $BACKUP_DIR"
log "No campaigns were switched. Verify a canary's PLAYBACKSTATUS and AMDSTATUS before routing traffic."
if [[ "$SCRIPT_ONLY" == 1 ]]; then
  log "SCRIPT ONLY: preserve/add Playback($PREROLL) immediately before your existing EAGI call manually."
fi
log "No-audio: MACHINE after 5 seconds of monitoring; server's existing 4-second policy is unchanged."
