#!/usr/bin/env bash
#
# UMNI Ecosystem — single-file installer for Ubuntu (Docker, prebuilt release images).
#
# Based on the official guide:
#   "Установка UMNI Server в среде виртуализации Proxmox VE на Debian 13 + Docker"
# but adapted from Debian to Ubuntu: the Docker APT repository uses
# https://download.docker.com/linux/ubuntu (NOT .../debian).
#
# What it does (all steps are idempotent, existing config is kept by default):
#   1. Installs Docker CE + compose plugin from Docker's *Ubuntu* repository.
#   2. Creates the UMNI directory layout in the target directory.
#   3. Writes docker-compose.release.yml, nginx.host.conf, .env and env_config/.env.
#   4. Fixes ownership (containers run as UID 1000) and starts the stack.
#   5. Waits for health and prints the web UI URL.
#
# All progress/error output is logged in English to stdout and to a log file.
#
# Tested target: Ubuntu 24.04 LTS and Ubuntu 26.04 LTS.
# Usage: sudo ./install-umni-ubuntu.sh [options]   (see --help)

set -Eeuo pipefail

# --------------------------------------------------------------------------- #
# Defaults
# --------------------------------------------------------------------------- #
SCRIPT_NAME="$(basename "$0")"
SCRIPT_VERSION="1.0.0"

INSTALL_DIR="/opt/umni-ecosystem"
LOG_FILE="/var/log/umni-install.log"

IMAGE_OWNER="sazanof"
IMAGE_TAG="latest"

WEB_PORT="80"
DB_USER="postgres"
DB_PASSWORD="postgres"
DB_NAME="ecosystem"
DB_PORT="5432"
BACKEND_LOG_LEVEL="debug"
STORAGE_HOST_BASE="/host"

STORAGE_MOUNTS=()

OPEN_FIREWALL=0
REPLACE_DOCKER=0
SKIP_DOCKER=0
FORCE_CONFIG=0
DO_START=1
HEALTH_TIMEOUT=180

DOCKER_UBUNTU_REPO="https://download.docker.com/linux/ubuntu"

# --------------------------------------------------------------------------- #
# Logging (English)
# --------------------------------------------------------------------------- #
C_RESET=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""
if [ -t 1 ]; then
  C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m'
  C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[36m'
fi

_LOG_READY=0
_setup_logging() {
  [ "$_LOG_READY" -eq 1 ] && return 0
  local dir; dir="$(dirname "$LOG_FILE")"
  mkdir -p "$dir" 2>/dev/null || true
  : > "$LOG_FILE" 2>/dev/null || true
  # Everything below (stdout+stderr) goes to the console and to the log file.
  exec > >(tee -a "$LOG_FILE") 2>&1
  _LOG_READY=1
  export _LOG_READY
}

_ts() { date '+%Y-%m-%d %H:%M:%S'; }

log() {
  local level="$1"; shift
  local color=""
  case "$level" in
    INFO)  color="$C_GREEN" ;;
    STEP)  color="$C_BLUE" ;;
    WARN)  color="$C_YELLOW" ;;
    ERROR) color="$C_RED" ;;
  esac
  # stderr is redirected into the tee pipe by _setup_logging, so a single
  # write reaches both the console and the log file.
  printf '%s[%s] %-5s%s %s\n' "$C_DIM" "$(_ts)" "$level" "$C_RESET${color}" "$*" >&2
}

step() { log STEP "==> $*"; }
info() { log INFO "$*"; }
warn() { log WARN "$*"; }
die()  { log ERROR "$*"; exit 1; }

on_error() {
  local code="$1" line="$2"
  log ERROR "Unexpected failure (exit code ${code}) at ${SCRIPT_NAME}:${line}"
  log ERROR "Check the log: ${LOG_FILE}"
  exit "$code"
}
trap 'on_error $? $LINENO' ERR

# --------------------------------------------------------------------------- #
# Argument parsing
# --------------------------------------------------------------------------- #
usage() {
  cat <<EOF
${SCRIPT_NAME} ${SCRIPT_VERSION} — UMNI Ecosystem installer for Ubuntu.

USAGE:
  sudo ${SCRIPT_NAME} [options]

OPTIONS:
  -d, --dir DIR            Install directory (default: ${INSTALL_DIR})
      --image-owner NAME   Docker Hub owner of the images (default: ${IMAGE_OWNER})
      --image-tag TAG      Image tag to deploy (default: ${IMAGE_TAG})
      --web-port PORT      Port the web UI is served on (default: ${WEB_PORT})
      --db-user USER       PostgreSQL user (default: ${DB_USER})
      --db-password PASS   PostgreSQL password (default: ${DB_PASSWORD})
      --db-name NAME       PostgreSQL database name (default: ${DB_NAME})
      --backend-log-level L  Backend LOG_LEVEL written to env_config/.env (default: ${BACKEND_LOG_LEVEL})
      --storage PATH[,PATH...]  Extra host path(s) to bind-mount as storage (repeatable)
      --log FILE           Log file path (default: ${LOG_FILE})
      --open-firewall      Add ufw rules (80/tcp, 514/udp, 5353/udp) if ufw is active
      --replace-docker     Remove conflicting packages (docker.io, containerd, ...) automatically
      --skip-docker        Do not touch the Docker installation
      --force-config       Overwrite existing config files (backups are kept as *.bak)
      --no-start           Only prepare files/Docker, do not start the stack
      --health-timeout SEC Seconds to wait for the stack to become healthy (default: ${HEALTH_TIMEOUT})
  -h, --help               Show this help and exit

EXAMPLES:
  sudo ${SCRIPT_NAME}
  sudo ${SCRIPT_NAME} --dir /srv/umni --web-port 8080 --storage /mnt/umni-data,/mnt/cam
  sudo ${SCRIPT_NAME} --skip-docker --no-start
EOF
}

parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      -d|--dir)                INSTALL_DIR="${2:?missing value for $1}"; shift 2 ;;
      --dir=*)                 INSTALL_DIR="${1#*=}"; shift ;;
      --image-owner)           IMAGE_OWNER="${2:?missing value for $1}"; shift 2 ;;
      --image-owner=*)         IMAGE_OWNER="${1#*=}"; shift ;;
      --image-tag)             IMAGE_TAG="${2:?missing value for $1}"; shift 2 ;;
      --image-tag=*)           IMAGE_TAG="${1#*=}"; shift ;;
      --web-port)              WEB_PORT="${2:?missing value for $1}"; shift 2 ;;
      --web-port=*)            WEB_PORT="${1#*=}"; shift ;;
      --db-user)               DB_USER="${2:?missing value for $1}"; shift 2 ;;
      --db-user=*)             DB_USER="${1#*=}"; shift ;;
      --db-password)           DB_PASSWORD="${2:?missing value for $1}"; shift 2 ;;
      --db-password=*)         DB_PASSWORD="${1#*=}"; shift ;;
      --db-name)               DB_NAME="${2:?missing value for $1}"; shift 2 ;;
      --db-name=*)             DB_NAME="${1#*=}"; shift ;;
      --backend-log-level)     BACKEND_LOG_LEVEL="${2:?missing value for $1}"; shift 2 ;;
      --backend-log-level=*)   BACKEND_LOG_LEVEL="${1#*=}"; shift ;;
      --storage)               append_storage "${2:?missing value for $1}"; shift 2 ;;
      --storage=*)             append_storage "${1#*=}"; shift ;;
      --log)                   LOG_FILE="${2:?missing value for $1}"; shift 2 ;;
      --log=*)                 LOG_FILE="${1#*=}"; shift ;;
      --open-firewall)         OPEN_FIREWALL=1; shift ;;
      --replace-docker)        REPLACE_DOCKER=1; shift ;;
      --skip-docker)           SKIP_DOCKER=1; shift ;;
      --force-config)          FORCE_CONFIG=1; shift ;;
      --no-start)              DO_START=0; shift ;;
      --health-timeout)        HEALTH_TIMEOUT="${2:?missing value for $1}"; shift 2 ;;
      --health-timeout=*)      HEALTH_TIMEOUT="${1#*=}"; shift ;;
      -h|--help)               usage; exit 0 ;;
      *) die "Unknown option: $1 (use --help)" ;;
    esac
  done
}

append_storage() {
  local raw="$1" part
  IFS=',' read -r -a parts <<< "$raw"
  for part in "${parts[@]}"; do
    part="$(printf '%s' "$part" | xargs)"
    [ -n "$part" ] && STORAGE_MOUNTS+=("$part")
  done
}

# --------------------------------------------------------------------------- #
# Pre-flight checks
# --------------------------------------------------------------------------- #
require_root() {
  [ "$(id -u)" -eq 0 ] || die "This installer must be run as root (try: sudo $SCRIPT_NAME ...)"
}

detect_os() {
  [ -r /etc/os-release ] || die "/etc/os-release not found — unsupported system."
  # shellcheck disable=SC1091
  . /etc/os-release
  OS_ID="${ID:-unknown}"
  OS_VERSION="${VERSION_ID:-unknown}"
  OS_CODENAME="${VERSION_CODENAME:-}"
  if [ -z "$OS_CODENAME" ] && command -v lsb_release >/dev/null 2>&1; then
    OS_CODENAME="$(lsb_release -sc 2>/dev/null || true)"
  fi
  OS_PRETTY="${PRETTY_NAME:-$OS_ID $OS_VERSION}"

  if [ "$OS_ID" != "ubuntu" ]; then
    warn "Detected '${OS_PRETTY}' — this script targets Ubuntu and configures the Ubuntu Docker repository."
    warn "Continuing anyway; Docker packages are fetched from ${DOCKER_UBUNTU_REPO}."
  else
    info "Detected OS: ${OS_PRETTY} (codename: ${OS_CODENAME:-unknown})"
  fi

  ARCH="$(dpkg --print-architecture 2>/dev/null || true)"
  [ -n "$ARCH" ] || die "Could not detect CPU architecture (dpkg --print-architecture)."
  info "Architecture: ${ARCH}"
}

check_port_available() {
  local port="$1"
  if ! command -v ss >/dev/null 2>&1; then
    warn "'ss' is not available; skipping the port availability check."
    return 0
  fi
  local owners
  owners="$(ss -ltnpH "sport = :${port}" 2>/dev/null || true)"
  [ -n "$owners" ] || { info "Port ${port} is free."; return 0; }

  # Nothing to do if our own containers already own the port (re-run).
  if printf '%s' "$owners" | grep -qE 'docker'; then
    info "Port ${port} is already served by Docker (our stack) — OK."
    return 0
  fi

  warn "Port ${port} is already in use:"
  printf '%s\n' "$owners" >&2
  # A system web server on :80 would block the UMNI frontend container.
  local svc
  for svc in apache2 nginx; do
    if systemctl is-active --quiet "$svc" 2>/dev/null; then
      warn "Stopping and disabling system service '${svc}' because it conflicts with the UMNI frontend."
      systemctl stop "$svc" || true
      systemctl disable "$svc" >/dev/null 2>&1 || true
    fi
  done
  if ss -ltnH "sport = :${port}" 2>/dev/null | grep -q .; then
    warn "Port ${port} is still busy. Change it with --web-port or free it, otherwise the frontend container will fail."
  fi
}

# --------------------------------------------------------------------------- #
# Step 1 — Docker from the Ubuntu repository
# --------------------------------------------------------------------------- #
detect_docker_suite() {
  # Returns an existing suite/codename in Docker's Ubuntu repo.
  # New Ubuntu releases (e.g. 26.04) may not have a dist in the Docker repo yet,
  # so we fall back to the closest supported LTS codename.
  local candidates=()
  [ -n "${OS_CODENAME:-}" ] && candidates+=("$OS_CODENAME")
  candidates+=(noble jammy focal)

  local c
  for c in "${candidates[@]}"; do
    if curl -fsSI --max-time 15 "${DOCKER_UBUNTU_REPO}/dists/${c}/Release" >/dev/null 2>&1; then
      printf '%s' "$c"
      return 0
    fi
  done
  return 1
}

handle_conflicting_packages() {
  local pkg conflicts=()
  for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
    if dpkg -s "$pkg" >/dev/null 2>&1; then
      conflicts+=("$pkg")
    fi
  done
  [ "${#conflicts[@]}" -gt 0 ] || return 0

  if dpkg -s docker-ce >/dev/null 2>&1; then
    # docker-ce already installed: apt will resolve/replace conflicts itself.
    info "docker-ce is installed; conflicting packages present: ${conflicts[*]}"
    return 0
  fi

  if [ "$REPLACE_DOCKER" -eq 1 ]; then
    warn "Removing conflicting packages: ${conflicts[*]}"
    DEBIAN_FRONTEND=noninteractive apt-get remove -y "${conflicts[@]}"
  else
    die "Conflicting packages found: ${conflicts[*]}. Re-run with --replace-docker to remove them, or install Docker manually."
  fi
}

install_docker() {
  if [ "$SKIP_DOCKER" -eq 1 ]; then
    info "Skipping Docker installation (--skip-docker)."
    return 0
  fi

  step "Installing Docker Engine from the Ubuntu repository"
  export DEBIAN_FRONTEND=noninteractive

  apt-get update -y
  apt-get install -y ca-certificates curl gnupg

  handle_conflicting_packages

  local suite
  if ! suite="$(detect_docker_suite)"; then
    die "Could not find a usable suite in ${DOCKER_UBUNTU_REPO}. Check network/proxy access."
  fi
  if [ "$suite" != "${OS_CODENAME:-}" ]; then
    warn "Docker has no '${OS_CODENAME:-unknown}' distribution yet; using the '${suite}' suite instead."
  fi
  info "Docker APT suite: ${suite} (arch: ${ARCH})"

  step "Adding Docker's official GPG key"
  install -m 0755 -d /etc/apt/keyrings
  curl -fsSL --max-time 60 "${DOCKER_UBUNTU_REPO}/gpg" -o /etc/apt/keyrings/docker.asc
  chmod a+r /etc/apt/keyrings/docker.asc

  step "Adding the Ubuntu Docker APT repository"
  # Legacy one-line list from older guides would create duplicate sources.
  if [ -f /etc/apt/sources.list.d/docker.list ]; then
    warn "Removing legacy /etc/apt/sources.list.d/docker.list (replaced by docker.sources)."
    rm -f /etc/apt/sources.list.d/docker.list
  fi
  cat > /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: ${DOCKER_UBUNTU_REPO}
Suites: ${suite}
Components: stable
Architectures: ${ARCH}
Signed-By: /etc/apt/keyrings/docker.asc
EOF

  apt-get update -y

  step "Installing docker-ce, docker-ce-cli, containerd.io, buildx and compose plugins"
  apt-get install -y \
    docker-ce docker-ce-cli containerd.io \
    docker-buildx-plugin docker-compose-plugin

  step "Enabling and starting the Docker service"
  systemctl enable --now docker

  if ! docker info >/dev/null 2>&1; then
    sleep 3
    docker info >/dev/null 2>&1 || die "Docker daemon is not responding. Check: systemctl status docker"
  fi
  if ! docker compose version >/dev/null 2>&1; then
    die "'docker compose' plugin is not available."
  fi
  info "Docker: $(docker --version 2>/dev/null | head -n1)"
  info "Compose: $(docker compose version 2>/dev/null | head -n1)"
}

# --------------------------------------------------------------------------- #
# Step 2 — UMNI files
# --------------------------------------------------------------------------- #
backup_if_exists() {
  local f="$1"
  if [ -e "$f" ]; then
    cp -a "$f" "${f}.bak.$(date '+%Y%m%d%H%M%S')"
    info "Backed up existing $(basename "$f")"
  fi
}

write_file() {
  # write_file <path> ...reads content from stdin
  local target="$1" tmp
  tmp="$(mktemp)"
  cat > "$tmp"
  if [ -e "$target" ] && [ "$FORCE_CONFIG" -ne 1 ]; then
    rm -f "$tmp"
    info "Keeping existing $(basename "$target") (use --force-config to overwrite)."
    return 0
  fi
  [ -e "$target" ] && backup_if_exists "$target"
  install -m 0644 "$tmp" "$target"
  rm -f "$tmp"
  info "Wrote $(basename "$target")"
}

create_layout() {
  step "Creating the directory layout in ${INSTALL_DIR}"
  install -d -m 0755 \
    "$INSTALL_DIR" \
    "$INSTALL_DIR/env_config" \
    "$INSTALL_DIR/logs" \
    "$INSTALL_DIR/storage" \
    "$INSTALL_DIR/plugins" \
    "$INSTALL_DIR/plugins/custom"
  info "Layout ready: env_config/ logs/ storage/ plugins/custom/"
}

write_compose() {
  step "Writing docker-compose.release.yml"

  # Extra host storage mounts (Linux host: container path == host path).
  local binds_file; binds_file="$(mktemp)"
  if [ "${#STORAGE_MOUNTS[@]}" -gt 0 ]; then
    local m
    for m in "${STORAGE_MOUNTS[@]}"; do
      printf '      - type: bind\n        source: %s\n        target: %s\n' "$m" "$m" >> "$binds_file"
    done
    info "Extra storage bind mounts: ${STORAGE_MOUNTS[*]}"
  fi

  local tmp; tmp="$(mktemp)"
  cat > "$tmp" <<'YAML'
# Deployment from prebuilt Docker Hub images (no build from sources).
#
# Layout created by install-umni-ubuntu.sh:
#   docker-compose.release.yml  nginx.host.conf  .env  env_config/.env
#
# Start:
#   docker compose -f docker-compose.release.yml pull
#   docker compose -f docker-compose.release.yml up -d
#
# HOST MODE (network_mode: host):
#   backend  — needs host networking for multicast DNS (_umni_api._tcp.local.)
#              and UDP broadcast syslog (port 514) from devices.
#   frontend — nginx listens on the host port and proxies to the backend on loopback.
#   postgres — stays on the bridge app-network, published only on 127.0.0.1:5432.
#
# IMPORTANT: a service with network_mode: host must not define networks:, ports: or expose:.

services:
  postgres:
    image: postgres:16-alpine
    container_name: umni_postgres
    restart: unless-stopped
    env_file:
      - ./env_config/.env
    environment:
      TZ: Europe/Moscow
      POSTGRES_DB: ${DB_NAME:-umni}
      POSTGRES_USER: ${DB_USER:-postgres}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
    ports:
      - "127.0.0.1:5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    image: ${IMAGE_OWNER:-sazanof}/umni-ecosystem-backend:${IMAGE_TAG:-latest}
    container_name: umni_backend
    restart: unless-stopped
    network_mode: host
    command: uvicorn main:app --host 127.0.0.1 --port 8000
    depends_on:
      postgres:
        condition: service_healthy
    env_file:
      - ./env_config/.env
    environment:
      DB_HOST: 127.0.0.1
      DB_USER: ${DB_USER:-postgres}
      DB_PASSWORD: ${DB_PASSWORD:-postgres}
      DB_NAME: ${DB_NAME:-umni}
      DB_PORT: ${DB_PORT:-5432}
      LOG_LEVEL: ${LOG_LEVEL:-INFO}
      LOG_DIR: /app/logs
      STORAGE_HOST_BASE: ${STORAGE_HOST_BASE:-/host}
      TZ: Europe/Moscow
    volumes:
      - ./logs:/app/logs
      # env_config is mounted read-write: the app persists ENCRYPTION_KEY here.
      - ./env_config:/app/env_config
      - ./storage:/app/storage
      - ./plugins/custom:/app/plugins/custom
      # Host storage. On a Linux host the path is the container path (same as the bind).
      # __UMNI_EXTRA_STORAGE_BINDS__

  frontend:
    image: ${IMAGE_OWNER:-sazanof}/umni-ecosystem-frontend:${IMAGE_TAG:-latest}
    container_name: umni_frontend
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./nginx.host.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      backend:
        condition: service_healthy

volumes:
  postgres_data:

networks:
  app-network:
    driver: bridge
YAML

  # Replace the storage placeholder with the actual bind mounts.
  local out; out="$(mktemp)"
  awk -v binds="$binds_file" '
    /^[[:space:]]*# __UMNI_EXTRA_STORAGE_BINDS__[[:space:]]*$/ {
      while ((getline line < binds) > 0) print line
      next
    }
    { print }
  ' "$tmp" > "$out"
  rm -f "$tmp" "$binds_file"

  local target="$INSTALL_DIR/docker-compose.release.yml"
  if [ -e "$target" ] && [ "$FORCE_CONFIG" -ne 1 ]; then
    rm -f "$out"
    info "Keeping existing docker-compose.release.yml (use --force-config to overwrite)."
  else
    [ -e "$target" ] && backup_if_exists "$target"
    install -m 0644 "$out" "$target"
    rm -f "$out"
    info "Wrote docker-compose.release.yml"
  fi
}

write_nginx() {
  step "Writing nginx.host.conf"
  write_file "$INSTALL_DIR/nginx.host.conf" <<NGINX
events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    sendfile on;
    keepalive_timeout 65;

    client_max_body_size 100m;

    server {
        listen ${WEB_PORT};
        server_name localhost;

        root /usr/share/nginx/html;
        index index.html;

        location / {
            try_files \$uri \$uri/ /index.html;
        }

        # Backend routes have no /api prefix (auth, ws, cameras, ...),
        # so the prefix is stripped by the trailing slash in proxy_pass.
        # Host mode: the backend listens on 127.0.0.1:8000 in the host network.
        location /api/ {
            proxy_pass http://127.0.0.1:8000/;
            proxy_http_version 1.1;
            proxy_set_header Upgrade \$http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host \$host;
            proxy_set_header X-Real-IP \$remote_addr;
            proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto \$scheme;
            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
        }

        location /docs {
            proxy_pass http://127.0.0.1:8000/docs;
            proxy_set_header Host \$host;
            proxy_set_header X-Real-IP \$remote_addr;
        }

        location /openapi.json {
            proxy_pass http://127.0.0.1:8000/openapi.json;
            proxy_set_header Host \$host;
            proxy_set_header X-Real-IP \$remote_addr;
        }
    }
}
NGINX
}

write_root_env() {
  step "Writing .env (docker compose interpolation)"
  local lvl_upper; lvl_upper="$(printf '%s' "$BACKEND_LOG_LEVEL" | tr '[:lower:]' '[:upper:]')"
  local tmp; tmp="$(mktemp)"
  {
    printf '# Only used for docker compose interpolation; it is NOT passed into containers directly.\n'
    printf '# Keep DB_* values in sync with env_config/.env (backend).\n\n'
    printf '# Docker Hub images to deploy\n'
    printf 'IMAGE_OWNER=%s\n' "$IMAGE_OWNER"
    printf 'IMAGE_TAG=%s\n' "$IMAGE_TAG"
    printf '\n'
    printf 'DB_USER=%s\n' "$DB_USER"
    printf 'DB_PASSWORD=%s\n' "$DB_PASSWORD"
    printf 'DB_NAME=%s\n' "$DB_NAME"
    printf 'DB_PORT=%s\n' "$DB_PORT"
    printf '\n# Port the web UI is exposed on (http://<host>:%s)\n' "$WEB_PORT"
    printf 'WEB_PORT=%s\n' "$WEB_PORT"
    printf '\n# Backend log level (compose passes it to the container, overriding env_config/.env)\n'
    printf 'LOG_LEVEL=%s\n' "$lvl_upper"
    printf '\n# Root used when translating host paths into container paths\n'
    printf 'STORAGE_HOST_BASE=%s\n' "$STORAGE_HOST_BASE"
  } > "$tmp"
  local target="$INSTALL_DIR/.env"
  if [ -e "$target" ] && [ "$FORCE_CONFIG" -ne 1 ]; then
    rm -f "$tmp"
    info "Keeping existing .env (use --force-config to overwrite)."
  else
    [ -e "$target" ] && backup_if_exists "$target"
    install -m 0640 "$tmp" "$target"
    rm -f "$tmp"
    info "Wrote .env"
  fi
}

write_backend_env() {
  step "Writing env_config/.env (backend configuration)"
  local tmp; tmp="$(mktemp)"
  {
    printf 'APP_MODE=production\n'
    printf 'DB_HOST=postgres\n'
    printf 'DB_USER=%s\n' "$DB_USER"
    printf 'DB_PASSWORD=%s\n' "$DB_PASSWORD"
    printf 'DB_NAME=%s\n' "$DB_NAME"
    printf 'DB_PORT=%s\n' "$DB_PORT"
    printf 'LOG_LEVEL=%s\n' "$BACKEND_LOG_LEVEL"
    printf 'DEBUG_MODE=app,cameras,devices,rules,mqtt,services,storages,systeminfo,scheduler,tasks,websockets,notifications,plugins\n'
    printf '\n# ENCRYPTION_KEY is generated by the backend on first start and saved into this file.\n'
    printf '# ENCRYPTION_KEY=\n'
  } > "$tmp"

  local target="$INSTALL_DIR/env_config/.env"
  if [ -e "$target" ] && [ "$FORCE_CONFIG" -ne 1 ]; then
    rm -f "$tmp"
    info "Keeping existing env_config/.env (use --force-config to overwrite)."
  else
    [ -e "$target" ] && backup_if_exists "$target"
    install -m 0640 "$tmp" "$target"
    rm -f "$tmp"
    info "Wrote env_config/.env (APP_MODE=production, LOG_LEVEL=${BACKEND_LOG_LEVEL})"
  fi
}

fix_ownership() {
  step "Fixing ownership (containers run as UID 1000)"
  local d
  for d in env_config logs storage plugins; do
    [ -e "$INSTALL_DIR/$d" ] || continue
    chown -R 1000:1000 "$INSTALL_DIR/$d" || warn "Could not chown ${d} (filesystem may not support it)."
  done
  info "Ownership set to 1000:1000 for env_config, logs, storage, plugins"
}

configure_firewall() {
  if ! command -v ufw >/dev/null 2>&1; then
    return 0
  fi
  if ! ufw status 2>/dev/null | grep -q "Status: active"; then
    return 0
  fi

  if [ "$OPEN_FIREWALL" -eq 1 ]; then
    step "Adding ufw rules (web, syslog, mDNS)"
    ufw allow "${WEB_PORT}/tcp" comment 'UMNI web UI' >/dev/null 2>&1 || true
    ufw allow 514/udp comment 'UMNI device syslog' >/dev/null 2>&1 || true
    ufw allow 5353/udp comment 'UMNI mDNS device discovery' >/dev/null 2>&1 || true
    info "ufw rules added for ${WEB_PORT}/tcp, 514/udp, 5353/udp"
  else
    warn "ufw is active. Device discovery/syslog need extra rules. Run with --open-firewall or manually:"
    warn "  ufw allow ${WEB_PORT}/tcp && ufw allow 514/udp && ufw allow 5353/udp"
  fi
}

# --------------------------------------------------------------------------- #
# Step 3 — Start and verify
# --------------------------------------------------------------------------- #
compose() { docker compose -f "$INSTALL_DIR/docker-compose.release.yml" "$@"; }

validate_compose() {
  command -v docker >/dev/null 2>&1 || die "Docker is not installed. Re-run without --skip-docker."
  docker compose version >/dev/null 2>&1 || die "The 'docker compose' plugin is not available."
  step "Validating docker-compose.release.yml"
  ( cd "$INSTALL_DIR" && compose config -q ) || die "docker-compose.release.yml is invalid."
  info "Compose configuration is valid."
}

start_stack() {
  step "Pulling images (owner: ${IMAGE_OWNER}, tag: ${IMAGE_TAG})"
  ( cd "$INSTALL_DIR" && compose pull ) || warn "Image pull reported errors; continuing with local images if present."

  step "Starting the containers"
  ( cd "$INSTALL_DIR" && compose up -d )

  step "Container status"
  ( cd "$INSTALL_DIR" && compose ps ) || true
}

wait_for_health() {
  local deadline=$((SECONDS + HEALTH_TIMEOUT))
  local url="http://127.0.0.1:${WEB_PORT}/api/health"
  step "Waiting for the stack to become healthy (up to ${HEALTH_TIMEOUT}s)"
  while [ "$SECONDS" -lt "$deadline" ]; do
    if curl -fsS --max-time 5 "$url" >/dev/null 2>&1; then
      printf '\n'
      info "Health check passed: ${url}"
      return 0
    fi
    printf '.' >&2
    sleep 3
  done
  printf '\n'
  warn "Health check did not pass within ${HEALTH_TIMEOUT}s."
  warn "Current status:"
  ( cd "$INSTALL_DIR" && compose ps ) || true
  warn "Recent backend logs:"
  ( cd "$INSTALL_DIR" && compose logs --tail 40 backend ) || true
  return 1
}

print_summary() {
  local ip
  ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
  [ -n "$ip" ] || ip="<server-ip>"

  log STEP "======================================================"
  info "UMNI Ecosystem installation finished"
  info "Directory : ${INSTALL_DIR}"
  info "Log file  : ${LOG_FILE}"
  info "Web UI    : http://${ip}:${WEB_PORT}/#/"
  info "Health    : http://${ip}:${WEB_PORT}/api/health"
  info "Docs      : http://${ip}:${WEB_PORT}/docs"
  printf '\n' >&2
  info "Useful commands:"
  info "  cd ${INSTALL_DIR} && docker compose -f docker-compose.release.yml ps"
  info "  cd ${INSTALL_DIR} && docker compose -f docker-compose.release.yml logs -f backend"
  info "  cd ${INSTALL_DIR} && docker compose -f docker-compose.release.yml down"
  if [ "${#STORAGE_MOUNTS[@]}" -gt 0 ]; then
    info "Storage binds: ${STORAGE_MOUNTS[*]}"
  fi
  log STEP "======================================================"
}

# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #
main() {
  parse_args "$@"
  _setup_logging
  log STEP "${SCRIPT_NAME} ${SCRIPT_VERSION} starting (log: ${LOG_FILE})"
  require_root
  detect_os
  install_docker
  create_layout
  check_port_available "$WEB_PORT"
  write_compose
  write_nginx
  write_root_env
  write_backend_env
  fix_ownership
  configure_firewall

  if [ "$DO_START" -eq 1 ]; then
    validate_compose
    start_stack
    wait_for_health || warn "Installation completed, but the stack is not healthy yet — check the logs above."
    print_summary
  else
    info "Files prepared (--no-start). Start manually with:"
    info "  cd ${INSTALL_DIR} && docker compose -f docker-compose.release.yml up -d"
  fi

  info "Done."
}

main "$@"
