#!/bin/bash
#
# Copyright (C) 2023-2024 Canonical, Ltd.
# Author: Adrien Nader <adrien.nader@canonical.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

set -e
set -u
shopt -s inherit_errexit

DESTDIR="${DESTDIR:-}"

DEFAULT_PROFILE='default'
DATA_DIR="${DESTDIR}/usr/share/crypto-config"
SYSTEM_PROFILES_DIR="${DATA_DIR}/profiles"
# USER_PROFILES='user/crypto-config'
# ALL_PROFILES="${SYSTEM_PROFILES_DIR} ${USER_PROFILES}"
ALL_PROFILES="${SYSTEM_PROFILES_DIR}"
STATE_DIR="${DESTDIR}/var/lib/crypto-config"
STATE_PROFILES_DIR="${STATE_DIR}/profiles"

verbose='false'

CURRENT="${STATE_PROFILES_DIR}/current"

log() {
  if "${verbose}"; then
    echo "$@"
  fi
}

dirs_in() {
  # Output the list of directories directly inside a given directory
  # Depth limit is 2. It was 1 in order to avoid picking applications'
  # directories but 2 is useful to be able to use "debian/foo" or "ubuntu/bar".
  # It could be higher once we can make sure 'metadata.json' is really for
  # crypto-config and not a random file with the same name.
  find "${1}" -mindepth 1 -maxdepth 2 -type d -printf '%P\n'
}

entries_in() {
  # Output the directory entries directly inside a given directory
  find "${1}" -mindepth 1 -maxdepth 1 -printf '%P\n'
}

_list_profiles_and_parent() {
  # Output a list of profiles and their parent in the following format:
  #   profile1_parent profile1
  #   profile2_parent profile2

  local profile_dir

  profile_dir="$1"

  for profile in $(dirs_in "${profile_dir}"); do
    # Ensure we only iterate on directories which can be valid profiles
    if [[ "${profile}" != 'default' ]] && ! [[ -e "${profile_dir}/${profile}/metadata.json" ]]; then
      continue
    fi

    # TODO: switch to the 'metadata.json' file rather than 'parent'
    jq -r '"\(.parent) '"${profile}"'"' < "${profile_dir}/${profile}/metadata.json"
  done
}

_reach_state() {
  local profile_dir
  local profile
  local apps

  profile_dir="${1}"
  profile="${2}"
  shift 2
  apps=("$@")

  # Skip profiles that don't exist in the current profile directory
  # This is because shell script makes it a fair bit difficult to do the
  # topological sort on profile names and keep track of which directory
  # they come from.
  if ! [[ -e "${profile_dir}/${profile}" ]]; then
    return
  fi

  log "  - Profile: ${profile}"

  # The default profile has no parent by definition and does not need to be
  # modified since it is the single source of truth
  if [[ "${profile}" = "${DEFAULT_PROFILE}" ]]; then
    log "    - source of truth"
    # Symlink to the directory where the profile lives: it is required to be
    # complete already, therefore we can use it directly
    ln -sfn "${profile_dir}/${profile}" "${STATE_PROFILES_DIR}/${profile}"
    return
  fi

  # Ensure the profile directory exists in the state directory
  mkdir -p "${STATE_PROFILES_DIR}/${profile}"

  # Due to the call to tsort we couldn't easily keep the information on
  # where the profiles reside. Recover that.
  parent_profile="$(jq -r '.parent' < "${profile_dir}/${profile}/metadata.json")"

  for app in "${apps[@]}"; do

    if [[ -z "${app}" ]]; then
      continue
    fi

    link="${STATE_PROFILES_DIR}/${profile}/${app}"

    # If app profile exists in the current profile, use it.
    # If not, if app profile exists in the parent profile, create a symlink to
    # there.
    # If it doesn't either, remove any existing symlink as there is no matching
    # profile available anymore.

    target="${profile_dir}/${profile}/${app}"
    if [[ -e "${target}" ]]; then
      log "    - ${app}: link to ${target}"
      ln -sfn "${target}" "${link}"
      continue
    fi

    target="${STATE_PROFILES_DIR}/${parent_profile}/${app}"
    if [[ -e "${target}" ]]; then
      log "    - ${app}: link to ${target}"
      ln -sfn "${target}" "${link}"
      continue
    fi

    if [[ -e "${link}" ]] || [[ -L "${link}" ]]; then
      log "    - ${app}: remove"
    else
      log "    - ${app}: absent"
    fi
    rm -f "${link}"

  done

  # TODO: remove profiles that have been removed
}

_update_profile() {
  local profile_dir
  local apps

  profile_dir="${1}"
  shift
  apps=("$@")

  if ! [[ -d "${profile_dir}" ]]; then
    return
  fi

  log "- Profile dir: ${profile_dir}"

  # TODO: profile names must be unique across profile directories

  _list_profiles_and_parent "${profile_dir}" \
  | tsort \
  | while read -r profile; do
      _reach_state "${profile_dir}" "${profile}" "${apps[@]}"
  done
}

generate_runtime_profiles() {
  mkdir -p "${STATE_PROFILES_DIR}"

  mapfile -t apps < <(entries_in "${SYSTEM_PROFILES_DIR}/${DEFAULT_PROFILE}")

  for profile_dir in ${ALL_PROFILES}; do
    _update_profile "${profile_dir}" "${apps[@]}"
  done

  # Make 'current' point to 'default' if it doesn't exist or points to a
  # non-existant file (works because -e uses stat(), not lstat())
  if ! [[ -e "${STATE_PROFILES_DIR}/current" ]]; then
    ln -sfn 'default' "${STATE_PROFILES_DIR}/current"
  fi
}


help() {
  cat << EOF
Usage: crypto-config <command> ...
System-wide cryptography configuration profile management

User commands:
  get-current
  status
  switch <profile>

Plumbing:
  generate-runtime-profiles
EOF
}

switch() {
  local target

  target="$1"

  if ! [[ -d "${STATE_PROFILES_DIR}/$1" ]]; then
    echo "'${target}' does not exist or is not a directory."
    exit 1
  fi

  ln -sfn "${target}" "${CURRENT}"

  echo "Profile switched to '${target}'."
}

get_current() {
  basename "$(readlink "${CURRENT}")"
}

status() {
  local current

  current="$(get_current)"

  printf "Current profile in '%s'\n" "${current}"
}

case "${1:-help}" in
  -h|-help|--help|help|h)
    help
    ;;
  generate-runtime-profiles)
    shift
    generate_runtime_profiles
    ;;
  get-current)
    shift
    get_current
    ;;
  status)
    shift
    status
    ;;
  switch)
    shift
    switch "$@"
    ;;
  *)
    printf "Unknown command '%s'\n" "$1"
    help
    ;;
esac
