#!/bin/sh
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

set -eu

# Test seams only: production installs never set these, so the defaults below are always used.
manifest_base_url="${CAMEL_INSTALL_MANIFEST_BASE_URL:-https://camel.apache.org/camel-cli/releases}"
maven_base_url="${CAMEL_INSTALL_MAVEN_BASE_URL:-https://repo1.maven.org/maven2/org/apache/camel/camel-launcher}"
ca_cert="${CAMEL_INSTALL_CA_CERT:-}"

data_root="${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli/versions"
camel_cli_root="${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli"
bin_dir="$HOME/.local/bin"

fail() {
    echo "install.sh: $1" 1>&2
    exit 1
}

is_valid_version() {
    v="$1"
    case "$v" in
        '' | .* | *. | *..* | *[!0-9.]*) return 1 ;;
    esac
    # The case above guarantees digits and single dots only, with no leading, trailing, or doubled
    # dot, so a valid X.Y.Z is exactly the value with three non-empty components (two dots).
    case "$v" in
        *.*.*.*) return 1 ;;
        *.*.*) return 0 ;;
        *) return 1 ;;
    esac
}

validate_sha256() {
    value="$1"
    label="$2"
    [ ${#value} -eq 64 ] || fail "$label is not a 64-character lowercase hex value"
    case "$value" in
        *[!0-9a-f]*) fail "$label is not a 64-character lowercase hex value" ;;
    esac
}

fetch() {
    url="$1"
    outfile="$2"
    if command -v curl >/dev/null 2>&1; then
        if [ -n "$ca_cert" ]; then
            curl -fsSL --cacert "$ca_cert" -o "$outfile" "$url"
        else
            curl -fsSL -o "$outfile" "$url"
        fi
    elif command -v wget >/dev/null 2>&1; then
        if [ -n "$ca_cert" ]; then
            wget -nv --ca-certificate="$ca_cert" -O "$outfile" "$url"
        else
            wget -nv -O "$outfile" "$url"
        fi
    else
        fail "curl or wget is required to install the Camel CLI"
    fi
}

sha256() {
    file="$1"
    if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$file" | awk '{print $1}'
    elif command -v shasum >/dev/null 2>&1; then
        shasum -a 256 "$file" | awk '{print $1}'
    elif command -v openssl >/dev/null 2>&1; then
        openssl dgst -sha256 "$file" | awk '{print $NF}'
    else
        fail "sha256sum, shasum, or openssl is required to verify the download"
    fi
}

# Reads $manifest_file line by line without ever sourcing, dot-invoking, or eval-ing its content.
parse_manifest() {
    file="$1"
    cr=$(printf '\r')
    p_format=""
    p_version=""
    p_tar=""
    p_zip=""
    seen_format=0
    seen_version=0
    seen_tar=0
    seen_zip=0
    line_count=0
    while IFS='=' read -r key value || [ -n "$key" ]; do
        # Tolerate a manifest served with CRLF line endings, matching the PowerShell parser.
        key="${key%"$cr"}"
        value="${value%"$cr"}"
        # Skip comment lines (properties-style '#', and the '##' ASF license header the website's
        # manifest generator prepends). They carry no data, so they are neither counted toward the
        # required line total nor validated as key=value pairs. Blank lines are still rejected below.
        case "$key" in
            \#*) continue ;;
        esac
        line_count=$((line_count + 1))
        [ -n "$key" ] || fail "manifest contains a blank line"
        [ -n "$value" ] || fail "manifest key '$key' has an empty value"
        case "$key" in
            format)
                [ "$seen_format" -eq 0 ] || fail "manifest has duplicate key: format"
                seen_format=1
                p_format="$value"
                ;;
            version)
                [ "$seen_version" -eq 0 ] || fail "manifest has duplicate key: version"
                seen_version=1
                p_version="$value"
                ;;
            tar_sha256)
                [ "$seen_tar" -eq 0 ] || fail "manifest has duplicate key: tar_sha256"
                seen_tar=1
                p_tar="$value"
                ;;
            zip_sha256)
                [ "$seen_zip" -eq 0 ] || fail "manifest has duplicate key: zip_sha256"
                seen_zip=1
                p_zip="$value"
                ;;
            *)
                fail "manifest has unknown key: $key"
                ;;
        esac
    done < "$file"

    if [ "$seen_format" -ne 1 ] || [ "$seen_version" -ne 1 ] || [ "$seen_tar" -ne 1 ] || [ "$seen_zip" -ne 1 ]; then
        fail "manifest is missing a required key"
    fi
    [ "$line_count" -eq 4 ] || fail "manifest must contain exactly four lines"

    [ "$p_format" = "1" ] || fail "unsupported manifest format: $p_format"
    is_valid_version "$p_version" || fail "manifest version is not a valid X.Y.Z value"
    validate_sha256 "$p_tar" "manifest tar_sha256"
    validate_sha256 "$p_zip" "manifest zip_sha256"
}

# Lists archive entries with 'tar -tzf' and rejects absolute paths, traversal, symlinks/hardlinks,
# multiple top-level roots, and a missing expected launcher, before anything is extracted.
validate_tar() {
    archive="$1"
    version="$2"
    expected_root="camel-launcher-$version"

    listing="$staging_dir/listing.txt"
    tar_err=$(tar -tzf "$archive" 2>&1 1>"$listing") || fail "archive is not a valid tar.gz${tar_err:+: $tar_err}"

    verbose_listing="$staging_dir/listing-verbose.txt"
    # LC_ALL=C keeps tar's hard-link annotation as the literal 'link to' string; GNU tar localizes it.
    tar_err=$(LC_ALL=C tar -tvzf "$archive" 2>&1 1>"$verbose_listing") \
        || fail "archive is not a valid tar.gz${tar_err:+: $tar_err}"
    # tar -tv renders symlinks as 'name -> target' and hard links as 'name link to target'. Matching
    # those substrings could in theory over-reject a regular file whose name contains ' -> ' or
    # ' link to '; that fail-closed bias is deliberate, the release archive never carries such names.
    grep -E -- ' -> | link to ' "$verbose_listing" >/dev/null 2>&1 \
        && fail "archive contains a symbolic or hard link entry, which is not allowed"

    saw_entry=0
    while IFS= read -r entry; do
        [ -n "$entry" ] || continue
        case "$entry" in
            /*) fail "archive contains an absolute path entry: $entry" ;;
        esac
        case "$entry" in
            *"../"* | ".." | *"/..") fail "archive contains a path traversal entry: $entry" ;;
        esac
        # Every entry must sit under the single expected top-level directory; a differing root means a
        # stray directory or the wrong version, so reject it directly rather than collecting roots.
        root="${entry%%/*}"
        [ "$root" = "$expected_root" ] \
            || fail "archive top-level directory does not match expected version: $root"
        saw_entry=1
    done < "$listing"

    [ "$saw_entry" -eq 1 ] || fail "archive must contain exactly one top-level directory"

    grep -Fqx "$expected_root/bin/camel.sh" "$listing" || fail "archive is missing bin/camel.sh"
}

# Runs the freshly staged upstream launcher; a nonzero exit (e.g. no Java 17+ available) aborts
# the install and leaves the previously active installation untouched.
verify_staged() {
    staged_sh="$1"
    chmod +x "$staged_sh" 2>/dev/null || true
    "$staged_sh" version >/dev/null 2>&1 || fail "staged launcher failed verification (Java 17+ required)"
}

activate() {
    version="$1"
    staged_root_dir="$2"
    target_dir="$data_root/$version"

    mkdir -p "$data_root"
    rm -rf "$target_dir"
    mv "$staged_root_dir" "$target_dir"

    mkdir -p "$bin_dir"
    tmp_link="$bin_dir/.camel.tmp.$$"
    rm -f "$tmp_link"
    ln -s "$target_dir/bin/camel.sh" "$tmp_link"
    mv -f "$tmp_link" "$bin_dir/camel"
}

# Records whether this install is pinned to an explicit version, so `camel self-update` can refuse to move
# a deliberately pinned install. Skipped when CAMEL_INSTALL_SELF_UPDATE=true: self-update always passes its
# own resolved --version for TOCTOU-safety (see SelfUpdateCommand), which is not a human pinning a version
# and must not create or clear a pin either way.
update_pin_state() {
    [ "${CAMEL_INSTALL_SELF_UPDATE:-}" = "true" ] && return 0
    pinned_version_file="$camel_cli_root/pinned-version"
    if [ -n "$requested_version" ]; then
        printf '%s\n' "$version" > "$pinned_version_file"
    else
        rm -f "$pinned_version_file"
    fi
}

requested_version=""
while [ $# -gt 0 ]; do
    case "$1" in
        --version)
            [ $# -ge 2 ] || fail "Usage: install.sh [--version X.Y.Z]"
            requested_version="$2"
            shift 2
            ;;
        *)
            fail "Usage: install.sh [--version X.Y.Z]"
            ;;
    esac
done

if [ -n "$requested_version" ]; then
    is_valid_version "$requested_version" || fail "invalid --version value: $requested_version (expected X.Y.Z)"
fi

staging_dir="$camel_cli_root/.staging.$$"
cleanup() {
    rm -rf "$staging_dir"
}
trap cleanup EXIT INT TERM

mkdir -p "$camel_cli_root"
rm -rf "$staging_dir"
mkdir -m 700 "$staging_dir"

if [ -n "$requested_version" ]; then
    manifest_url="$manifest_base_url/$requested_version.properties"
else
    manifest_url="$manifest_base_url/latest.properties"
fi

manifest_file="$staging_dir/manifest.properties"
fetch "$manifest_url" "$manifest_file" || fail "failed to download manifest from $manifest_url"

parse_manifest "$manifest_file"
version="$p_version"

if [ -n "$requested_version" ] && [ "$requested_version" != "$version" ]; then
    fail "manifest version ($version) does not match requested version ($requested_version)"
fi

archive_url="$maven_base_url/$version/camel-launcher-$version-bin.tar.gz"
archive_file="$staging_dir/camel-launcher-$version-bin.tar.gz"
fetch "$archive_url" "$archive_file" || fail "failed to download archive from $archive_url"

actual_tar_sha256="$(sha256 "$archive_file")"
[ "$actual_tar_sha256" = "$p_tar" ] || fail "checksum mismatch for downloaded archive"

validate_tar "$archive_file" "$version"

extract_dir="$staging_dir/extract"
mkdir -p "$extract_dir"
tar -xzf "$archive_file" -C "$extract_dir"

staged_root_dir="$extract_dir/camel-launcher-$version"
verify_staged "$staged_root_dir/bin/camel.sh"

activate "$version" "$staged_root_dir"
update_pin_state

case ":$PATH:" in
    *":$bin_dir:"*) ;;
    *) echo "Add $bin_dir to your PATH to use the 'camel' command." ;;
esac

echo "Installed Camel CLI $version to $data_root/$version"
