#!/bin/bash
# ==============================================================================================
# ================================= Decision Engine ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Behavior-driven scoring kernel for media automation. Sourced by consumer
# scripts — not run directly. Evaluates candidates, applies weighted scoring,
# temporal decay, and deduplication, then returns a verdict.
#
# Does NOT download media, search indexers, or manage applications. Has no
# side effects — no file writes, no API calls, no deletions.
#
# Currently paired with: Arrs_Stack/playback_aware_lidarr_discovery.sh
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# A library, not a program. Consumers source it and drive the pipeline themselves:
#
#   1. score_candidate()      — sum the four weighted component scores
#   2. apply_temporal_decay() — reduce by one unit per 30 days of age, floored at 0
#   3. is_duplicate_candidate() — check the consumer's own history file
#   4. make_decision()        — ACCEPT or REJECT against the consumer's threshold
#
# Every input is supplied by the caller and every output is returned to it. The engine holds
# no state between calls, reads no config, and never acts on its own verdict.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Domain-Agnostic Core
#   The engine itself has no knowledge of music vs TV vs movies. Consumers
#   define thresholds, weights, and strictness profiles — the engine just
#   scores and decides. This prevents cross-domain bias pollution: music
#   strictness cannot contaminate TV intake logic.
#
#   Lidarr consumers  → highly selective, quality-first discovery
#   Sonarr consumers  → balanced, family-aware episodic intake
#   Radarr consumers  → broader flexibility with intelligent filtering
#
# Temporal Decay
#   Old behavioral signals lose influence over time (one decay unit per 30
#   days). Prevents permanent genre lock-in, historical bias accumulation,
#   and dead-user score dominance.
#
# Consumer Owns the Decision
#   The engine returns ACCEPT/REJECT/SCORE. What happens next is entirely
#   the consumer's concern — the engine never acts on its own verdict.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# No Side Effects — Structural, Not Incidental
#   Writes no files, makes no API calls, deletes nothing, starts nothing. This is the
#   safeguard: a scoring mistake here can only ever produce a wrong number, never a wrong
#   action. Keep it that way — the moment this library acquires a side effect, every consumer
#   inherits it silently.
#
# No Root, No Lock, No detect_hosts — Deliberate
#   Correct for a sourced library and should not be "fixed" to match the executable scripts.
#   There is no state for a lock to protect, no privileged operation to gate, and no
#   host-specific config to alias. It runs entirely inside the caller's process.
#
# Caller Owns the Verdict
#   ACCEPT/REJECT is a return value, not an instruction. Nothing here can cause a candidate
#   to be added, removed, or downloaded — the consumer decides what a verdict means.
#
# Literal Duplicate Matching
#   is_duplicate_candidate() matches with grep -Fx, treating the candidate as data rather
#   than a pattern. Artist and title strings routinely contain regex metacharacters, and a
#   false positive here silently discards a genuinely new candidate.
#
# Decay Floors at Zero
#   apply_temporal_decay() clamps at 0, so an old signal can never become a negative score
#   that drags an otherwise-passing candidate below threshold.
#
# Integer Arithmetic Throughout
#   All scoring is integer. No floating point means no locale-dependent decimal parsing and
#   no rounding drift between hosts.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# None, by design. The engine reads no conf file and no environment variable.
#
# Thresholds, weights and strictness profiles live with the consumer — see
# LIDARR_DISCOVERY_* / SONARR_DISCOVERY_* / RADARR_DISCOVERY_* in master.conf. That is what
# keeps the core domain-agnostic: music strictness cannot leak into TV intake, because the
# engine never learns which domain it is scoring for.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# None — this file is sourced, never executed:
#
#   source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
#
# It has no argument parsing, no --dry-run and no --status, because it takes no action that
# a dry run could suppress.
#
# ==============================================================================================
# FUNCTIONS
# ==============================================================================================
#
#   score_candidate user_score popularity_score recency_score quality_score
#       Returns TOTAL_SCORE (integer sum). Consumer defines actual weight values.
#
#   evaluate_threshold score minimum
#       Returns 0 (pass) or 1 (fail). Used as: if evaluate_threshold ...
#
#   apply_temporal_decay score age_days
#       Returns adjusted score. Subtracts (age_days / 30), floor at 0.
#
#   is_duplicate_candidate candidate history_file
#       Returns 0 (duplicate found in history_file) or 1 (not found).
#
#   make_decision score threshold
#       Returns "ACCEPT" or "REJECT". Calls evaluate_threshold internally.
#
# ==============================================================================================

# ── score_candidate ───────────────────────────────────────────────────────────
score_candidate() {

    local user_score="${1:-0}"
    local popularity_score="${2:-0}"
    local recency_score="${3:-0}"
    local quality_score="${4:-0}"
    local TOTAL_SCORE

    TOTAL_SCORE=$(( \
        user_score + \
        popularity_score + \
        recency_score + \
        quality_score \
    ))

    echo "$TOTAL_SCORE"
}

# ── evaluate_threshold ───────────────────────────────────────────────────────
evaluate_threshold() {

    local score="$1"
    local minimum="$2"

    if (( score >= minimum )); then
        return 0
    fi

    return 1
}

# ── apply_temporal_decay ─────────────────────────────────────────────────────
apply_temporal_decay() {

    local score="$1"
    local age_days="$2"

    local decay=$(( age_days / 30 ))

    local adjusted=$(( score - decay ))

    (( adjusted < 0 )) && adjusted=0

    echo "$adjusted"
}

# ── is_duplicate_candidate ───────────────────────────────────────────────────
is_duplicate_candidate() {

    local candidate="$1"
    local history_file="$2"

    # -F -x, not "^$" anchors: the candidate is data, not a pattern. Interpolating it into a
    # regex makes every metacharacter in an artist or title active — "R.E.M." matches "RxExMy",
    # and an unbalanced bracket makes grep error out entirely. Either way the caller reads the
    # result as "already seen" and silently skips something genuinely new. -F disables regex,
    # -x anchors the whole line, which is exactly what the anchors were reaching for.
    grep -qiFx -- "$candidate" "$history_file" 2>/dev/null
}

# ── make_decision ─────────────────────────────────────────────────────────────
make_decision() {

    local score="$1"
    local threshold="$2"

    if evaluate_threshold "$score" "$threshold"; then
        echo "ACCEPT"
    else
        echo "REJECT"
    fi
}