#!/usr/bin/env bash
# justfiles/_secrets_lib.sh — shared loader for secrets-* recipes (ADR-017).
# Sourced from each recipe via: source "{{justfile_directory()}}/justfiles/_secrets_lib.sh"
#
# After sourcing, the following variables are set and validated:
#   PROJECT_ROOT, PROJECT_NCL, IMPORT_PATH
#   PROJECT_JSON       — exported project.ncl as JSON (jq-able)
#   GLOBAL_JSON        — exported ~/.config/ontoref/config.ncl as JSON
#   SLUG               — project.ncl::slug
#   VAULT_ID           — project.ncl::sops.vault_id, fallback to SLUG
#   VAULT_TOOL         — project.ncl::sops.vault_backend (restic|kopia)
#   REGISTRY           — project.ncl::sops.registry_endpoint
#   MASTER_KEY         — project override → ~/.config/ontoref/config.ncl::vault.master_key_path
#   COSIGN_KEY_PATH    — global vault.cosign.key_path (signing)
#   COSIGN_PUB_PATH    — global vault.cosign.pub_path (verification)
#   VAULT_DIR          — ~/.config/ontoref/vaults/<vault_id>
#   ACCESS_SOPS        — $VAULT_DIR/access.sops.yaml

set -euo pipefail

PROJECT_ROOT="${ONTOREF_PROJECT_ROOT:-$(pwd)}"
PROJECT_NCL="${PROJECT_ROOT}/.ontoref/project.ncl"
[[ -f "$PROJECT_NCL" ]] || { echo "No .ontoref/project.ncl found at $PROJECT_ROOT"; exit 1; }

IMPORT_PATH="${NICKEL_IMPORT_PATH:-${PROVISIONING:-}}"
PROJECT_JSON=$(nickel export --import-path "$IMPORT_PATH" "$PROJECT_NCL")
GLOBAL_JSON=$(nickel export --import-path "$IMPORT_PATH" "$HOME/.config/ontoref/config.ncl" 2>/dev/null || echo '{}')

SLUG=$(echo "$PROJECT_JSON" | jq -r '.slug // empty')
VAULT_ID=$(echo "$PROJECT_JSON" | jq -r '.sops.vault_id // empty')
VAULT_ID="${VAULT_ID:-$SLUG}"
VAULT_TOOL=$(echo "$PROJECT_JSON" | jq -r '.sops.vault_backend // "restic"')
REGISTRY=$(echo "$PROJECT_JSON" | jq -r '.sops.registry_endpoint // empty')

MASTER_KEY=$(echo "$PROJECT_JSON" | jq -r '.sops.master_key_path // empty')
if [[ -z "$MASTER_KEY" ]]; then
    MASTER_KEY=$(echo "$GLOBAL_JSON" | jq -r '.vault.master_key_path // empty')
fi

COSIGN_KEY_PATH=$(echo "$GLOBAL_JSON" | jq -r '.vault.cosign.key_path // empty')
COSIGN_PUB_PATH=$(echo "$GLOBAL_JSON" | jq -r '.vault.cosign.pub_path // empty')
COSIGN_TLOG=$(echo "$GLOBAL_JSON" | jq -r '.vault.cosign.tlog // false')
COSIGN_SIGNING_CONFIG=$(echo "$GLOBAL_JSON" | jq -r '.vault.cosign.signing_config_path // empty')

# Sign/verify flags derived from tlog policy. When tlog=false we sign without
# Rekor (cosign 2+ requires --signing-config that excludes rekorTlogUrls/Config)
# and verify with --insecure-ignore-tlog so missing entries do not fail.
# Symmetric — sign and verify must agree.
if [[ "$COSIGN_TLOG" == "true" ]]; then
    COSIGN_SIGN_FLAGS=""
    COSIGN_VERIFY_FLAGS=""
else
    if [[ -z "$COSIGN_SIGNING_CONFIG" ]]; then
        echo "vault.cosign.tlog=false but signing_config_path not set — generate one and declare it" >&2
        exit 1
    fi
    [[ -f "$COSIGN_SIGNING_CONFIG" ]] || {
        echo "Cosign signing-config not found at $COSIGN_SIGNING_CONFIG" >&2
        exit 1
    }
    COSIGN_SIGN_FLAGS="--signing-config $COSIGN_SIGNING_CONFIG"
    COSIGN_VERIFY_FLAGS="--insecure-ignore-tlog"
fi

VAULT_DIR="$HOME/.config/ontoref/vaults/$VAULT_ID"
ACCESS_SOPS="$VAULT_DIR/access.sops.yaml"

[[ -n "$VAULT_ID" ]]   || { echo "Cannot determine vault_id (slug empty and sops.vault_id absent)"; exit 1; }
[[ -n "$MASTER_KEY" ]] || { echo "master_key_path not declared (project.ncl::sops or ~/.config/ontoref/config.ncl::vault)"; exit 1; }
[[ -f "$MASTER_KEY" ]] || { echo "Master key (.kage) not found at $MASTER_KEY"; exit 1; }

# Auto-rotate audit log on every recipe invocation (call at end of this file
# after vault_rotate_audit_log is defined — bash does not hoist functions).

# Vault lock TTL (minutes). Past this, locks are considered abandoned and
# admin can force-close. Per ADR-017 G1 — cooperative lock atop ZOT ACL.
VAULT_LOCK_TTL_MIN=60
VAULT_LOCK_TAG="lock"   # OCI ref: src-vault/<id>:lock

# Audit log rotation thresholds. access.jsonl is rotated when EITHER applies:
#   size:  current file > VAULT_AUDIT_ROTATE_BYTES (default 10MB)
#   age:   current file mtime older than VAULT_AUDIT_ROTATE_DAYS (default 90)
# Rotated to access.jsonl.<UTC-ISO>.gz inside the same logs/ dir.
VAULT_AUDIT_ROTATE_BYTES=${VAULT_AUDIT_ROTATE_BYTES:-10485760}
VAULT_AUDIT_ROTATE_DAYS=${VAULT_AUDIT_ROTATE_DAYS:-90}

# Rotate $VAULT_DIR/logs/access.jsonl when size or age exceeds thresholds.
# Idempotent: noop when file absent or under both thresholds. Safe to call at
# the start of every secrets-* operation.
vault_rotate_audit_log() {
    local log="$VAULT_DIR/logs/access.jsonl"
    [[ -f "$log" ]] || return 0

    local size_bytes age_days now_epoch mtime_epoch should_rotate=0
    size_bytes=$(stat -f %z "$log" 2>/dev/null || stat -c %s "$log" 2>/dev/null || echo 0)
    [[ "$size_bytes" -gt "$VAULT_AUDIT_ROTATE_BYTES" ]] && should_rotate=1

    if [[ "$should_rotate" -eq 0 ]]; then
        now_epoch=$(date -u +%s)
        mtime_epoch=$(stat -f %m "$log" 2>/dev/null || stat -c %Y "$log" 2>/dev/null || echo "$now_epoch")
        age_days=$(( (now_epoch - mtime_epoch) / 86400 ))
        [[ "$age_days" -gt "$VAULT_AUDIT_ROTATE_DAYS" ]] && should_rotate=1
    fi

    if [[ "$should_rotate" -eq 1 ]]; then
        local stamp rotated
        stamp=$(date -u +%Y-%m-%dT%H-%M-%SZ)
        rotated="$VAULT_DIR/logs/access.jsonl.${stamp}.gz"
        gzip -c "$log" > "$rotated" && : > "$log"
        # Append a marker entry recording the rotation so audit chain stays continuous.
        printf '{"ts":"%s","actor":"%s","op":"audit-log-rotated","detail":"previous=%s size=%s"}\n' \
            "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-system}" \
            "access.jsonl.${stamp}.gz" "$size_bytes" >> "$log"
    fi
}

# Fetch current lock state from ZOT. Returns JSON {locked_by,actor,since,expires}
# or empty string if no lock or fetch fails. ZOT credentials must be in $TMPDIR_CFG/config.json.
# Caller provides $TMPDIR_CFG before invoking.
vault_lock_fetch() {
    local out
    out=$(DOCKER_CONFIG="$TMPDIR_CFG" oras manifest fetch \
        "${REGISTRY}/src-vault/${VAULT_ID}:${VAULT_LOCK_TAG}" 2>/dev/null) || return 0
    # Lock metadata is in the manifest annotations — extract our keys.
    echo "$out" | jq -c '{
        locked_by:       (.annotations."ontoref.vault.locked_by"       // ""),
        actor:           (.annotations."ontoref.vault.actor"           // ""),
        since:           (.annotations."ontoref.vault.since"           // ""),
        expires:         (.annotations."ontoref.vault.expires"         // ""),
        force_closed_by: (.annotations."ontoref.vault.force_closed_by" // "")
    }'
}

# Push a lock artifact with the given metadata. Requires $TMPDIR_CFG with ZOT creds.
# Args: locked_by actor since expires
vault_lock_push() {
    local locked_by="$1" actor="$2" since="$3" expires="$4"
    # oras rejects absolute paths — work in a tmp dir with relative filename.
    local lock_dir lock_file rc
    lock_dir=$(mktemp -d)
    lock_file="lock.json"
    # Lock metadata is in OCI annotations; the blob just needs ≥ 1 byte —
    # ZOT rejects empty blobs (returns 400 on the PUT). Use a minimal JSON
    # placeholder so the file is self-describing if pulled.
    printf '{"kind":"vault-lock"}\n' > "$lock_dir/$lock_file"
    set +e
    ( cd "$lock_dir" && DOCKER_CONFIG="$TMPDIR_CFG" oras push \
        "${REGISTRY}/src-vault/${VAULT_ID}:${VAULT_LOCK_TAG}" \
        --artifact-type "application/vnd.ontoref.vault.lock.v1" \
        --annotation "ontoref.vault.locked_by=${locked_by}" \
        --annotation "ontoref.vault.actor=${actor}" \
        --annotation "ontoref.vault.since=${since}" \
        --annotation "ontoref.vault.expires=${expires}" \
        "${lock_file}:application/vnd.ontoref.vault.lock.metadata.v1" ) >/dev/null 2>&1
    rc=$?
    set -e
    rm -rf "$lock_dir"
    return $rc
}

# Delete the lock artifact from ZOT. Requires $TMPDIR_CFG.
vault_lock_release() {
    DOCKER_CONFIG="$TMPDIR_CFG" oras manifest delete -f \
        "${REGISTRY}/src-vault/${VAULT_ID}:${VAULT_LOCK_TAG}" >/dev/null 2>&1 || true
}

# Build a $TMPDIR_CFG from the decrypted access.sops.yaml. Sets the global
# TMPDIR_CFG variable; caller is responsible for `rm -rf "$TMPDIR_CFG"` later.
vault_zot_config_open() {
    [[ -f "$ACCESS_SOPS" ]] || { echo "[lock] access.sops.yaml absent — bootstrap or sync first"; return 1; }
    local creds zu zp
    creds=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
    zu=$(echo "$creds" | grep '^zot_username:' | awk '{print $2}')
    zp=$(echo "$creds" | grep '^zot_password:' | awk '{print $2}')
    TMPDIR_CFG=$(mktemp -d)
    local b64
    b64=$(printf '%s:%s' "$zu" "$zp" | base64)
    printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY" "$b64" > "$TMPDIR_CFG/config.json"
    unset creds zu zp b64
}

# ── ADR-017 G2 — Impact analysis ───────────────────────────────────────────────
# vault_impact_report: list relative paths of *.sops.yaml files in the vault
# that have changed since the last restic snapshot. Empty list = no credential
# changes. Output one path per line, relative to $VAULT_DIR.
vault_impact_changed_creds() {
    local repo="$VAULT_DIR/repo"
    [[ -d "$repo" ]] || return 0
    local vk
    vk=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt --extract '["vault_key"]' "$ACCESS_SOPS" 2>/dev/null)
    [[ -n "$vk" ]] || return 0

    local snaps
    snaps=$(RESTIC_PASSWORD="$vk" restic -r "$repo" snapshots --json 2>/dev/null) || return 0
    local last_id
    last_id=$(echo "$snaps" | jq -r 'last | .id // empty' 2>/dev/null)
    [[ -n "$last_id" ]] || return 0

    # Take an in-memory snapshot of current files (no restic backup — too invasive
    # for a dry diff). Use a hash comparison instead: hash each *.sops.yaml in the
    # last snapshot via 'restic dump' and compare to current hash.
    local cur_dir snap_dir tmp_diff
    cur_dir="$VAULT_DIR"
    tmp_diff=$(mktemp)
    while IFS= read -r rel; do
        [[ -z "$rel" ]] && continue
        local cur_hash="" snap_hash=""
        if [[ -f "$cur_dir/$rel" ]]; then
            cur_hash=$(shasum -a 256 "$cur_dir/$rel" | awk '{print $1}')
        fi
        snap_hash=$(RESTIC_PASSWORD="$vk" restic -r "$repo" dump "$last_id" "$rel" 2>/dev/null \
            | shasum -a 256 | awk '{print $1}')
        if [[ "$cur_hash" != "$snap_hash" ]]; then
            echo "$rel" >> "$tmp_diff"
        fi
    done < <(find "$VAULT_DIR" -name "*.sops.yaml" -type f 2>/dev/null \
             | while read -r f; do echo "${f#${VAULT_DIR}/}"; done)
    cat "$tmp_diff"
    rm -f "$tmp_diff"
}

# vault_impact_registries: given a list of changed sops paths (relative), find
# which RegistryEntry IDs in the project manifest reference them via
# credential_sops or credential_sops_rw. Output: <entry_id>\t<sops_path>\t<field>
vault_impact_registries() {
    local manifest_ncl="$PROJECT_ROOT/.ontology/manifest.ncl"
    [[ -f "$manifest_ncl" ]] || return 0
    local manifest_json
    manifest_json=$(nickel export --import-path "$IMPORT_PATH" "$manifest_ncl" 2>/dev/null) || return 0
    local entries
    entries=$(echo "$manifest_json" | jq -c '.registry_provides.registries.registries // []' 2>/dev/null)
    [[ "$entries" != "null" && -n "$entries" ]] || return 0

    while IFS= read -r changed_full; do
        # Strip leading 'src-vault/' since credential_sops paths are relative to src-vault/.
        local rel="${changed_full#src-vault/}"
        echo "$entries" | jq -r --arg rel "$rel" '
            .[] | select(.credential_sops == $rel or .credential_sops_rw == $rel)
                | "\(.id)\t\($rel)\t\(if .credential_sops == $rel then "credential_sops" else "credential_sops_rw" end)"
        '
    done
}

# Final action of this lib: rotate audit log if needed. Idempotent.
vault_rotate_audit_log
