#!/usr/bin/env bash

set -euo pipefail

# --- Helper functions ---
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
exists() { command -v "$1" >/dev/null 2>&1; }

# The package names in a list, without the comments that explain them.
#
# The lists are annotated -- which package exists for which settings page, why
# an exception was made -- and those annotations are for whoever reads the file
# next. dnf is not so forgiving: it does not ignore an argument it cannot
# match, it reports "No match for argument: #" and exits 1, and with `set -e`
# above that ends this stage on the first annotated list it reaches.
#
# It could not be seen from here. On a machine that already has everything, a
# re-run matches every real name and fails only on the comments; and every
# contract that reads these lists strips comments before comparing, so the
# tests were reading a file this script was not.
packages_in() {
	sed 's/#.*//' "$1" | tr "\n" " "
}

# Names a list asked for that still are not installed, so --skip-unavailable
# above can never silently shrink a list: a skipped font is a warning somebody
# reads, not an absence somebody debugs a month later.
report_missing() {
	local file="$1" name missing=()
	for name in $(packages_in "$file"); do
		# Three ways a list entry can be satisfied: it is a package name
		# (rpm -q), a capability another package provides (--whatprovides,
		# e.g. wget -> wget2-wget), or a bare command name provided as a file
		# path (command -v, e.g. awk -> /usr/bin/awk from gawk, which
		# --whatprovides misses because the provide is the path, not the word).
		rpm -q --whatprovides "$name" >/dev/null 2>&1 && continue
		command -v "$name" >/dev/null 2>&1 && continue
		missing+=("$name")
	done
	(( ${#missing[@]} > 0 )) && log "WARNING: not available on this machine: ${missing[*]}"
	return 0
}

# Runs something whose failure must not cost you the desktop.
#
# `set -e` above is right for the packages Panama cannot work without and wrong
# for everything else. A codec swap that finds nothing to swap, a group update
# renamed upstream, a third-party host that is down -- each of those used to end
# this stage wherever it happened to sit, and the desktop was installed near the
# bottom, so any one of them meant a machine with no Hyprland on it and a single
# line of dnf output to explain why.
#
# So the ordering rule for this file: anything that can fail for a reason
# outside this repository goes below the desktop, and goes through here.
# stdout only. Swallowing stderr here would hide the one line that says WHY a
# step was stepped over -- and worse, every one of these runs under sudo, whose
# password prompt is the thing you would be hiding on a machine that asks for
# one.
soft() {
	local what="$1"; shift
	"$@" >/dev/null || { log "$what did not complete; continuing"; softly_failed+=("$what"); }
}
softly_failed=()

# --- Defined Paths ---
# The default, not an assignment: ./install and link-dotfiles honor an exported
# PANAMA_PATH, and clobbering it here made a clone anywhere else source the
# extras catalog from a path that does not exist.
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"

# Reading the extras catalog, shared with `panama apps` so the two front doors
# cannot disagree about what a category contains.
# shellcheck source=../lib/extras-catalog
source "$PANAMA_PATH/setup/lib/extras-catalog"

# Which machine this is. A server takes the short path below: core tools,
# node, the agents -- no third-party repos, no desktop, no flatpaks.
# shellcheck source=../lib/machine-role
source "$PANAMA_PATH/setup/lib/machine-role"
ROLE="$(panama_role)"

# One list, installed the way every list is installed: --skip-unavailable so a
# single rotted name cannot cost the transaction, then report_missing so a
# skipped name is a warning somebody reads.
install_list() {
	local file="$PANAMA_PATH/setup/packages/$1" label="$2" packages
	if [[ -f "$file" ]]; then
		packages=$(packages_in "$file")
		log "Installing $label Packages"
		echo -e "Includes the following packages:"
		echo -e "$(<"$file")"
		sudo dnf install -y --skip-unavailable $packages > /dev/null
		report_missing "$file"
		log "$label packages installed!"
	else
		log "Package list was not in specified path: $file"
	fi
}

# --- Node and pnpm, through nvm ----------------------------------------------
#
# nvm is a shell function rather than a binary, so it has to be sourced before
# it can be used at all -- and its script reads variables that `set -u` above
# treats as fatal, so the strictness is lifted for exactly that source and put
# straight back.
#
# Deliberately not dnf's nodejs: config/bash/shell switches Node per project
# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving
# `nvm use` looking like it did nothing.
#
# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf
# package, so it travels with the version it belongs to instead of outliving it.
setup_node() {
	if [[ -s /etc/profile.d/nvm.sh ]]; then
		log "Installing the latest Node LTS through nvm"
		set +u
		# shellcheck source=/dev/null
		source /etc/profile.d/nvm.sh
		if nvm install --lts >/dev/null 2>&1; then
			nvm alias default 'lts/*' >/dev/null 2>&1 || true
			npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); }
			log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)"
		else
			log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)")
		fi
		set -u
	else
		log "nvm is not installed, so Node was not set up"
	fi
}

# --- Applications no repository packages -------------------------------------
#
# Everything else Panama installs comes from dnf or Flathub. These do not
# exist in either, so each is an explicit exception with a reason, and each is
# skipped when already present so a re-run costs nothing.
#
# None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4,
# a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An
# installer that resolves "latest" keeps working; one that names a version rots.
#
# A failure here is logged and stepped over rather than aborting: an
# unreachable third-party host should not cost the rest of the run.

# Bun: the JavaScript runtime and package manager. No RPM, no flatpak.
install_bun() {
	if [[ -x "$HOME/.bun/bin/bun" ]]; then
		log "Bun already installed at \"$HOME/.bun/bin/bun\""
	else
		log "Installing Bun via curl..."
		curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); }
	fi
}

# Claude Code: Anthropic's CLI. The official installer keeps itself updated
# afterwards, so this runs once and then never needs to again.
install_claude_code() {
	if command -v claude >/dev/null 2>&1; then
		log "Claude Code already installed at \"$(command -v claude)\""
	else
		log "Installing Claude Code via the official installer..."
		curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); }
	fi
}

# Codex: OpenAI's CLI. Distributed through npm, which is why this runs after
# setup_node -- the nvm-managed Node is the one it should land in.
install_codex() {
	if command -v codex >/dev/null 2>&1; then
		log "Codex already installed at \"$(command -v codex)\""
	elif command -v npm >/dev/null 2>&1; then
		log "Installing Codex via npm..."
		npm install -g @openai/codex >/dev/null 2>&1 || { log "Codex install failed; skipping"; softly_failed+=("Codex"); }
	else
		log "npm is not available, so Codex was not installed"; softly_failed+=("Codex")
	fi
}

# --- What was stepped over ---------------------------------------------------
#
# Tolerating a failure is only better than aborting on it if somebody is told.
# The whole point of surviving a soft failure is that the rest gets installed
# anyway -- but a machine missing something should say so once, here, rather
# than be discovered a week later.
report_soft_failures() {
	if (( ${#softly_failed[@]} > 0 )); then
		log "Installed, but these were stepped over:"
		printf '         - %s\n' "${softly_failed[@]}"
		log "None of them stops the machine, but this run is not recorded as"
		log "complete, so the next 'panama update' tries them again."
		# A step that did not complete has not happened. Exiting non-zero is what
		# keeps ./install from stamping the packages hash over the gaps -- stamped,
		# they would never be retried (the hash-skip would say nothing changed).
		exit 1
	fi
}

# --- The server path ---------------------------------------------------------
#
# Everything a server runs is above this line plus the lists it installs. No
# RPM Fusion, no Terra, no COPR, no multimedia, no flatpaks: those exist for a
# desktop, and every one of them is a network dependency and a failure mode a
# headless machine has no reason to carry.
if [[ "$ROLE" == server ]]; then
	echo -e "\n--- Installing packages (server) ---"
	log "Updating all packages. This may take a while"
	sudo dnf update -y --refresh > /dev/null
	install_list core-packages "Core"
	install_list server-packages "Server"
	setup_node
	install_bun
	install_claude_code
	install_codex
	report_soft_failures
	exit 0
fi

echo -e "\n--- Installing Repositories ---"
log "Installing RPM Fusion Free and Nonfree Repositories"
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null
log "Enabling Fedora Cisco OpenH264 Repository"
# soft: this repo does not exist on every spin, and its absence must not cost
# the desktop -- the ordering rule at soft()'s definition applies to the
# repository extras just as much as to the codec swaps below.
soft "enabling the openh264 repository" sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1
log "Installing RPM Fusion AppStream Metadata"
soft "the core group update" sudo dnf update @core -y
soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstream-data
# Terra bootstraps itself: --repofrompath defines a throwaway repo just long
# enough to install terra-release, which then writes the real /etc/yum.repos.d
# entry. Doing that a second time is not harmless -- dnf5 refuses the whole
# transaction with 'Id is present more than once in the configuration', because
# the throwaway id collides with the one terra-release already installed.
#
# That is what killed a re-run on a machine Terra had already reached: this sits
# in the repository section, above everything, so `set -e` ended the stage
# before a single package was considered. An installer whose second run does
# less than its first is worse than one that never ran.
if rpm -q terra-release >/dev/null 2>&1; then
	log "Terra repository already installed"
else
	log "Installing Terra Repository"
	sudo dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release > /dev/null
fi

echo -e "\n--- Installing relevant packages ---"
log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null

# --- Install the shared core, then the desktop-only lists ---
# --skip-unavailable throughout (inside install_list): dnf5 refuses a whole
# transaction over one missing name, so a single rotted entry used to cost
# every package in a list -- and the desktop below never installed. The
# skipped names are reported afterwards rather than silently dropped.
install_list core-packages "Core"
install_list initial-packages "Initial"
install_list desktop-packages "Desktop"

# --- Install the Hyprland desktop ---
#
# Directly after desktop-packages, which is what supplies the dnf plugin that
# `dnf copr` needs, and deliberately before anything optional. This is the one
# thing on the list that Panama is; a machine that gets only this far is a
# machine you can log into, and every step below it is a convenience.
#
# Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper.
HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages"
if [[ -f "$HYPR_FILE" ]]; then
	log "Enabling Hyprland COPR"
	sudo dnf copr enable -y lionheartp/Hyprland > /dev/null
	HYPR_PACKAGES=$(packages_in "$HYPR_FILE")
	log "Installing Hyprland desktop packages"
	echo -e "Includes the following packages:"
	echo -e "$(<"$HYPR_FILE")"
	sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null
	log "Hyprland packages installed!"
else
	log "Package list was not in specified path: $HYPR_FILE"
fi

# Said out loud, because the failure this guards against was silent. The stage
# used to die somewhere above this point and report one red line among twenty
# minutes of scrollback, and the machine looked installed until you tried to log
# into it.
if rpm -q hyprland >/dev/null 2>&1; then
	log "Hyprland $(rpm -q --queryformat '%{VERSION}' hyprland) is installed."
else
	log "Hyprland is NOT installed. Nothing below this point will give you a desktop."
	exit 1
fi

# --- Codecs and multimedia ---------------------------------------------------
#
# Below the desktop and every one of them non-fatal, because none is a
# dependency of it and each can fail for reasons that have nothing to do with
# this repository -- a swap whose source package this spin never shipped, a
# group renamed upstream between Fedora releases.
#
# A trailing `&& sync` on the group update previously meant a failure was exempt
# from set -e as well (bash does not apply -e to the left of a && list), so it
# went unreported rather than being deliberately tolerated. It is deliberate now.

log "Updating core, multimedia, and sound-and-video groups"
soft "the multimedia group update" \
    sudo dnf4 groupupdate -y 'core' 'multimedia' 'sound-and-video' \
    --setop='install_weak_deps=False' \
    --exclude='PackageKit-gstreamer-plugin' \
    --allowerasing
sync
log "Swapping ffmpeg-free for ffmpeg"
soft "the ffmpeg swap" sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing
log "Swapping mesa-va-drivers for mesa-va-drivers-freeworld"
soft "the mesa driver swap" sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld
log "Upgrading Multimedia group with optional packages"
soft "the optional Multimedia upgrade" sudo dnf4 group upgrade -y --with-optional Multimedia
log "Installing GStreamer plugins (bad, good, base)"
soft "the GStreamer plugins" \
    sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \
    --exclude=gstreamer1-plugins-bad-free-devel

# --- Install Development Packages needed for Neovim ---
DEV_FILE="$PANAMA_PATH/setup/packages/development-packages"
if [[ -f "$DEV_FILE" ]]; then
	DEV_PACKAGES=$(packages_in "$DEV_FILE")
	log "Installing Development Packages. Mostly for Neovim."
  echo -e "Includes the following packages:"
  echo -e "$(<"$DEV_FILE")"
  soft "the development packages" sudo dnf install -y $DEV_PACKAGES
	log "Development packages installed!"
else
	log "Package list was not in specified path: $DEV_FILE"
fi

setup_node
install_bun
install_claude_code
install_codex

# Claude Desktop: Anthropic ships macOS and Windows only, so this is a community
# RPM built from the official release. Panama used to build it from source -- it
# was `panama app claude-desktop` -- because no repository carried it. Upstream
# publishes one now, which is strictly better: the result upgrades with every
# other package instead of needing a slow rebuild each time a version ships.
#
# The repository is added by upstream's own setup script rather than by writing
# the .repo file out here. A baseurl copied into this repository is a pin by
# another name, and that script is the part upstream keeps correct.
if rpm -q claude-desktop-extra >/dev/null 2>&1; then
  log "Claude Desktop already installed"
else
  if [[ ! -f /etc/yum.repos.d/claude-desktop.repo ]]; then
    log "Adding the Claude Desktop repository..."
    # Fetched to a file and then run, never piped into root: a pipe executes
    # whatever the network answered with no chance to look, and this one is an
    # unpinned script from a personal GitHub Pages site -- the least trusted
    # thing this installer touches. The file is kept next to the run so what
    # executed is still on disk to read afterwards.
    claude_repo_script="$(mktemp -t claude-desktop-repo.XXXXXX.sh)"
    if curl -fsSL https://patrickjaja.github.io/claude-desktop-extra/install-rpm.sh \
        -o "$claude_repo_script"; then
      sudo bash "$claude_repo_script" > /dev/null 2>&1 \
        || log "Could not add the Claude Desktop repository (script kept at $claude_repo_script)"
    else
      log "Could not download the Claude Desktop repository script"
    fi
  fi
  log "Installing Claude Desktop..."
  sudo dnf install -y claude-desktop-extra > /dev/null \
    || { log "Claude Desktop install failed; skipping"; softly_failed+=("Claude Desktop"); }
fi

# RustDesk: remote desktop. The flatpak cannot register the root-owned system
# service that unattended access needs -- see panama-doctor's rustdesk check --
# so this takes the RPM. The download URL is resolved from the latest release
# rather than written down, so it does not go stale.
if rpm -q rustdesk >/dev/null 2>&1; then
  log "RustDesk already installed"
else
  log "Resolving the latest RustDesk release..."
  # `|| true` because a failed curl -- unauthenticated GitHub API calls get
  # rate-limited -- would otherwise trip set -e and kill the stage before the
  # empty-result fallback below could do its job.
  rustdesk_url="$(curl -fsSL https://api.github.com/repos/rustdesk/rustdesk/releases/latest 2>/dev/null \
    | jq -r --arg arch "$(uname -m)" '.assets[].browser_download_url | select(test($arch + "\\.rpm$")) | select(test("suse") | not)' \
    | head -1 || true)"
  if [[ -n "$rustdesk_url" ]]; then
    log "Installing RustDesk from $rustdesk_url"
    # The RPM ships rustdesk.service already enabled, which is what provides
    # unattended access; Panama deliberately does not start it a second time.
    sudo dnf install -y "$rustdesk_url" > /dev/null || { log "RustDesk install failed; skipping"; softly_failed+=("RustDesk"); }
  else
    log "Could not resolve a RustDesk release; skipping"; softly_failed+=("RustDesk")
  fi
fi

# --- Install Flatpak Packages ---
FLATPAK_FILE="$PANAMA_PATH/setup/packages/flatpak-packages"
if [[ -f "$FLATPAK_FILE" ]]; then
	FLATPAK_PACKAGES=$(packages_in "$FLATPAK_FILE")
	log "Adding Flathub remote"
	soft "adding the Flathub remote" \
	    sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
	log "Installing Flatpak Packages"
	echo -e "Includes the following packages:"
	echo -e "$(<"$FLATPAK_FILE")"
	# One ID renamed on Flathub must not cost the rest of the run; the desktop
	# is already installed by this point and none of these is part of it.
	soft "some Flatpak packages" sudo flatpak install -y flathub $FLATPAK_PACKAGES
	log "Flatpak packages installed!"
else
	log "Package list was not in specified path: $FLATPAK_FILE"
fi

# --- Install the extras that were chosen ------------------------------------
#
# Everything above is what every Panama machine gets. This is what one machine
# asked for: the interview offers the categories in setup/packages/extras/ as a
# checklist and records the chosen names, so a work laptop does not acquire
# emulators and a desktop does not skip Steam.
#
# Absent means none. That is what makes this stage safe to re-run by hand while
# repairing one piece of a machine -- and it means a category is installed only
# by an explicit answer, never by a default that drifted.
#
# A category mixes both package managers, because the applications do: some are
# in Fedora or RPM Fusion and some publish only a flatpak. A bare line is a dnf
# package and a `flatpak:` line is a Flathub ID, so one file per category holds
# the whole answer rather than splitting each category across two.
#
# Reading the file is setup/lib/extras-catalog's job, not this function's, because
# `panama apps` offers the same catalog from the other side. Two parsers would
# eventually disagree about what a category contains, and the one that disagreed
# quietly would be this one -- it runs unattended.
#
# Neither install is fatal. A category is a set of applications somebody wanted,
# not a dependency of the desktop, and losing the rest of the run because one of
# them was renamed upstream would be the wrong trade.
install_extra_category() {
	local file="$1" name
	name="$(basename "$file")"

	local dnf_packages flatpak_ids
	# sed rather than grep -v: most categories are flatpak-only, and grep exits 1
	# when it selects nothing, which set -e above turns into a dead stage.
	dnf_packages=$(catalog_all_targets "$file" | sed '/^flatpak:/d' | tr "\n" " ")
	flatpak_ids=$(catalog_all_targets "$file" | sed -n 's/^flatpak://p' | tr "\n" " ")

	if [[ -n "${dnf_packages// /}" ]]; then
		log "Installing $name: $dnf_packages"
		sudo dnf install -y $dnf_packages > /dev/null || { log "Some $name packages did not install"; softly_failed+=("$name packages"); }
	fi
	if [[ -n "${flatpak_ids// /}" ]]; then
		log "Installing $name flatpaks: $flatpak_ids"
		sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null
		sudo flatpak install -y flathub $flatpak_ids > /dev/null || { log "Some $name flatpaks did not install"; softly_failed+=("$name flatpaks"); }
	fi
}

EXTRAS_DIR="$PANAMA_PATH/setup/packages/extras"
for extra in ${PANAMA_EXTRAS:-}; do
	if [[ -f "$EXTRAS_DIR/$extra" ]]; then
		install_extra_category "$EXTRAS_DIR/$extra"
	else
		log "No such extras category: $extra"
	fi
done

report_soft_failures
