#!/bin/bash
# services/gitea.sh — Self-hosted Gitea (lightweight Git server), with an
# optional two-way GitHub mirror sync (gitea-github-sync.sh, vendored from
# the ai-stack bundle but genuinely standalone here — this does NOT pull in
# Ollama/ComfyUI/InvokeAI/any of the rest of that stack, just the one
# Gitea container + the sync script).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
#   sudo bash gitea.sh
# (Docker must already be installed when run standalone)

# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }

    _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    _COMMON="$_SELF_DIR/../lib/common.sh"

    if [[ -f "$_COMMON" ]]; then
        # shellcheck source=../lib/common.sh
        source "$_COMMON"
    else
        log_info()    { echo -e "\033[0;34m[INFO]\033[0m $*"; }
        log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
        log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
        log_error()   { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }

        require_docker() {
            command -v docker &>/dev/null || {
                log_error "Docker not found. Install it first:"
                log_error "  curl -fsSL https://get.docker.com | sudo sh"
                return 1
            }
            docker compose version &>/dev/null || {
                log_error "Docker Compose plugin missing:"
                log_error "  sudo apt-get install -y docker-compose-plugin"
                return 1
            }
        }

        ensure_docker_dir_ownership() {
            chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
        }

        port_in_use() {
            local _port="$1" _proto="${2:-tcp}"
            local _flag="-tlnH"
            [ "$_proto" = "udp" ] && _flag="-ulnH"
            ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
        }

        find_free_port() {
            local _varname="$1" _port="$2" _proto="${3:-tcp}"
            while port_in_use "$_port" "$_proto"; do
                _port=$((_port + 1))
            done
            eval "$_varname='$_port'"
        }

        generate_password() {
            local _len="${1:-32}"
            tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
        }

        prompt_text() {
            local _q="$1" _def="$2" _var="$3" _r
            [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
            read -r -p "  $_q " _r
            eval "$_var='${_r:-$_def}'"
        }

        prompt_yn() {
            local _q="$1" _def="$2" _var="$3" _r
            [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
            read -r -p "  $_q " _r
            eval "$_var='${_r:-$_def}'"
        }

        prompt_reinstall_mode() {
            local _var="$1" _r
            [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; }
            echo ""
            echo "  1) Update   — refresh the image only, leave config/data as-is"
            echo "  2) Full reinstall — wipe and reconfigure from scratch"
            echo "  3) Cancel   — leave the existing install untouched"
            read -r -p "  Choice [3]: " _r
            case "$_r" in
                1) eval "$_var='update'" ;;
                2) eval "$_var='fresh'" ;;
                *) eval "$_var='cancel'" ;;
            esac
        }

        configure_caddy_for_service() {
            local _name="$1" _upstream="$2" _subdomain="$3"
            local _display_port="${_upstream##*:}"
            log_info "Access $_name directly on port $_display_port (no Caddy in standalone mode)."
            CADDY_SERVICE_CONFIGURED=false
        }

        write_readme() {
            local _dir="$1"; shift
            mkdir -p "$_dir"
            cat > "$_dir/README.md"
        }
        backup_if_exists() {
            local _file="$1"
            [ -f "$_file" ] || return 0
            cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
        }
    fi

    ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
    ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
    DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
    DRY_RUN="${DRY_RUN:-false}"
    UNATTENDED="${UNATTENDED:-false}"
    SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
    SITE_DOMAIN="${SITE_DOMAIN:-example.com}"

    register_service() { :; }
    _RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────

register_service gitea utilities "Self-hosted Git server (Gitea) — raw local clones plus optional two-way GitHub mirror sync" 3001

_gitea_sync_vendor_src() {
    local _self_dir
    _self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    echo "$(cd "$_self_dir/.." && pwd)/vendor/ai-stack/gitea-github-sync.sh"
}

# Prompt for a token with retries — pasted tokens over SSH sometimes race the
# prompt (the terminal delivers the paste a beat after an already-submitted
# empty line, so the token shows up echoed on the *next* line instead of
# being read). A single empty answer used to be taken as "no token", silently
# — this gives it up to 3 tries before actually giving up, and strips
# whitespace in case the paste carried a stray leading/trailing newline.
_gitea_prompt_token() {
    local _question="$1" _varname="$2"
    local _max=1 _tries=0 _val=""
    [ "$UNATTENDED" != true ] && _max=3
    while [[ $_tries -lt $_max ]]; do
        prompt_text "$_question" "" _val
        _val="$(printf '%s' "$_val" | tr -d '[:space:]')"
        [[ -n "$_val" ]] && break
        _tries=$((_tries + 1))
        [[ $_tries -lt $_max ]] && log_warning "  Nothing came through — if you pasted it, try again (a paste can race the prompt over SSH)."
    done
    eval "$_varname='$_val'"
}

# Gitea's container always runs internally as UID 1000 (USER_UID/USER_GID in
# the compose file below are fixed, independent of whoever's running this
# installer) — the official image chowns /data to that UID on its own at
# startup. A plain ensure_docker_dir_ownership call fights that: it's a
# recursive chown of the whole service directory to $ACTUAL_USER, which on
# a box where the installer runs as root directly (ACTUAL_USER=root) resets
# the live data/ back to UID 0. If the container doesn't happen to restart
# right after (e.g. Update mode against an already-running container, which
# just no-ops), nothing ever re-fixes it, and every write to Gitea's own
# SQLite DB then fails with "attempt to write a readonly database" — the
# directory holding the DB file is owned by root, not the UID 1000 process
# trying to write it. Confirmed live. Chown everything else as normal in
# $DIR; leave data/ (and the Actions runner's own runner-data/, same reason)
# for their respective containers to manage.
_gitea_fix_ownership() {
    local _dir="$1"
    [ "$DRY_RUN" = true ] && return 0
    chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir" 2>/dev/null || true
    local _entry
    for _entry in "$_dir"/*; do
        [ -e "$_entry" ] || continue
        case "$(basename "$_entry")" in
            data|runner-data) continue ;;
        esac
        chown -R "$ACTUAL_USER:$ACTUAL_USER" "$_entry" 2>/dev/null || true
    done
}

# Offers to add "Sign in with Authelia" (OpenID Connect) to Gitea's own
# login page. This is a different thing from Caddy forward_auth, which this
# service deliberately skips (see the Caddy call below) since Gitea already
# has a solid built-in login — this adds Authelia as an *additional* OAuth2
# login option on the same page, on top of that built-in login, not a
# replacement for it. Nothing about local admin login changes.
#
# Fully automated on both sides, matching how the rest of this installer
# avoids manual web wizards: registers Gitea as an OIDC client in Authelia
# (services/authelia.sh's _authelia_provision_oidc_client), then adds the
# resulting client as an authentication source in Gitea via its own CLI. If
# either side isn't available (Authelia not installed, or the Gitea CLI
# call fails for some reason — e.g. an older image without `admin auth
# add-oauth`), falls back to printing the values for a two-minute manual
# add in Gitea's Site Administration UI instead of losing the setup.
_gitea_offer_authelia_sso() {
    local DIR="$1"

    [ -d "$DOCKER_DIR/authelia" ] || return 0
    declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0

    echo ""
    local USE_SSO=""
    prompt_yn "  Add \"Sign in with Authelia\" (OpenID Connect) to Gitea's login page? (y/n):" "n" USE_SSO
    [[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0

    local _default_domain=""
    [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && _default_domain="git.${SITE_DOMAIN}"
    local GITEA_OIDC_DOMAIN=""
    prompt_text "  Domain Gitea is reachable at [${_default_domain:-required}]:" "$_default_domain" GITEA_OIDC_DOMAIN
    if [ -z "$GITEA_OIDC_DOMAIN" ]; then
        log_warning "No domain entered — skipping Authelia SSO for Gitea."
        return 0
    fi

    local _2fa="" AUTH_POLICY="two_factor"
    prompt_yn "  Require two-factor for Gitea logins via Authelia too? (y/n):" "y" _2fa
    [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"

    if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" "n" "" \
        "https://${GITEA_OIDC_DOMAIN}/user/oauth2/authelia/callback"; then
        log_warning "Couldn't register Gitea as an OIDC client in Authelia — skipping SSO setup."
        return 0
    fi

    local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
    log_info "Adding Authelia as an authentication source in Gitea..."
    if docker exec -u git gitea gitea admin auth add-oauth \
        --name authelia --provider openidConnect \
        --key gitea --secret "$OIDC_CLIENT_SECRET_PLAIN" \
        --auto-discover-url "$_discovery_url" &>/dev/null; then
        log_success "\"Sign in with Authelia\" added to Gitea's login page — local admin login still works too."
    else
        log_warning "Couldn't add the auth source automatically. Add it by hand:"
        log_warning "  Gitea -> Site Administration -> Authentication Sources -> Add Authentication Source"
        log_warning "  Type: OAuth2, Provider: OpenID Connect, Name: authelia"
        log_warning "  Client ID:      gitea"
        log_warning "  Client Secret:  $OIDC_CLIENT_SECRET_PLAIN"
        log_warning "  Discovery URL:  $_discovery_url"
        log_warning "  (The Client Secret above is shown once — it isn't stored in plaintext anywhere.)"
    fi

    declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$GITEA_OIDC_DOMAIN"
}

# Offers Gitea's OTHER Authelia integration — not the OIDC button above, but
# ENABLE_REVERSE_PROXY_AUTHENTICATION: Gitea auto-logs in as whatever user
# name arrives in a trusted header, no click and no separate Gitea session
# to expire on its own schedule. This is genuinely stronger than the OIDC
# button (which still shows a login page, just with an extra option on it)
# and matches the pattern services/frigate.sh uses — except Gitea's own
# login form stays available as a fallback for anyone NOT arriving from a
# trusted source, so there's no "native login disabled with nothing gating
# it" failure mode to guard against here the way Frigate's had.
#
# The security boundary is REVERSE_PROXY_TRUSTED_PROXIES, not a shared
# secret: Gitea only honors the identity header from source IPs inside that
# range. Gitea's own Docker image shipped this wildcarded (GHSA-f75j-4cw6-
# rmx4 — any IP could set X-WEBAUTH-USER and log in as anyone), so this is
# always computed from caddy_net's real subnet (same lookup
# ufw_allow_from_caddy_net uses) and refuses to enable the feature at all if
# that can't be determined — never falls back to a permissive default.
#
# Requires Gitea to actually be reachable from an address inside that range,
# which means joining caddy_net like every other locally-Caddy-fronted
# service in this repo (Gitea currently reaches Caddy via its published
# host port instead — host.docker.internal upstream — because it predates
# this feature). Local Caddy only: a remote Caddy machine's source address
# isn't a stable, narrowly-scopeable range the way caddy_net's bridge subnet
# is, so this skips remote mode rather than guess at a trust range worth
# getting wrong.
_gitea_offer_reverse_proxy_auth() {
    local DIR="$1"

    [ -d "$DOCKER_DIR/authelia" ] || return 0
    [ -d "$DOCKER_DIR/caddy" ] || return 0

    if grep -q 'ENABLE_REVERSE_PROXY_AUTHENTICATION=true' "$DIR/docker-compose.yml" 2>/dev/null; then
        log_info "Gitea's zero-click Authelia login (reverse-proxy auth) is already enabled — skipping."
        return 0
    fi

    echo ""
    local USE_RP=""
    prompt_yn "  Skip Gitea's own login entirely for anyone arriving via Authelia — fully transparent, no click, no separate Gitea session to re-expire? Rewires Gitea onto Caddy's internal network (Caddy must be on this same machine). (y/n):" "n" USE_RP
    [[ "$USE_RP" =~ ^[Yy]$ ]] || return 0

    local _subnet
    _subnet="$(docker network inspect "${SITE_CADDY_NET:-caddy_net}" \
        --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null)"
    if [ -z "$_subnet" ]; then
        log_warning "Couldn't determine ${SITE_CADDY_NET:-caddy_net}'s subnet — refusing to enable"
        log_warning "reverse-proxy auth without a scoped trust range. An unscoped default lets ANY"
        log_warning "client impersonate ANY Gitea user via a spoofed header (this was a real Gitea"
        log_warning "CVE — GHSA-f75j-4cw6-rmx4). Skipping."
        return 1
    fi

    log_info "Wiring Gitea onto caddy_net and enabling reverse-proxy authentication..."
    sed -i "/GITEA__security__INSTALL_LOCK=true/a\\      - GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION=true\\n      - GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION=true\\n      - GITEA__service__ENABLE_REVERSE_PROXY_EMAIL=true\\n      - GITEA__security__REVERSE_PROXY_AUTHENTICATION_USER=Remote-User\\n      - GITEA__security__REVERSE_PROXY_AUTHENTICATION_EMAIL=Remote-Email\\n      - GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES=${_subnet}" \
        "$DIR/docker-compose.yml"
    cat >> "$DIR/docker-compose.yml" << EOF
    networks:
      - caddy_net

networks:
  caddy_net:
    external: true
    name: ${SITE_CADDY_NET:-caddy_net}
EOF

    _gitea_fix_ownership "$DIR"
    (cd "$DIR" && docker compose up -d) \
        && log_success "Gitea restarted on caddy_net (trusted range: ${_subnet})." \
        || { log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }

    # Re-point Caddy at the container (gitea:3000, now reachable over
    # caddy_net) instead of the host-published port, with the auth gate in
    # front. This replaces the plain block set up earlier in this install —
    # configure_caddy_for_service's own "already exists — overwrite?" prompt
    # covers that; nothing here bypasses it.
    configure_caddy_for_service "Gitea" "gitea:3000" "git" "    import authelia"
    if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
        log_success "Gitea now signs in transparently via Authelia at https://${CADDY_SERVICE_DOMAIN} — its own login page is still there for anyone reaching it another way."
        declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$CADDY_SERVICE_DOMAIN"
    else
        log_warning "Caddy wasn't reconfigured — env vars are set, but nothing is routing Gitea through Authelia yet."
        log_warning "Point Gitea's Caddy entry at gitea:3000 (not the old host.docker.internal upstream) with 'import authelia' in front, or just re-run this offer."
    fi
}

# Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions-
# workflow-compatible) with a local runner — mainly useful as a fallback so
# .gitea/workflows/*.yml can still run something like a GitHub Actions build
# if GitHub itself is ever unreachable, since this Gitea is otherwise just a
# passive pull mirror. Off by default; opt-in on fresh installs and Update
# reruns alike (idempotent — a rerun after it's already set up just no-ops).
#
# The runner (gitea/act_runner) polls Gitea for jobs and needs the host's
# Docker socket to launch a fresh container per job — same pattern this repo
# already uses for portainer/watchtower/uptimekuma/beszel/traccar's autoheal,
# not something new to this file. Worth knowing: that's root-equivalent
# access to this host, standard for any CI runner, not unique to Gitea's.
_gitea_offer_actions_runner() {
    local DIR="$1"

    if grep -q '^  act_runner:$' "$DIR/docker-compose.yml" 2>/dev/null; then
        log_info "Gitea Actions runner is already set up (act_runner service already in docker-compose.yml) — skipping."
        log_info "Check its status: docker compose -f $DIR/docker-compose.yml ps act_runner"
        return 0
    fi

    echo ""
    local USE_ACTIONS=""
    prompt_yn "  Enable Gitea Actions (CI) with a local runner — runs .gitea/workflows/*.yml the same way GitHub Actions runs .github/workflows/*.yml, useful as a fallback if GitHub is ever unreachable? (y/n):" "n" USE_ACTIONS
    [[ "$USE_ACTIONS" =~ ^[Yy]$ ]] || return 0

    if ! grep -q 'GITEA__actions__ENABLED' "$DIR/docker-compose.yml"; then
        log_info "Enabling Gitea Actions..."
        sed -i '/GITEA__security__INSTALL_LOCK=true/a\      - GITEA__actions__ENABLED=true\n      - GITEA__actions__DEFAULT_ACTIONS_URL=github' "$DIR/docker-compose.yml"
        (cd "$DIR" && docker compose up -d) \
            && log_success "Actions enabled — Gitea restarted to apply." \
            || { log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }
    fi

    log_info "Generating a runner registration token..."
    local RUNNER_TOKEN=""
    RUNNER_TOKEN="$(docker exec -u git gitea gitea actions generate-runner-token 2>/dev/null | tail -1)"
    if [[ -z "$RUNNER_TOKEN" ]]; then
        log_warning "Couldn't generate a runner token automatically (older Gitea image?). Generate one by hand:"
        log_warning "  Gitea -> Site Administration -> Actions -> Runners -> Create new Runner"
        log_warning "  then add an act_runner container yourself using that token — see"
        log_warning "  https://docs.gitea.com/usage/actions/quickstart for the compose snippet."
        return 1
    fi

    mkdir -p "$DIR/runner-data"
    cat >> "$DIR/docker-compose.yml" << EOF

  act_runner:
    image: gitea/act_runner:latest
    container_name: gitea-runner
    restart: unless-stopped
    environment:
      - GITEA_INSTANCE_URL=http://gitea:3000
      - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
      - GITEA_RUNNER_NAME=gitea-runner
    volumes:
      - ./runner-data:/data
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - gitea
EOF

    _gitea_fix_ownership "$DIR"
    (cd "$DIR" && docker compose up -d act_runner) \
        && log_success "Actions runner started — .gitea/workflows/*.yml will now run automatically on push." \
        || log_warning "Runner failed to start — check: docker compose -f $DIR/docker-compose.yml logs act_runner"
}

# ── Own systemd timer, not gitea-github-sync.sh's built-in --install-timer ──
# The vendor script's own timer installer always runs the script bare (no
# --pull-only/--push-only), i.e. always both directions — there's no way to
# hand it a fixed sync direction. Since this installer asks up front which
# direction to run automatically, the timer's ExecStart bakes that flag in
# directly instead of delegating to the vendor script's own (less flexible)
# --install-timer/--remove-timer modes.
_gitea_write_sync_timer() {
    local DIR="$1" RUN_USER="$2" RUN_HOME="$3" FLAG="$4" INTERVAL="$5"
    local _service="/etc/systemd/system/gitea-github-sync.service"
    local _timer="/etc/systemd/system/gitea-github-sync.timer"

    cat > "$_service" << UNIT
[Unit]
Description=Gitea-GitHub Mirror Sync
After=network-online.target docker.service
Wants=network-online.target

[Service]
Type=oneshot
User=${RUN_USER}
Environment=HOME=${RUN_HOME}
Environment=SYNC_ENV=${DIR}/.env
ExecStart=/bin/bash ${DIR}/gitea-github-sync.sh ${FLAG}
UNIT

    cat > "$_timer" << UNIT
[Unit]
Description=Gitea-GitHub Sync Timer

[Timer]
OnBootSec=5min
OnUnitActiveSec=${INTERVAL}
Persistent=true

[Install]
WantedBy=timers.target
UNIT

    systemctl daemon-reload
    systemctl enable --now gitea-github-sync.timer
}

_gitea_remove_sync_timer() {
    systemctl disable --now gitea-github-sync.timer 2>/dev/null || true
    rm -f /etc/systemd/system/gitea-github-sync.service /etc/systemd/system/gitea-github-sync.timer
    systemctl daemon-reload 2>/dev/null || true
}

# Ask sync direction + autosync, apply to either a fresh setup or a
# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's
# international-calling step reasoning: a live-editable extra, not a
# structural setting tied exclusively to fresh installs).
_gitea_run_sync_direction_step() {
    local DIR="$1"

    echo ""
    echo "  Sync direction:"
    echo "    1) GitHub -> Gitea only  (cloud to local — backup your GitHub repos here)"
    echo "    2) Gitea -> GitHub only  (local to cloud — push repos created here up to GitHub)"
    echo "    3) Both directions"
    local _DIR_CHOICE=""
    prompt_text "  Choice [1]:" "1" _DIR_CHOICE
    local FLAG="" DIR_DESC=""
    case "$_DIR_CHOICE" in
        2) FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
        3) FLAG="";            DIR_DESC="both directions" ;;
        *) FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
    esac
    log_info "Sync direction: $DIR_DESC"

    _gitea_remove_sync_timer

    echo ""
    local AUTOSYNC=""
    prompt_yn "Enable automatic sync on a schedule? (y/n):" "y" AUTOSYNC
    if [[ "$AUTOSYNC" =~ ^[Yy]$ ]]; then
        local INTERVAL=""
        prompt_text "  Sync interval (e.g. 1h, 6h, 1d) [6h]:" "6h" INTERVAL
        _gitea_write_sync_timer "$DIR" "$ACTUAL_USER" "$ACTUAL_HOME" "$FLAG" "$INTERVAL"
        log_success "Timer installed: syncs every $INTERVAL ($DIR_DESC)."
        log_info "Check status: systemctl status gitea-github-sync.timer"
        log_info "Run now:      sudo systemctl start gitea-github-sync.service"
        log_info "Logs:         ~/.config/gitea-github-sync/sync.log"
    else
        log_info "Automatic sync not enabled. Run it yourself whenever you want:"
        log_info "  cd $DIR && bash gitea-github-sync.sh $FLAG"
        [[ -z "$FLAG" ]] && log_info "  (no flag needed for both directions)"
    fi

    # ── Run it now, off the timer — lets you confirm tokens/config are
    # actually correct right here instead of waiting for the first
    # scheduled run (or a manual invocation later) to find out.
    echo ""
    echo "  Run a sync now?"
    echo "    1) Dry-run preview only (--list) — shows what would sync, no changes"
    echo "    2) Run for real now ($DIR_DESC)"
    echo "    3) Skip — don't run anything now"
    local _RUN_DEFAULT="1"
    [ "$UNATTENDED" = true ] && _RUN_DEFAULT="3"
    local _RUN_NOW=""
    prompt_text "  Choice [$_RUN_DEFAULT]:" "$_RUN_DEFAULT" _RUN_NOW
    case "$_RUN_NOW" in
        2)
            log_info "Running sync now ($DIR_DESC)..."
            sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
                bash "$DIR/gitea-github-sync.sh" $FLAG \
                && log_success "Sync run complete." \
                || log_warning "Sync run failed — check the output above, or ~/.config/gitea-github-sync/sync.log"
            ;;
        3) log_info "Skipped — run it later with the commands above." ;;
        *)
            log_info "Dry-run preview (--list)..."
            sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
                bash "$DIR/gitea-github-sync.sh" --list
            ;;
    esac
}

install_gitea() {
    log_info "Setting up self-hosted Gitea..."

    local DIR="$DOCKER_DIR/gitea"
    local SYNC_SRC
    SYNC_SRC="$(_gitea_sync_vendor_src)"

    if [ "$DRY_RUN" = true ]; then
        echo "[DRY-RUN] Would create $DIR with docker-compose.yml (gitea/gitea:latest)"
        echo "[DRY-RUN] Would scan for free host ports (web + SSH) to avoid collisions"
        echo "[DRY-RUN] Would open the SSH clone port in UFW (web port too, or scoped to caddy_net"
        echo "[DRY-RUN]   if Caddy ends up fronting it locally)"
        echo "[DRY-RUN] Would prompt for a Gitea admin username/password, then create that account"
        echo "[DRY-RUN]   and an API token once the container is ready (no manual web wizard)"
        echo "[DRY-RUN] Would prompt for a GitHub token and copy in gitea-github-sync.sh"
        echo "[DRY-RUN] Would ask sync direction (GitHub->Gitea / Gitea->GitHub / both) and whether"
        echo "[DRY-RUN]   to install a systemd timer for automatic sync, or print manual instructions"
        echo "[DRY-RUN] Would offer to run a sync now (dry-run preview or for real), off-schedule"
        echo "[DRY-RUN] Would offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
        echo "[DRY-RUN] Would offer zero-click Authelia login (reverse-proxy auth) if Authelia"
        echo "[DRY-RUN]   and local Caddy are both installed — rewires Gitea onto caddy_net"
        echo "[DRY-RUN] Would offer to enable Gitea Actions (CI) with a local act_runner container"
        echo "[DRY-RUN] Would write $DIR/README.md"
        return 0
    fi

    if [[ ! -f "$SYNC_SRC" ]]; then
        log_error "Vendored gitea-github-sync.sh not found at $SYNC_SRC"
        return 1
    fi

    require_docker || return 1

    # ── Existing install? ───────────────────────────────────────────────────
    if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
        echo ""
        log_info "Existing Gitea install found at $DIR."
        local MODE=""
        prompt_reinstall_mode MODE
        case "$MODE" in
            update)
                cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
                chmod +x "$DIR/gitea-github-sync.sh"
                _gitea_fix_ownership "$DIR"
                (cd "$DIR" && docker compose up -d) \
                    && log_success "Gitea refreshed and restarted." \
                    || log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
                _gitea_run_sync_direction_step "$DIR"
                _gitea_offer_authelia_sso "$DIR"
                _gitea_offer_reverse_proxy_auth "$DIR"
                _gitea_offer_actions_runner "$DIR"
                log_success "Existing .env (tokens) and web/SSH ports were left untouched."
                return 0
                ;;
            cancel)
                log_info "Leaving the existing install as-is."
                return 0
                ;;
            fresh) log_info "Reconfiguring from scratch — every prompt below runs again." ;;
        esac
    fi

    mkdir -p "$DIR"
    _gitea_fix_ownership "$DIR"
    cd "$DIR" || return 1

    # ── Port scan — web (default 3001->3000) and SSH (default 2222->22) ────
    local WEB_PORT=3001 SSH_PORT=2222
    find_free_port WEB_PORT "$WEB_PORT"
    find_free_port SSH_PORT "$SSH_PORT"
    [[ "$WEB_PORT" != 3001 ]] && log_info "Port 3001 was taken — Gitea's web UI will use ${WEB_PORT}."
    [[ "$SSH_PORT" != 2222 ]] && log_info "Port 2222 was taken — Gitea's SSH clone port will use ${SSH_PORT}."

    backup_if_exists docker-compose.yml
    cat > docker-compose.yml << EOF
name: gitea
services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    restart: unless-stopped
    ports:
      - "${WEB_PORT}:3000"
      - "${SSH_PORT}:22"
    volumes:
      - ./data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=sqlite3
      - GITEA__database__PATH=/data/gitea/gitea.db
      - GITEA__security__INSTALL_LOCK=true
EOF

    _gitea_fix_ownership "$DIR"
    docker compose up -d \
        && log_success "Gitea container started." \
        || { log_error "docker compose up failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }

    # ── Admin credentials — asked up front so a slow first boot doesn't need
    # a second manual pass; these get used the moment Gitea's CLI is ready.
    echo ""
    local GITEA_ADMIN_USER=""
    prompt_text "  Gitea admin username [$ACTUAL_USER]:" "$ACTUAL_USER" GITEA_ADMIN_USER
    local _GEN_PASS GITEA_ADMIN_PASS=""
    _GEN_PASS="$(generate_password 24)"
    prompt_text "  Gitea admin password [$_GEN_PASS]:" "$_GEN_PASS" GITEA_ADMIN_PASS

    # ── Wait for Gitea to actually be ready, then create the account. One
    # retry loop instead of a separate readiness probe: first boot (SQLite
    # init) can take well over a minute on slower disks, and folding account
    # creation into the same loop means a slow-but-eventually-successful boot
    # doesn't dead-end the install the way a fixed 60s probe used to.
    log_info "Waiting for Gitea to finish starting (first boot can take a minute or two)..."
    local _tries=0 _created=false _exists=false
    while [[ $_tries -lt 60 ]]; do
        if docker exec -u git gitea gitea admin user create --admin \
            --username "$GITEA_ADMIN_USER" --password "$GITEA_ADMIN_PASS" \
            --email "${GITEA_ADMIN_USER}@localhost" --must-change-password=false \
            &>/dev/null; then
            _created=true
            break
        fi
        # Gitea is up but this username already exists (e.g. retry after an
        # earlier partial run) — treat as success and sync the password to
        # what was just entered rather than failing the whole install.
        if docker exec -u git gitea gitea admin user list 2>/dev/null | awk '{print $2}' | grep -qx "$GITEA_ADMIN_USER"; then
            _exists=true
            # --must-change-password=false matters here: change-password
            # defaults to setting that flag TRUE, which then makes Gitea
            # reject every API call (including this script's own token-based
            # calls) with 403 "You must change your password" until someone
            # logs into the web UI and clears it by hand. Confirmed live —
            # this silently broke the sync script on every retry against an
            # already-existing account.
            docker exec -u git gitea gitea admin user change-password \
                --username "$GITEA_ADMIN_USER" --password "$GITEA_ADMIN_PASS" \
                --must-change-password=false &>/dev/null
            _created=true
            break
        fi
        sleep 2
        _tries=$((_tries + 1))
    done
    if [[ "$_created" != true ]]; then
        log_error "Gitea didn't come up in time — check: docker compose -f $DIR/docker-compose.yml logs"
        log_error "Once it's healthy, just re-run 'sudo ./setup.sh gitea' to pick up from here."
        return 1
    fi
    if [[ "$_exists" == true ]]; then
        log_success "Admin account already existed: $GITEA_ADMIN_USER (password updated to what you just entered)"
    else
        log_success "Admin account created: $GITEA_ADMIN_USER"
    fi

    # Token name includes a timestamp so a retry against an account that
    # already has a "sync" token from an earlier partial run (see the
    # already-exists branch above) never collides — Gitea rejects a second
    # token with a name that's already taken for that user, which used to
    # silently fall through to the manual-paste prompt below on every retry.
    local GITEA_TOKEN=""
    GITEA_TOKEN="$(docker exec -u git gitea gitea admin user generate-access-token \
        --username "$GITEA_ADMIN_USER" --token-name "sync-$(date +%s)" \
        --scopes write:repository,write:user --raw 2>/dev/null)"
    if [[ -z "$GITEA_TOKEN" ]]; then
        log_warning "Automatic token generation didn't work (older Gitea image?) — generate one"
        log_warning "by hand: log into http://localhost:${WEB_PORT} as $GITEA_ADMIN_USER, then"
        log_warning "Settings -> Applications -> Generate New Token (repo + user write access)."
        _gitea_prompt_token "  Paste the GITEA token here (not the GitHub one — that's next):" GITEA_TOKEN
    fi

    # ── GitHub token ─────────────────────────────────────────────────────────
    echo ""
    log_info "Needs a GitHub Personal Access Token (not an SSH key — this talks to GitHub's"
    log_info "REST API too, which SSH can't do). Generate one at https://github.com/settings/tokens"
    log_info "with 'repo' scope if you don't already have one handy."
    local GITHUB_TOKEN=""
    _gitea_prompt_token "  GitHub token:" GITHUB_TOKEN
    if [[ -z "$GITHUB_TOKEN" ]]; then
        log_warning "No GitHub token entered — Gitea itself is still up, but the sync script won't"
        log_warning "work until you add one to $DIR/.env and re-run this installer (update mode)."
    fi

    cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
    chmod +x "$DIR/gitea-github-sync.sh"

    backup_if_exists "$DIR/.env"
    cat > "$DIR/.env" << ENV
# Written by services/gitea.sh — re-run that (update mode) to change any of this.
GITEA_URL='http://localhost:${WEB_PORT}'
GITEA_TOKEN='${GITEA_TOKEN}'
GITHUB_TOKEN='${GITHUB_TOKEN}'
ENV
    chmod 600 "$DIR/.env"
    chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" "$DIR/gitea-github-sync.sh"

    # ── Discover GitHub/Gitea usernames + scope prefs (the sync script's own
    # first-time setup) — runs as the real user, not root, so its config
    # lands under the real user's home, not /root.
    if [[ -n "$GITHUB_TOKEN" && -n "$GITEA_TOKEN" ]]; then
        echo ""
        if [ "$UNATTENDED" = true ]; then
            log_info "Unattended mode — skipping the interactive sync setup. Run it yourself later:"
            log_info "  cd $DIR && sudo -u $ACTUAL_USER bash gitea-github-sync.sh --init"
        else
            sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
                bash "$DIR/gitea-github-sync.sh" --init
        fi
    fi

    _gitea_run_sync_direction_step "$DIR"

    # ── Caddy — no forward_auth gate here. Gitea has its own built-in login,
    # unlike the no-auth-at-all apps elsewhere in this repo that need Caddy
    # to gate them via Authelia. Optional "Sign in with Authelia" (OIDC) is
    # offered separately below, as an addition to Gitea's own login, not a
    # replacement requiring Caddy involvement. ─────────────────────────────
    configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git"

    # ── Firewall ─────────────────────────────────────────────────────────────
    # SSH clone (SSH_PORT->22) is a different protocol than the web UI — Caddy
    # can't front it no matter what CADDY_SERVICE_MODE came back as, so it
    # always needs its own direct rule or `git clone ssh://...` hangs forever
    # (a dropped SYN with UFW active, not a fast connection-refused).
    if command -v ufw &>/dev/null; then
        if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then
            ufw delete allow "${WEB_PORT}/tcp" 2>/dev/null || true
            ufw_allow_from_caddy_net "${WEB_PORT}"
        else
            ufw allow "${WEB_PORT}/tcp" comment "Gitea web UI" >/dev/null 2>&1 || true
        fi
        ufw allow "${SSH_PORT}/tcp" comment "Gitea SSH clone" >/dev/null 2>&1 || true
        ensure_ufw_enabled
        log_success "UFW: opened SSH clone port ${SSH_PORT}/tcp"
    else
        log_warning "ufw not installed — if you use a firewall, open TCP ${SSH_PORT} for SSH clones."
    fi

    _gitea_offer_authelia_sso "$DIR"
    _gitea_offer_reverse_proxy_auth "$DIR"
    _gitea_offer_actions_runner "$DIR"

    write_readme "$DIR" << MD
# Gitea

Self-hosted Git server. Raw, real working-copy clones are just normal
\`git clone\` commands against it (or against GitHub directly) — Gitea's own
storage is separate from that, used for the web UI and the mirror sync
below.

- Web UI: http://localhost:${WEB_PORT}
- Admin login: \`${GITEA_ADMIN_USER}\` / see \`.env\` if you need the generated
  password again (\`docker exec -u git gitea gitea admin user change-password\`
  to rotate it)
- SSH clone port: ${SSH_PORT} (e.g. \`git clone ssh://git@localhost:${SSH_PORT}/user/repo.git\`)

## GitHub mirror sync

\`gitea-github-sync.sh\` (in this directory) mirrors repos between this Gitea
and GitHub. Tokens live in \`.env\` (chmod 600) — treat them like passwords.

\`\`\`bash
cd $DIR
bash gitea-github-sync.sh --list          # preview what would sync, no changes
bash gitea-github-sync.sh --pull-only     # GitHub -> Gitea only
bash gitea-github-sync.sh --push-only     # Gitea -> GitHub only
bash gitea-github-sync.sh                 # both directions
\`\`\`

Config (which repos, private/forks handling) lives at
\`~/.config/gitea-github-sync/config\` — edit directly, or re-run
\`bash gitea-github-sync.sh --init\` to redo it interactively.

## Sign in with Authelia (optional)

If Authelia is installed, re-run \`sudo ./setup.sh gitea\` (Update mode is
fine — this doesn't touch tokens or anything else) and answer yes to
"Add \"Sign in with Authelia\"?" to add it as an extra OAuth2 login option
on Gitea's own login page. Local admin login keeps working exactly as
before — this is additive, not a replacement. Managed in Gitea under
Site Administration -> Authentication Sources (source name: \`authelia\`).

## Zero-click Authelia login (optional, stronger)

A second, separate Authelia integration: instead of an extra button on
Gitea's login page, Gitea auto-logs in as whoever Authelia says you are —
no click, and no separate Gitea session that can expire on its own and
force a re-login later. Re-run \`sudo ./setup.sh gitea\` (Update mode) and
answer yes to the "Skip Gitea's own login entirely..." prompt. Requires
Authelia and Caddy on this same machine — it moves Gitea onto Caddy's
internal Docker network (\`caddy_net\`) and Gitea only trusts the identity
header from that network's address range, not from the internet or from
its own host-published port. Gitea's own login page keeps working for
anyone who reaches it any other way (e.g. directly on its port). New
users arriving this way get an ordinary (non-admin) Gitea account created
automatically the first time they show up.

## Gitea Actions (CI) — optional local runner

Re-run \`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to
"Enable Gitea Actions?" to run \`.gitea/workflows/*.yml\` here the same way
GitHub Actions runs \`.github/workflows/*.yml\` — mainly useful as a fallback
so builds still work if GitHub is ever unreachable. Adds an \`act_runner\`
container (\`docker compose ps\` will show \`gitea-runner\`) that polls this
Gitea instance for jobs and launches a fresh container per job using this
host's own Docker socket — same pattern already used by this repo's
portainer/watchtower/uptimekuma services, not something new. Manage runners
under Site Administration -> Actions -> Runners.

## Manage

\`\`\`bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
sudo ./setup.sh gitea       # re-run to change sync direction/schedule, or refresh
\`\`\`
MD

    echo ""
    log_success "Gitea installed at $DIR"
    echo "  Web UI: http://localhost:${WEB_PORT}  (login: ${GITEA_ADMIN_USER})"
    echo "  Details, sync commands: $DIR/README.md"
    echo ""
}

# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_gitea
