#!/usr/bin/env bash
#
# 3E Egitim — produces a masked dump (AD-16, plan chunk4 §4.6).
#
# Production data never leaves production in the raw. This takes a dump, masks
# it in a scratch database and writes the masked result. Staging, local prod and
# dev copies are fed from that output and from nothing else.
#
# Usage:
#   deployment/scripts/anonymize-dump.sh <source-dump.sql> <output-dump.sql>
#
# ⚠ THIS REFUSES TO RUN IN PRODUCTION. Masking works in place —
# `anon.anonymize_database()` really rewrites the tables — so running it against
# production would be data loss, not a privacy measure. The environment check is
# the first thing here and it has no override.

set -Eeuo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RULES="${ROOT_DIR}/deployment/postgresql/anon-rules.sql"

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'

die() { echo -e "${RED}$*${NC}" >&2; exit 1; }

# ---------------------------------------------------------------------------
# 1) The environment gate — before anything else
# ---------------------------------------------------------------------------
# APP_ENV is read from the file as well as the shell, because the shell running
# this may not carry the application's environment.
app_env="${APP_ENV:-}"

if [[ -z "${app_env}" && -f "${ROOT_DIR}/.env" ]]; then
    app_env="$(grep -E '^APP_ENV=' "${ROOT_DIR}/.env" | head -1 | cut -d= -f2- | tr -d '"'"'"'[:space:]')"
fi

case "${app_env}" in
    production|prod)
        die "REFUSED: APP_ENV=${app_env}. Masking rewrites data in place; it cannot run against production."
        ;;
esac

if [[ "${ALLOW_PRODUCTION_ANONYMIZE:-}" == "1" ]]; then
    die "REFUSED: this script has no production escape hatch. ALLOW_PRODUCTION_ANONYMIZE is deliberately unsupported."
fi

# ---------------------------------------------------------------------------
# 2) Inputs
# ---------------------------------------------------------------------------
SOURCE_DUMP="${1:-}"
OUTPUT_DUMP="${2:-}"

[[ -n "${SOURCE_DUMP}" && -n "${OUTPUT_DUMP}" ]] \
    || die "Usage: $(basename "$0") <source-dump.sql> <output-dump.sql>"
[[ -f "${SOURCE_DUMP}" ]] || die "Source dump not found: ${SOURCE_DUMP}"
[[ -f "${RULES}" ]] || die "Masking rules not found: ${RULES}"

ENV_FILE="${ROOT_DIR}/.env"
[[ -f "${ENV_FILE}" ]] || die "Missing ${ENV_FILE}"
set -a; source "${ENV_FILE}"; set +a

: "${DB_HOST:?}" ; : "${DB_DIRECT_PORT:?}" ; : "${DB_ADMIN_USERNAME:?}" ; : "${DB_ADMIN_PASSWORD:?}"

WORK_DB="anon_scratch_$$"

psql_admin() {
    PGPASSWORD="${DB_ADMIN_PASSWORD}" psql \
        --host "${DB_HOST}" --port "${DB_DIRECT_PORT}" \
        --username "${DB_ADMIN_USERNAME}" \
        --no-psqlrc --quiet --set ON_ERROR_STOP=1 "$@"
}

# ---------------------------------------------------------------------------
# Choosing pg_dump — THE VERSION HAS TO MATCH
# ---------------------------------------------------------------------------
# pg_dump cannot be older than the server; an older one stops with "server
# version mismatch". Shared development machines carry several PostgreSQL
# installations and the first pg_dump on PATH may be the wrong one (on this
# machine PATH had 17.10 while the server was 18.4). So the candidates are
# checked and the compatible one is chosen rather than assumed.
server_major="$(psql_admin --dbname postgres --tuples-only --no-align \
    --command "SELECT current_setting('server_version_num')::int / 10000")"

PG_DUMP=""
for candidate in \
    "$(command -v pg_dump || true)" \
    "$(dirname "$(readlink -f "$(command -v psql)")")/pg_dump" \
    /opt/homebrew/opt/libpq/bin/pg_dump \
    /usr/local/opt/libpq/bin/pg_dump
do
    [[ -x "${candidate}" ]] || continue

    candidate_major="$("${candidate}" --version | sed -E 's/.* ([0-9]+).*/\1/')"

    if [[ "${candidate_major}" -ge "${server_major}" ]]; then
        PG_DUMP="${candidate}"
        break
    fi
done

[[ -n "${PG_DUMP}" ]] || die "No pg_dump compatible with a PostgreSQL ${server_major} server was found; the installed ones are older."


cleanup() {
    # The scratch database goes away whatever happens: it holds unmasked
    # production data, and leaving it behind is precisely what this script
    # exists to avoid.
    psql_admin --dbname postgres --command "DROP DATABASE IF EXISTS \"${WORK_DB}\"" >/dev/null 2>&1 || true
}
trap cleanup EXIT

# ---------------------------------------------------------------------------
# 3) Load, mask, export
# ---------------------------------------------------------------------------
echo -e "${BLUE}→ scratch database ${WORK_DB}${NC}"
psql_admin --dbname postgres --command "CREATE DATABASE \"${WORK_DB}\"" >/dev/null

echo -e "${BLUE}→ loading the source dump${NC}"
psql_admin --dbname "${WORK_DB}" --file "${SOURCE_DUMP}" >/dev/null

echo -e "${BLUE}→ applying masking rules${NC}"
psql_admin --dbname "${WORK_DB}" --file "${RULES}" >/dev/null

echo -e "${BLUE}→ masking (in place)${NC}"
psql_admin --dbname "${WORK_DB}" --command "SELECT anon.anonymize_database()" >/dev/null

# ---------------------------------------------------------------------------
# 4) Coverage check — forgetting a rule must not be silent
# ---------------------------------------------------------------------------
# An unmasked sensitive column raises nothing; it simply goes out in the clear.
# So the number of labelled columns is checked here: zero means the rules never
# applied and the output is NOT safe.
labelled="$(psql_admin --dbname "${WORK_DB}" --tuples-only --no-align --command "
    SELECT count(*) FROM pg_seclabel l
      JOIN pg_class c ON c.oid = l.objoid
     WHERE l.provider = 'anon'")"

[[ "${labelled}" -gt 0 ]] || die "REFUSED: no column was masked (${labelled}). No output was produced."

# A count above zero only proves the rules RAN. It says nothing about a table
# added later whose rules nobody wrote — which is the failure this file warns
# about, and which chunk5 came within one review of shipping.
#
# So every column whose NAME is unambiguously sensitive must carry a label.
# Name based, like the audit masking: a column called `national_id` is one
# whatever table it turns up in. Ambiguous names (`name`, `title`) stay out of
# the list and are handled by explicit rules instead.
unlabelled="$(psql_admin --dbname "${WORK_DB}" --tuples-only --no-align --command "
    SELECT string_agg(c.relname || '.' || a.attname, ', ' ORDER BY c.relname, a.attname)
      FROM pg_attribute a
      JOIN pg_class c ON c.oid = a.attrelid
      JOIN pg_namespace n ON n.oid = c.relnamespace
     WHERE n.nspname = 'public'
       AND c.relkind = 'r'
       AND a.attnum > 0
       AND NOT a.attisdropped
       AND a.attname IN (
             'email', 'email_hash', 'phone', 'national_id', 'national_id_hash', 'password',
             'remember_token', 'token', 'token_hash', 'ip_address', 'payload',
             'two_factor_secret', 'two_factor_recovery_codes'
           )
       AND NOT EXISTS (
             SELECT 1 FROM pg_seclabel l
              WHERE l.provider = 'anon'
                AND l.objoid = c.oid
                AND l.objsubid = a.attnum
           )")"

if [[ -n "${unlabelled}" ]]; then
    die "REFUSED: sensitive columns carry no masking rule: ${unlabelled}. Add them to ${RULES}."
fi

echo -e "${BLUE}→ writing the masked dump${NC}"
PGPASSWORD="${DB_ADMIN_PASSWORD}" "${PG_DUMP}" \
    --host "${DB_HOST}" --port "${DB_DIRECT_PORT}" \
    --username "${DB_ADMIN_USERNAME}" \
    --dbname "${WORK_DB}" \
    --no-owner --no-privileges \
    --file "${OUTPUT_DUMP}"

echo -e "${GREEN}✓ masked dump ready: ${OUTPUT_DUMP}${NC}"
echo -e "${YELLOW}  ${labelled} columns masked. A new sensitive column means a new rule in ${RULES}.${NC}"
