#!/bin/bash
# Review and approve what a project declares it needs.
#
# A project declares its requirements in .dejima/{dependencies,
# allowed-domains}, which are committed and shared - but declaring grants
# nothing. This is where a human turns a declaration into an approval.
#
# Usage: dejima-project <status|approve> [<path>]
set -euo pipefail

# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
    -h|--help)
        cat <<'DEJIMA_PROJECT_HELP'
dejima-project - review and approve what a project declares it needs

Usage: dejima-project <status|approve> [path]

  status [path]    what the project declares, and whether each entry is
                   approved, pending, rejected as malformed, or beyond the cap
  approve [path]   approve everything pending, after showing it and asking

  path defaults to the current directory.

A project declares its requirements in files inside its own checkout:

  .dejima/dependencies      packages its image should carry
  .dejima/allowed-domains   hosts its sessions may reach
  .dejima/corpus            rules about how its history is recorded
  .dejima/tools             optional tools its sessions should have

Those are declarations, not grants. They live in the workspace, which a session
can write, so nothing they contain takes effect until it is approved here and
recorded in a store outside the container's reach. That is the whole point of
the split: a session can ask, and only an operator can agree.

An entry that fails validation is reported as rejected and never offered for
approval, so a malformed declaration cannot be waved through by answering yes.

approve is interactive and shows everything before it asks once.

See docs/configuration.md.
DEJIMA_PROJECT_HELP
        exit 0 ;;
esac

SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"

action="${1:-}"
project="${2:-$PWD}"
[ -n "$action" ] || { echo "Usage: dejima-project <status|approve> [<path>]" >&2; exit 1; }
project="$(cd "$project" && pwd)"

decl_dir="$project/.dejima"
store="$(resolve_project_store "$project")"

# Approved entries for one kind, tagged with the store they came from, so
# status can say where a grant actually lives rather than merging both into an
# anonymous list.
#
# approved_entries's exit status is captured from a plain command
# substitution, not from a `while read < <(...)` piped straight into the tag
# loop - a corrupt store that makes approved_entries fail before printing
# anything leaves that pattern's while loop with zero iterations, and a while
# loop that never ran its body reports success regardless of what the command
# feeding it did, which would turn a corrupt-store failure into a silent
# "nothing approved" here.
approved_tagged() {
    local kind="$1" entries e
    entries="$(approved_entries "$(config_root)" "$kind")" || return 1
    while IFS= read -r e; do
        [ -n "$e" ] || continue
        printf 'global %s\n' "$e"
    done <<< "$entries"
    [ -n "$store" ] || return 0
    entries="$(approved_entries "$store" "$kind")" || return 1
    while IFS= read -r e; do
        [ -n "$e" ] || continue
        printf 'project %s\n' "$e"
    done <<< "$entries"
    return 0
}

# Prints "<state> <entry>" per declared entry: approved / pending / rejected.
# Used by `approve`, which acts on pending entries only and has no use for the
# approved-but-undeclared ones `report` (below) exists to surface.
classify() {
    local kind="$1" validator="$2" approved state entry
    approved="$(approved_tagged "$kind" | cut -d' ' -f2-)" || return 1
    while read -r state entry; do
        case "$state" in
            rejected)  printf 'rejected %s\n' "$entry" ;;
            truncated) printf 'truncated %s\n' "$entry" ;;
            ok)
                if grep -qxF "$entry" <<< "$approved"; then
                    printf 'approved %s\n' "$entry"
                else
                    printf 'pending %s\n' "$entry"
                fi
                ;;
        esac
    done < <(read_declared "$decl_dir/$kind" "$validator")
    return 0
}

# The status view: the union of what the project declares and what is
# actually approved. Declarations alone were the bug (issue #61) - an
# approval recorded with `dejima-allow --project` has no declaration behind
# it and was therefore invisible here, which reads as "the command did
# nothing".
#
# Entries are already validated (read_declared/approved_entries refuse
# anything else), so the plain string comparisons below are enough - no
# entry can contain a character that would make one domain/spec look like
# another.
report() {
    local kind="$1" validator="$2" approved state entry src tag e seen=""
    approved="$(approved_tagged "$kind")" || return 1
    while read -r state entry; do
        case "$state" in
            rejected)  printf '  rejected  %s\n' "$entry" ;;
            truncated) printf '  truncated %s entries declared; only the first %s considered\n' \
                              "$entry" "$DEJIMA_MAX_DECLARED" ;;
            ok)
                src=""
                while read -r tag e; do
                    [ "$e" = "$entry" ] || continue
                    src="$tag"
                    break
                done <<< "$approved"
                if [ -n "$src" ]; then
                    printf '  approved  %s (%s)\n' "$entry" "$src"
                    seen="$seen$entry"$'\n'
                else
                    printf '  pending   %s (declared, not approved)\n' "$entry"
                fi
                ;;
        esac
    done < <(read_declared "$decl_dir/$kind" "$validator")
    # Approvals with no declaration behind them - reported so an approval
    # nothing declares is still visible. seen accumulates here too, or an entry approved in both stores (global
    # and project) and undeclared in either would print once per store.
    while read -r tag e; do
        [ -n "$e" ] || continue
        grep -qxF "$e" <<< "$seen" && continue
        printf '  approved  %s (%s, not declared)\n' "$e" "$tag"
        seen="$seen$e"$'\n'
    done <<< "$approved"
    return 0
}

case "$action" in
    status)
        echo "Project: $project"
        echo "Project approvals: ${store:-<none yet>}"
        echo "Global approvals: $(config_root)"
        echo
        echo "dependencies:"
        report dependencies valid_dep_spec
        echo "allowed-domains:"
        report allowed-domains valid_permitted_domain
        echo "corpus:"
        report corpus valid_corpus_rule
        echo "tools:"
        report tools valid_tool_name
        ;;
    approve)
        pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
        pending_doms=$(classify allowed-domains valid_permitted_domain | sed -n 's/^pending //p')
        pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
        pending_tools=$(classify tools valid_tool_name | sed -n 's/^pending //p')
        rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_permitted_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
        trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
        trunc_doms=$(classify allowed-domains valid_permitted_domain | sed -n 's/^truncated //p')
        if [ -n "$trunc_deps" ]; then
            echo "Warning: dependencies declares $trunc_deps entries; only the first $DEJIMA_MAX_DECLARED were considered." >&2
        fi
        if [ -n "$trunc_doms" ]; then
            echo "Warning: allowed-domains declares $trunc_doms entries; only the first $DEJIMA_MAX_DECLARED were considered." >&2
        fi
        if [ -n "$rejected" ]; then
            echo "Refusing to approve these malformed entries (fix the declaration):" >&2
            sed 's/^/  /' <<< "$rejected" >&2
            echo >&2
        fi
        if [ -z "$pending_deps" ] && [ -z "$pending_doms" ] && [ -z "$pending_corpus" ] \
            && [ -z "$pending_tools" ]; then
            echo "Nothing pending approval for $project."
            exit 0
        fi
        echo "Declared but not approved for $project:"
        [ -n "$pending_deps" ] && printf '  dependency: %s\n' $pending_deps
        [ -n "$pending_doms" ] && printf '  domain:     %s\n' $pending_doms
        [ -n "$pending_corpus" ] && printf '  corpus:     %s\n' $pending_corpus
        # Named as what it is rather than as a package: approving one adds a
        # program to the image every session on this project then has on
        # PATH. dejima hardens what it ships (docs/tools.md), which is what
        # makes this a shorter conversation than a dependency - but it is
        # still a change to what the sandbox contains.
        [ -n "$pending_tools" ] && printf '  tool:       %s (installed into the image)\n' $pending_tools
        echo
        printf 'Approve all of the above? [y/N] '
        read -r reply || reply=n
        case "$reply" in
            y|Y) ;;
            *) echo "Nothing approved."; exit 0 ;;
        esac
        # Both loops collect instead of letting set -e abort. Neither CLI is
        # called with --container, so neither touches a running container and
        # there is no live patch to succeed or fail here - but each can still
        # fail on its own (an unwritable store, a slug collision, a corrupt
        # approval list), and a bare call would abort the whole script on the
        # first failure, silently skipping every remaining entry - the deps
        # after it AND all the domains - with no summary at all. A failed
        # declaration is deliberately NOT in that list: both CLIs swallow one
        # and still exit 0, because the approval is what grants and it is
        # already written by then. What a failure MEANS is the
        # other half: the approval itself did not happen, so neither report
        # below may call an entry approved when nothing was recorded.
        dep_failed=""
        dep_approved=0
        for spec in $pending_deps; do
            if "$SCRIPT_DIR/dejima-dep" add --project "$project" "$spec"; then
                dep_approved=1
            else
                dep_failed="$dep_failed $spec"
            fi
        done
        failed=""
        dom_approved=0
        for dom in $pending_doms; do
            if "$SCRIPT_DIR/dejima-allow" --project "$project" "$dom"; then
                dom_approved=1
            else
                failed="$failed $dom"
            fi
        done
        # Written through approve_entry directly, unlike the two above. Those
        # go through their own CLIs because approving them can also patch a
        # RUNNING container's firewall or image; a corpus rule changes what the
        # next session is told and nothing about a live one, so there is
        # nothing to patch and no CLI to route through.
        tool_failed=""
        tool_approved=0
        for tool in $pending_tools; do
            if approve_entry "$(ensure_project_store "$project")" tools "$tool"; then
                tool_approved=1
            else
                tool_failed="$tool_failed $tool"
            fi
        done
        corpus_failed=""
        corpus_approved=0
        for rule in $pending_corpus; do
            if approve_entry "$(ensure_project_store "$project")" corpus "$rule"; then
                corpus_approved=1
            else
                corpus_failed="$corpus_failed $rule"
            fi
        done
        echo
        if [ -n "$corpus_failed" ]; then
            echo "NOT approved for $project (could not record):$corpus_failed" >&2
        fi
        if [ "$corpus_approved" = 1 ]; then
            echo "Approved corpus rules for $project. They take effect at the next launch."
        fi
        if [ -n "$tool_failed" ]; then
            echo "NOT approved for $project (could not record):$tool_failed" >&2
        fi
        if [ "$tool_approved" = 1 ]; then
            echo "Approved tools for $project. The image rebuilds on the next launch."
        fi
        if [ -n "$dep_failed" ]; then
            echo "NOT approved for $project (dejima-dep failed):$dep_failed" >&2
            echo "Fix the cause and re-run; nothing was recorded for those." >&2
        fi
        if [ -n "$failed" ]; then
            echo "NOT approved for $project (dejima-allow failed):$failed" >&2
            echo "Fix the cause and re-run; nothing was recorded for those." >&2
        fi
        # Only when something actually landed: a run that failed every entry
        # must not follow "NOT approved" with a line that reads as success.
        if [ "$dep_approved" -eq 1 ] || [ "$dom_approved" -eq 1 ]; then
            echo "Restart the sandbox for the approved entries to take effect."
        fi
        # `dejima-project approve ~/p && dejima` is the obvious way to chain
        # these, and exiting 0 after recording nothing would walk that chain
        # straight into a session with none of the approvals the user just
        # confirmed.
        if [ -n "$dep_failed" ] || [ -n "$failed" ]; then
            exit 1
        fi
        ;;
    *)
        echo "Usage: dejima-project <status|approve> [<path>]" >&2
        exit 1
        ;;
esac
