#!/usr/bin/env bash
# ============================================================
#  soft-repo-manager.sh v1.8 — центр управления личным репозиторием софта Ubuntu
#  v1.8: + прогресс [i/N | xx%] и ✅/❌ по каждому пакету в пункте 11
#        + rsync --info=progress2 для offline-бандлов (видны % и скорость)
#        + проверка блокировки dpkg перед установкой (PID виновника)
#        + глобальный лог сеанса logs/run-*.log и лог apt logs/apt-*.log
#        + автосоздание ~/soft-tracker и ~/soft-tracker/logs при старте
# ============================================================
set -uo pipefail

# ===================== КОНФИГ =====================
TRACKER_DIR="$HOME/soft-tracker"
OUT="$TRACKER_DIR/out"
REPO="$HOME/ubuntu-repo"
LIST="$TRACKER_DIR/install-list.txt"
NAS_TARGET="${NAS_TARGET:-nikita@192.168.1.150:/volume1/soft/ubuntu-repo}"
REPO_URL="https://a2.njbest.ru/ubuntu-repo/"
CODENAME=$(. /etc/os-release 2>/dev/null && echo "$VERSION_CODENAME")
if [ -z "${CODENAME:-}" ]; then CODENAME="noble"; fi

NAS_USER="${NAS_TARGET%%@*}"
_REST="${NAS_TARGET#*@}"
NAS_HOST="${_REST%%:*}"
NAS_PATH="${_REST#*:}"

CTRL_SOCK="$TRACKER_DIR/.nas-ctrl"
RSH="ssh -o ControlMaster=auto -o ControlPath=$CTRL_SOCK -o ControlPersist=600 -o ConnectTimeout=8"

# v1.8: каталоги и логи
mkdir -p "$TRACKER_DIR" "$TRACKER_DIR/logs" "$OUT"
LOG_DIR="$TRACKER_DIR/logs"
RUN_LOG="$LOG_DIR/run-$(date +%Y%m%d-%H%M%S).log"
APT_LOG="$LOG_DIR/apt-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$RUN_LOG") 2>&1

nas_remote_rsync() {
  $RSH "$NAS_USER@$NAS_HOST" 'command -v rsync || command -v /usr/syno/bin/rsync || command -v /usr/bin/rsync || true' 2>/dev/null | head -1
}

# ===================== 1) ТРЕКЕР =====================
do_tracker() {
  echo "=== [📊] Отчёт трекера: аудит установленного софта ==="
  mkdir -p "$OUT"
  local SUDO=""
  if [ "$(id -u)" -ne 0 ]; then SUDO=sudo; fi
  $SUDO python3 - "$OUT" <<'PY'
import csv, gzip, json, os, re, subprocess, sys
from datetime import datetime
from pathlib import Path

OUT = Path(sys.argv[1]).expanduser()
OUT.mkdir(parents=True, exist_ok=True)

def run(cmd):
    p = subprocess.run(cmd, shell=True, capture_output=True, text=True, errors='replace')
    return p.stdout

def read_log(path):
    path = Path(path)
    if not path.exists():
        return ''
    if path.suffix == '.gz':
        with gzip.open(path, 'rt', errors='replace') as f:
            return f.read()
    return path.read_text(errors='replace')

records = []

apt_pkgs = set()
files = []
log_dir = Path('/var/log/apt')
if log_dir.exists():
    for f in log_dir.glob('history.log*'):
        m = re.search(r'history\.log(?:\.(\d+))?(?:\.gz)?$', f.name)
        files.append((int(m.group(1)) if m and m.group(1) else 0, f))
for _, f in sorted(files, key=lambda x: -x[0]):
    for block in read_log(f).split('\n\n'):
        sd = re.search(r'Start-Date: ([\d\-]+ [\d:]+)', block)
        if not sd:
            continue
        ts = sd.group(1)
        cmd = re.search(r'Commandline: (.*)', block)
        cmd = cmd.group(1).strip() if cmd else ''
        for field in ('Install', 'Upgrade'):
            m = re.search(field + r': (.*)', block)
            if not m:
                continue
            for name, ver in re.findall(r'([a-z0-9][a-z0-9.+\-]*):[a-z0-9_\-]+ \(([^)]*)\)', m.group(1)):
                records.append({'ts': ts, 'package': name, 'version': ver.split(',')[0],
                                'method': 'apt', 'source': '', 'command': cmd,
                                'action': field.lower()})
                apt_pkgs.add(name)

dpkg_files = []
for f in Path('/var/log').glob('dpkg.log*'):
    m = re.search(r'dpkg\.log(?:\.(\d+))?(?:\.gz)?$', f.name)
    dpkg_files.append((int(m.group(1)) if m and m.group(1) else 0, f))
for _, f in sorted(dpkg_files, key=lambda x: -x[0]):
    for line in read_log(f).splitlines():
        m = re.match(r'([\d\-]+ [\d:]+) install (\S+?):\S+ <none> (\S+)', line)
        if m and m.group(2) not in apt_pkgs:
            records.append({'ts': m.group(1), 'package': m.group(2), 'version': m.group(3),
                            'method': 'dpkg', 'source': '', 'command': 'dpkg -i <локальный .deb>',
                            'action': 'install'})
            apt_pkgs.add(m.group(2))

snap_ver = {}
for line in run('snap list 2>/dev/null').splitlines()[1:]:
    c = line.split()
    if len(c) >= 2:
        snap_ver[c[0]] = c[1]
for line in run('snap changes 2>/dev/null').splitlines()[1:]:
    c = line.split(None, 4)
    if len(c) < 5:
        continue
    m = re.search(r'Install "([^"]+)" snap', c[4])
    if m:
        records.append({'ts': c[2].replace('T', ' ')[:19], 'package': m.group(1),
                        'version': snap_ver.get(m.group(1), '?'), 'method': 'snap',
                        'source': 'snap store (snapcraft.io)',
                        'command': 'snap install ' + m.group(1), 'action': 'install'})

remote_url = {}
for line in run('flatpak remotes --columns=name,url 2>/dev/null').splitlines():
    if '\t' in line:
        n, u = line.split('\t', 1)
        remote_url[n] = u
for line in run('flatpak list --app --columns=application,version,origin 2>/dev/null').splitlines():
    if '\t' not in line:
        continue
    app, ver, origin = (line.split('\t') + [''] * 3)[:3]
    d = Path('/var/lib/flatpak/app') / app
    ts = datetime.fromtimestamp(d.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S') if d.exists() else '0000-00-00 00:00:00'
    records.append({'ts': ts, 'package': app, 'version': ver or '?', 'method': 'flatpak',
                    'source': remote_url.get(origin, origin), 'command': 'flatpak install ' + app,
                    'action': 'install', 'approx': True})

cache = {}
def source_of(pkg):
    if pkg in cache:
        return cache[pkg]
    out = run('apt-cache policy ' + pkg)
    m = re.search(r'\d+ (https?://\S+|file:\S+)', out)
    if m:
        src = m.group(1)
    elif 'Installed: (none)' not in out:
        src = 'локальный .deb (вне репозиториев)'
    else:
        src = 'неизвестно (пакет удалён)'
    cache[pkg] = src
    return src

for r in records:
    if r['method'] in ('apt', 'dpkg') and not r['source']:
        r['source'] = source_of(r['package'])

records.sort(key=lambda r: r['ts'])
for i, r in enumerate(records, 1):
    r['seq'] = i

host = run('hostname').strip()
codename = run('bash -c ". /etc/os-release && echo $VERSION_CODENAME"').strip()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')

with open(OUT / 'install-history.csv', 'w', newline='', encoding='utf-8-sig') as f:
    w = csv.writer(f, delimiter=';')
    w.writerow(['№', 'Дата', 'Пакет', 'Версия', 'Способ', 'Источник', 'Команда'])
    for r in records:
        w.writerow([r['seq'], r['ts'], r['package'], r['version'], r['method'], r['source'], r['command']])

(OUT / 'install-history.json').write_text(json.dumps(records, ensure_ascii=False, indent=1), encoding='utf-8')

md = ['# Отчёт об установках софта — ' + host, '',
      '- **Создан:** ' + now,
      '- **ОС:** Ubuntu ' + codename,
      '- **Всего записей:** ' + str(len(records)), '',
      '## Сводка по способам', '| Способ | Кол-во |', '|---|---|']
for mth in sorted({r['method'] for r in records}):
    md.append('| %s | %d |' % (mth, sum(1 for r in records if r['method'] == mth)))
md += ['', '## Сводка по серверам-источникам', '| Источник | Кол-во |', '|---|---|']
srcs = {}
for r in records:
    srcs[r['source']] = srcs.get(r['source'], 0) + 1
for s, c in sorted(srcs.items(), key=lambda kv: -kv[1]):
    md.append('| %s | %d |' % (s or '—', c))
md += ['', '## Хронология установок',
       '| № | Дата | Пакет | Версия | Способ | Источник | Команда |',
       '|---|---|---|---|---|---|---|']
for r in records:
    md.append('| %d | %s | %s | %s | %s | %s | %s |' % (
        r['seq'], r['ts'], r['package'], r['version'], r['method'],
        r['source'] or '—', r['command'].replace('|', '\\|')[:90]))
(OUT / 'install-history.md').write_text('\n'.join(md) + '\n', encoding='utf-8')

if os.environ.get('SUDO_UID'):
    uid, gid = int(os.environ['SUDO_UID']), int(os.environ.get('SUDO_GID', 0))
    for p in OUT.iterdir():
        os.chown(p, uid, gid)

print('✅ Отчёты готовы в', OUT)
print('   записей:', len(records))
for p in sorted(OUT.iterdir()):
    print('  -', p.name)
PY
}

# ===================== ПУБЛИКАЦИЯ =====================
do_publish_repo() {
  echo "=== [📤] Публикация репозитория на NAS (аддитивно) ==="
  mkdir -p "$REPO/tools"
  cp -f "$0" "$REPO/tools/soft-repo-manager.sh" 2>/dev/null
  if [ -f "$LIST" ]; then cp -f "$LIST" "$REPO/tools/install-list.txt" 2>/dev/null; fi
  if ! command -v rsync >/dev/null; then
    echo "⚠️ Локально нет rsync — пробую scp..."
    scp -r -o ControlMaster=auto -o ControlPath="$CTRL_SOCK" -o ControlPersist=600 "$REPO/" "$NAS_TARGET/" \
      && { echo "✅ Опубликовано (scp)"; return 0; }
    echo "❌ scp не смог — запусти пункт 10 (Диагностика)"; return 1
  fi
  local rpath
  rpath=$(nas_remote_rsync)
  if [ -z "$rpath" ]; then
    echo "⚠️ На NAS не найден rsync — пробую scp..."
    scp -r -o ControlMaster=auto -o ControlPath="$CTRL_SOCK" -o ControlPersist=600 "$REPO/" "$NAS_TARGET/" \
      && { echo "✅ Опубликовано (scp)"; return 0; }
    echo "❌ Не удалось — запусти пункт 10 (Диагностика)"; return 1
  fi
  echo "-- rsync на NAS: $rpath"
  if rsync -av --info=progress2 -e "$RSH" --rsync-path="$rpath" "$REPO/" "$NAS_TARGET/"; then
    echo "✅ Опубликовано на NAS (включая tools/ для новых машин)"
    return 0
  fi
  echo "❌ Ошибка публикации — запусти пункт 10 (Диагностика) и пришли лог $RUN_LOG"
  return 1
}

# ===================== 3) БИЛДЕР =====================
do_builder() {
  echo "=== [📦] Обновление apt-репозитория ==="
  local JSON="$OUT/install-history.json"
  if [ ! -f "$JSON" ]; then
    echo "⚠️ Отчёта нет — сначала запускаю трекер..."
    do_tracker
  fi
  mkdir -p "$REPO/pool" "$REPO/dists/$CODENAME/main/binary-amd64"

  local rpath
  rpath=$(nas_remote_rsync)
  if [ -n "$rpath" ]; then
    rsync -a --info=progress2 -e "$RSH" --rsync-path="$rpath" "$NAS_TARGET/pool/" "$REPO/pool/" 2>/dev/null \
      || echo "⚠️ Не удалось подтянуть pool с NAS — собираю из локального"
  else
    echo "⚠️ NAS недоступен или там нет rsync — собираю из локального pool"
  fi

  local BASE=/tmp/base_pkgs.txt
  if [ -f /var/log/installer/initial-status.gz ]; then
    zcat /var/log/installer/initial-status.gz | grep '^Package:' | awk '{print $2}' | sort -u > "$BASE"
    echo "Базовых пакетов ОС: $(wc -l < "$BASE")"
  else
    : > "$BASE"
    echo "⚠️ initial-status.gz не найден — фильтр базы отключён"
  fi

  python3 - "$JSON" <<'PY' | sort -u > /tmp/report.txt
import json, sys
seen = []
for r in json.load(open(sys.argv[1])):
    if r['method'] in ('apt', 'dpkg') and r['package'] not in seen:
        seen.append(r['package'])
print('\n'.join(seen))
PY
  apt-mark showmanual | sort -u > /tmp/manual.txt
  comm -12 /tmp/report.txt /tmp/manual.txt > "$REPO/mysoft.txt"
  grep -vxFf "$BASE" "$REPO/mysoft.txt" > /tmp/set.txt || true
  echo "Твоего софта (manual, вне базы): $(wc -l < /tmp/set.txt)"

  echo "Строю замыкание зависимостей..."
  apt-cache depends --recurse --no-recommends --no-suggests --no-conflicts \
    --no-breaks --no-replaces --no-enhances $(cat /tmp/set.txt) 2>/dev/null \
    | grep -E '^[a-z0-9][a-z0-9.+-]*$' | sort -u > /tmp/closure_all.txt
  grep -vxFf "$BASE" /tmp/closure_all.txt > "$REPO/closure.txt" || true
  echo "К обработке: $(wc -l < "$REPO/closure.txt") пакетов"

  cd "$REPO/pool" || return 1
  : > ../missing.txt

  echo "Считаю объёмы пакетов..."
  declare -A PVER PSIZE HAVE
  local f
  for f in *.deb; do
    if [ -e "$f" ]; then HAVE["${f%_*}"]=1; fi
  done
  while read -r p v; do
    if [ -n "$p" ]; then PVER["$p"]="$v"; fi
  done < <(
    apt-cache policy $(cat ../closure.txt) 2>/dev/null \
      | awk '/^[^ :]+:$/ {p=$1; sub(/:$/,"",p)} /Candidate:/ {print p, $2}'
  )
  while read -r p s; do
    if [ -n "$p" ]; then PSIZE["$p"]="$s"; fi
  done < <(
    apt-cache show $(cat ../closure.txt) 2>/dev/null \
      | awk '/^Package: /{p=$2} /^Size: /{if (!(p in seen)) {seen[p]=1; print p, $2}}'
  )

  local TOTAL TODO=0 TODOBYTES=0 DONEBYTES=0 SKIP=0 PREMISS=0
  declare -a DLPKG DLSIZE
  TOTAL=$(wc -l < ../closure.txt)
  while read -r p; do
    local ver="${PVER[$p]:-}"
    if [ -z "$ver" ] || [ "$ver" = "(none)" ]; then
      if ls 2>/dev/null | grep -q "^${p}_"; then
        SKIP=$((SKIP+1))
        continue
      fi
      echo "$p" >> ../missing.txt
      PREMISS=$((PREMISS+1))
      continue
    fi
    if [ -n "${HAVE[${p}_${ver}]:-}" ]; then
      SKIP=$((SKIP+1))
      continue
    fi
    DLPKG+=("$p")
    DLSIZE+=("${PSIZE[$p]:-0}")
    TODOBYTES=$((TODOBYTES + ${PSIZE[$p]:-0}))
  done < ../closure.txt
  TODO=${#DLPKG[@]}
  echo "Уже в pool: $SKIP; к скачиванию: $TODO пакетов, $(numfmt --to=iec $TODOBYTES)B; недоступно: $PREMISS"

  local START=$SECONDS i p sz el speed rem eta
  for ((i=0; i<TODO; i++)); do
    p="${DLPKG[i]}"
    if ! apt-get download "$p" >>"$APT_LOG" 2>&1; then
      echo "$p" >> ../missing.txt
      printf '\n⚠️ не скачалось: %s (записано в missing.txt, лог: %s)\n' "$p" "$APT_LOG"
    fi
    sz=$(stat -c %s "${p}_${PVER[$p]}"_*.deb 2>/dev/null | head -n1)
    if [ -z "$sz" ]; then sz=0; fi
    DONEBYTES=$((DONEBYTES + sz))
    el=$((SECONDS - START))
    if [ $el -lt 1 ]; then el=1; fi
    speed=$((DONEBYTES / el))
    rem=$((TODOBYTES - DONEBYTES))
    if [ $rem -lt 0 ]; then rem=0; fi
    if [ $speed -gt 0 ]; then eta=$((rem / speed)); else eta=0; fi
    printf '\r[%d/%d] %s | %sB/%sB (%d%%) | осталось %sB | %sB/s | ETA %s   ' \
      "$((i+1))" "$TODO" "$p" \
      "$(numfmt --to=iec $DONEBYTES)" "$(numfmt --to=iec $TODOBYTES)" \
      "$(( TODOBYTES>0 ? DONEBYTES*100/TODOBYTES : 100 ))" \
      "$(numfmt --to=iec $rem)" \
      "$(numfmt --to=iec $speed)" "$(date -u -d @$eta +%H:%M:%S)"
  done
  if [ "$TODO" -gt 0 ]; then echo; fi
  echo "Уже в pool: $SKIP; докачано: $TODO; недоступно: $(wc -l < ../missing.txt)"

  local q FOUNDQ=0
  for q in firefox_*snap* thunderbird_*snap* chromium_*snap* chromium-browser_*snap*; do
    if [ -e "$q" ]; then
      mkdir -p "$REPO/quarantine"
      mv -f "$q" "$REPO/quarantine/" 2>/dev/null && FOUNDQ=$((FOUNDQ+1))
    fi
  done
  if [ "$FOUNDQ" -gt 0 ]; then
    echo "⚠️ Snap-заглушек перемещено в карантин: $FOUNDQ (см. $REPO/quarantine)"
  fi

  cd "$REPO" || return 1
  apt-ftparchive packages pool/ > "dists/$CODENAME/main/binary-amd64/Packages"
  gzip -kf9 "dists/$CODENAME/main/binary-amd64/Packages"
  apt-ftparchive \
    -o APT::FTPArchive::Release::Origin=Nikita-Repo \
    -o APT::FTPArchive::Release::Label=Nikita-Repo \
    -o APT::FTPArchive::Release::Suite="$CODENAME" \
    -o APT::FTPArchive::Release::Codename="$CODENAME" \
    -o APT::FTPArchive::Release::Components=main \
    release "dists/$CODENAME" > "dists/$CODENAME/Release"

  python3 - "$JSON" > "$REPO/manifest.txt" <<'PY'
import json, sys
for r in json.load(open(sys.argv[1])):
    if r['method'] in ('apt', 'dpkg'):
        print(f"{r['seq']}\t{r['package']}\t{r['version']}\t{r['source']}")
PY

  do_publish_repo
}

# ===================== 4) OFFLINE SNAP/FLATPAK =====================
do_offline() {
  echo "=== [💾] Офлайн-копии snap/flatpak (дедуп по версии) ==="
  local O="$REPO/offline"
  mkdir -p "$O/snaps" "$O/flats"
  : > "$O/missing-offline.txt"
  echo "-- snaps --"
  snap list 2>/dev/null | tail -n +2 | while read -r s ver rest; do
    if [ -z "$s" ]; then continue; fi
    if ls "$O/snaps/" 2>/dev/null | grep -q "^${s}_${ver}_"; then
      echo "= $s $ver (уже есть)"
      continue
    fi
    echo "⬇ $s $ver"
    ( cd "$O/snaps" && snap download "$s" ) 2>&1 | tail -2 || echo "$s" >> "$O/missing-offline.txt"
  done
  echo "-- flatpaks --"
  flatpak list --app --columns=application,version 2>/dev/null | while IFS=$'\t' read -r app ver; do
    if [ -z "$app" ]; then continue; fi
    if [ -f "$O/flats/${app}-${ver}.flatpak" ]; then
      echo "= $app $ver (уже есть)"
      continue
    fi
    echo "⬇ $app $ver"
    flatpak build-bundle /var/lib/flatpak "$O/flats/${app}-${ver}.flatpak" "$app" || echo "$app" >> "$O/missing-offline.txt"
  done

  : > "$O/manifest.txt"
  local fb nb nv t
  for fb in "$O/snaps/"*.snap; do
    if [ -e "$fb" ]; then
      nb=$(basename "$fb")
      nv="${nb#*_}"
      nv="${nv%%_*}"
      printf 'snap\t%s\t%s\t%s\n' "${nb%%_*}" "$nv" "$nb" >> "$O/manifest.txt"
    fi
  done
  for fb in "$O/flats/"*.flatpak; do
    if [ -e "$fb" ]; then
      nb=$(basename "$fb")
      t="${nb%.flatpak}"
      printf 'flatpak\t%s\t%s\t%s\n' "${t%-*}" "${t##*-}" "$nb" >> "$O/manifest.txt"
    fi
  done
  echo "Манифест offline: $(wc -l < "$O/manifest.txt") записей"

  local rpath
  rpath=$(nas_remote_rsync)
  if [ -n "$rpath" ]; then
    rsync -av --info=progress2 -e "$RSH" --rsync-path="$rpath" "$O/" "$NAS_TARGET/offline/" 2>/dev/null \
      || echo "⚠️ offline не ушёл на NAS — повтори публикацию пунктом 5"
  else
    echo "⚠️ NAS недоступен — offline останется локально, опубликуй позже пунктом 5"
  fi
  echo "✅ Офлайн-копии готовы: $O"
}

# ===================== 6) ПРОВЕРКА =====================
do_check() {
  echo "=== [🌐] Проверка доступности репозитория ==="
  local code
  code=$(curl -s -o /dev/null -w '%{http_code}' "${REPO_URL}dists/$CODENAME/Release")
  echo "GET ${REPO_URL}dists/$CODENAME/Release → HTTP $code"
  if [ "$code" = "200" ]; then
    echo "✅ Репозиторий доступен"
  else
    echo "❌ Недоступен: проверь публикацию (п.5) и nginx на NAS"
  fi
}

# ===================== 7) СТАТИСТИКА =====================
do_stats() {
  echo "=== [📈] Статистика ==="
  if [ -f "$OUT/install-history.csv" ]; then echo "Записей в отчёте:      $(( $(wc -l < "$OUT/install-history.csv") - 1 ))"; fi
  if [ -d "$REPO/pool" ]; then echo ".deb в pool:           $(ls -1 "$REPO/pool"/*.deb 2>/dev/null | wc -l)"; fi
  if [ -f "$REPO/closure.txt" ]; then echo "Замыкание:             $(wc -l < "$REPO/closure.txt") пакетов"; fi
  if [ -f "$REPO/mysoft.txt" ]; then echo "Приложений (mysoft):   $(wc -l < "$REPO/mysoft.txt")"; fi
  if [ -f "$REPO/offline/manifest.txt" ]; then echo "Snap+flatpak в offline: $(wc -l < "$REPO/offline/manifest.txt")"; fi
  if [ -f "$REPO/missing.txt" ]; then echo "Недоступно для скачки: $(wc -l < "$REPO/missing.txt") (см. $REPO/missing.txt)"; fi
  if [ -d "$REPO/quarantine" ]; then echo "Snap-заглушек в карантине: $(ls -1 "$REPO/quarantine" 2>/dev/null | wc -l)"; fi
  if [ -d "$REPO" ]; then echo "Размер репозитория:    $(du -sh "$REPO" 2>/dev/null | cut -f1)"; fi
  echo "Логи сеансов:          $LOG_DIR"
  echo "Текущий лог:           $RUN_LOG"
}

# ===================== 8/9) ПОДКЛЮЧЕНИЕ =====================
do_attach() {
  echo "=== [🔌] Подключение репозитория на этой машине ==="
  echo "deb [trusted=yes] $REPO_URL $CODENAME main" | sudo tee /etc/apt/sources.list.d/nikita-repo.list >/dev/null
  printf 'Package: *\nPin: release o=Nikita-Repo\nPin-Priority: 900\n' | sudo tee /etc/apt/preferences.d/nikita-repo >/dev/null
  printf 'Package: firefox thunderbird chromium-browser chromium\nPin: version *snap*\nPin-Priority: -1\n' | sudo tee /etc/apt/preferences.d/no-snap-stubs >/dev/null
  sudo apt update
  echo "✅ Подключено: sources.list + pin 900 + блокировка snap-заглушек (firefox/thunderbird/chromium)"
}
do_detach() {
  echo "=== [🔌] Отключение репозитория ==="
  sudo rm -f /etc/apt/sources.list.d/nikita-repo.list /etc/apt/preferences.d/nikita-repo /etc/apt/preferences.d/no-snap-stubs
  sudo apt update
  echo "✅ Отключено (sources.list, pin 900 и no-snap-stubs удалены)"
}

# ===================== 11) УСТАНОВКА ПО СПИСКУ (v1.8: прогресс + логи) =====================
do_install_list() {
  echo "=== [📥] Установка софта по списку ==="
  if [ ! -f "$LIST" ]; then
    if [ -f "$REPO/mysoft.txt" ] && [ -s "$REPO/mysoft.txt" ]; then
      cp "$REPO/mysoft.txt" "$LIST"
      echo "Список сгенерирован из $REPO/mysoft.txt (твои manual-пакеты). Правка — пункт 12."
    else
      echo "⚠️ Нет списка $LIST и нет mysoft.txt — создай список пунктом 12"
      return 1
    fi
  fi
  if [ ! -f /etc/apt/sources.list.d/nikita-repo.list ]; then
    echo "Репозиторий не подключён — подключаю (пункт 8)..."
    do_attach || return 1
  fi

  # v1.8: проверка блокировки dpkg до старта
  if command -v fuser >/dev/null 2>&1; then
    local LOCKPID
    LOCKPID=$(sudo fuser /var/lib/dpkg/lock-frontend 2>/dev/null | tr -d ' ')
    if [ -n "$LOCKPID" ]; then
      echo "⚠️ dpkg занят процессом PID $LOCKPID: $(ps -o cmd= -p "$LOCKPID" 2>/dev/null | head -1)"
      echo "   Дождись его окончания или прерви его, затем повтори пункт 11."
      return 1
    fi
  fi

  local rpath
  rpath=$(nas_remote_rsync)
  if [ -n "$rpath" ]; then
    echo "-- Тяну offline-бандлы с NAS (прогресс и скорость ниже) --"
    rsync -a --info=progress2 -e "$RSH" --rsync-path="$rpath" "$NAS_TARGET/offline/" "$REPO/offline/" 2>/dev/null \
      || echo "⚠️ Не удалось подтянуть offline с NAS — snap/flatpak пойдут из store"
    echo
  fi

  # v1.8: считаем состав заранее для процентов
  declare -a APTPKGS SNAPSK FLATSK
  while read -r p; do
    case "$p" in
      ''|'#'*) continue ;;
      snap:*) SNAPSK+=("${p#snap:}") ;;
      flatpak:*) FLATSK+=("${p#flatpak:}") ;;
      *) APTPKGS+=("$p") ;;
    esac
  done < "$LIST"
  local NA=${#APTPKGS[@]} NS=${#SNAPSK[@]} NF=${#FLATSK[@]}
  local TOTALN=$((NA + NS + NF))
  if [ "$TOTALN" -eq 0 ]; then
    echo "⚠️ Список пуст — нечего устанавливать"
    return 1
  fi
  echo "К установке: apt: $NA, snap: $NS, flatpak: $NF (всего $TOTALN)"
  echo "Подробный лог apt: $APT_LOG"
  echo

  local fail=() i=0 pct p s app b
  for p in "${APTPKGS[@]}"; do
    i=$((i+1))
    pct=$(( i*100 / TOTALN ))
    printf '[%d/%d | %3d%%] ⬇ apt: %s ... ' "$i" "$TOTALN" "$pct" "$p"
    if sudo apt install -y "$p" >>"$APT_LOG" 2>&1; then
      printf '✅\n'
    else
      printf '❌\n'
      echo "   Последние строки лога:"
      tail -n 6 "$APT_LOG" | sed 's/^/   | /'
      fail+=("$p")
    fi
  done

  for s in "${SNAPSK[@]}"; do
    i=$((i+1))
    pct=$(( i*100 / TOTALN ))
    printf '[%d/%d | %3d%%] ⬇ snap: %s ... ' "$i" "$TOTALN" "$pct" "$s"
    b=$(ls "$REPO/offline/snaps/" 2>/dev/null | grep "^${s}_" | head -1)
    if [ -n "$b" ]; then
      if sudo snap install --dangerous "$REPO/offline/snaps/$b" >>"$APT_LOG" 2>&1; then
        printf '✅ (бандл)\n'
      else
        printf '❌\n'; tail -n 4 "$APT_LOG" | sed 's/^/   | /'; fail+=("$s")
      fi
    else
      if sudo snap install "$s" >>"$APT_LOG" 2>&1; then
        printf '✅ (store)\n'
      else
        printf '❌\n'; tail -n 4 "$APT_LOG" | sed 's/^/   | /'; fail+=("$s")
      fi
    fi
  done

  for app in "${FLATSK[@]}"; do
    i=$((i+1))
    pct=$(( i*100 / TOTALN ))
    printf '[%d/%d | %3d%%] ⬇ flatpak: %s ... ' "$i" "$TOTALN" "$pct" "$app"
    b=$(ls "$REPO/offline/flats/" 2>/dev/null | grep "^${app}-" | head -1)
    if [ -n "$b" ]; then
      if flatpak install --bundle -y "$REPO/offline/flats/$b" >>"$APT_LOG" 2>&1; then
        printf '✅ (бандл)\n'
      else
        printf '❌\n'; tail -n 4 "$APT_LOG" | sed 's/^/   | /'; fail+=("$app")
      fi
    else
      if flatpak install -y flathub "$app" >>"$APT_LOG" 2>&1; then
        printf '✅ (flathub)\n'
      else
        printf '❌\n'; tail -n 4 "$APT_LOG" | sed 's/^/   | /'; fail+=("$app")
      fi
    fi
  done

  echo
  if [ ${#fail[@]} -eq 0 ]; then
    echo "✅ Всё из списка установлено ($TOTALN позиций)"
  else
    echo "⚠️ Не установилось (${#fail[@]}): ${fail[*]}"
    echo "   Полный лог: $APT_LOG"
  fi
}

# ===================== 12) РЕДАКТОР СПИСКА =====================
do_edit_list() {
  echo "=== [✏️] Список установки: $LIST ==="
  if [ ! -f "$LIST" ]; then
    if [ -f "$REPO/mysoft.txt" ] && [ -s "$REPO/mysoft.txt" ]; then
      cp "$REPO/mysoft.txt" "$LIST"
    else
      : > "$LIST"
    fi
    echo "Создан. Формат: по одному apt-пакету в строке; snap:имя; flatpak:id; # — комментарий"
  fi
  "${EDITOR:-nano}" "$LIST"
}

# ===================== 13) ОБЗОР И УСТАНОВКА: apt + snap + flatpak =====================
do_repo_apps() {
  echo "=== [📋] Софт в репозитории: обзор и установка (apt + snap + flatpak) ==="
  mkdir -p "$REPO/offline/snaps" "$REPO/offline/flats"
  local LISTFILE="$REPO/mysoft.txt"
  local OFFMAN="$REPO/offline/manifest.txt"
  if [ ! -s "$LISTFILE" ]; then
    echo "Локального списка apt нет — тяну из архива/NAS..."
    if ! curl -sf "${REPO_URL}mysoft.txt" -o "$LISTFILE" 2>/dev/null; then
      local rpath
      rpath=$(nas_remote_rsync)
      if [ -n "$rpath" ]; then
        rsync -a -e "$RSH" --rsync-path="$rpath" "$NAS_TARGET/mysoft.txt" "$LISTFILE" 2>/dev/null
      fi
    fi
  fi
  if [ ! -s "$OFFMAN" ]; then
    if ! curl -sf "${REPO_URL}offline/manifest.txt" -o "$OFFMAN" 2>/dev/null; then
      local rpath2
      rpath2=$(nas_remote_rsync)
      if [ -n "$rpath2" ]; then
        rsync -a -e "$RSH" --rsync-path="$rpath2" "$NAS_TARGET/offline/manifest.txt" "$OFFMAN" 2>/dev/null
      fi
    fi
    if [ ! -s "$OFFMAN" ]; then
      echo "⚠️ Манифест snap/flatpak недоступен — на билдер-машине запусти пункт 4, чтобы он создался"
    fi
  fi
  if [ ! -s "$LISTFILE" ] && [ ! -s "$OFFMAN" ]; then
    echo "❌ Нет ни списка apt, ни манифеста offline: репо пуст или недосягаем"
    return 1
  fi

  declare -A INST SNAPINST FLATINST
  while read -r p; do
    INST["$p"]=1
  done < <(dpkg-query -W -f='${Package} ${Status}\n' 2>/dev/null | awk '$2=="install" && $3=="ok" && $4=="installed" {print $1}')
  while read -r s; do
    if [ -n "$s" ]; then SNAPINST["$s"]=1; fi
  done < <(snap list 2>/dev/null | tail -n +2 | awk '{print $1}')
  while read -r a; do
    if [ -n "$a" ]; then FLATINST["$a"]=1; fi
  done < <(flatpak list --app --columns=application 2>/dev/null)

  declare -a TYPE NAME VER FILEX
  if [ -s "$LISTFILE" ]; then
    while read -r n; do
      if [ -n "$n" ]; then
        TYPE+=(apt); NAME+=("$n"); VER+=(""); FILEX+=("")
      fi
    done < <(sort "$LISTFILE")
  fi
  if [ -s "$OFFMAN" ]; then
    while IFS=$'\t' read -r t n v fx; do
      if [ -n "$n" ]; then
        TYPE+=("$t"); NAME+=("$n"); VER+=("$v"); FILEX+=("$fx")
      fi
    done < <(sort -t$'\t' -k2 "$OFFMAN")
  fi
  local TOTALN=${#NAME[@]}

  local FILTER=""
  read -r -p "Фильтр по имени (Enter — показать всё): " FILTER
  declare -a SHOW=()
  local i
  for ((i=0; i<TOTALN; i++)); do
    if [ -z "$FILTER" ]; then
      SHOW+=("$i")
    else
      if [[ "${NAME[i],,}" == *"${FILTER,,}"* ]]; then
        SHOW+=("$i")
      fi
    fi
  done
  local N=${#SHOW[@]}
  if [ "$N" -eq 0 ]; then
    echo "Ничего не найдено по фильтру '$FILTER'"
    return 1
  fi

  echo "Софта в репо: $N   (✓ = уже стоит на этой машине)"
  local k=1
  local j
  local mark
  for j in "${SHOW[@]}"; do
    mark=" "
    if [ "${TYPE[j]}" = "apt" ]; then
      if [ -n "${INST[${NAME[j]}]:-}" ]; then mark="✓"; fi
    elif [ "${TYPE[j]}" = "snap" ]; then
      if [ -n "${SNAPINST[${NAME[j]}]:-}" ]; then mark="✓"; fi
    elif [ "${TYPE[j]}" = "flatpak" ]; then
      if [ -n "${FLATINST[${NAME[j]}]:-}" ]; then mark="✓"; fi
    fi
    if [ -n "${VER[j]}" ]; then
      printf ' %3d) %s [%-7s] %s (%s)\n' "$k" "$mark" "${TYPE[j]}" "${NAME[j]}" "${VER[j]}"
    else
      printf ' %3d) %s [%-7s] %s\n' "$k" "$mark" "${TYPE[j]}" "${NAME[j]}"
    fi
    k=$((k+1))
  done
  echo
  echo "Выбор: номера и диапазоны через пробел (напр. 1 3 5-8), 'all', или Enter — только посмотреть"
  read -r -p " Выбор: " SEL
  if [ -z "$SEL" ]; then
    echo "Ок, режим просмотра завершён."
    return 0
  fi

  declare -a PICKIDX=()
  if [ "$SEL" = "all" ]; then
    for ((i=0; i<N; i++)); do
      PICKIDX+=("$i")
    done
  else
    local tok a b
    for tok in $SEL; do
      if [[ "$tok" == *-* ]]; then
        a="${tok%-*}"
        b="${tok#*-}"
        if [[ "$a" =~ ^[0-9]+$ ]] && [[ "$b" =~ ^[0-9]+$ ]]; then
          for ((i=a; i<=b; i++)); do
            if [ "$i" -ge 1 ] && [ "$i" -le "$N" ]; then
              PICKIDX+=("$((i-1))")
            fi
          done
        fi
      else
        if [[ "$tok" =~ ^[0-9]+$ ]]; then
          if [ "$tok" -ge 1 ] && [ "$tok" -le "$N" ]; then
            PICKIDX+=("$((tok-1))")
          fi
        fi
      fi
    done
  fi
  if [ ${#PICKIDX[@]} -eq 0 ]; then
    echo "Ничего не выбрано."
    return 0
  fi

  declare -a APTSEL=() SNAPSEL=() SNAPFILE=() FLATSEL=() FLATFILE=()
  for j in "${PICKIDX[@]}"; do
    local orig="${SHOW[j]}"
    if [ "${TYPE[orig]}" = "apt" ]; then
      APTSEL+=("${NAME[orig]}")
    elif [ "${TYPE[orig]}" = "snap" ]; then
      SNAPSEL+=("${NAME[orig]}")
      SNAPFILE+=("${FILEX[orig]}")
    elif [ "${TYPE[orig]}" = "flatpak" ]; then
      FLATSEL+=("${NAME[orig]}")
      FLATFILE+=("${FILEX[orig]}")
    fi
  done

  echo "Выбрано: apt: ${#APTSEL[@]}, snap: ${#SNAPSEL[@]}, flatpak: ${#FLATSEL[@]}"
  if [ ${#APTSEL[@]} -gt 0 ]; then echo "  apt:     ${APTSEL[*]}"; fi
  if [ ${#SNAPSEL[@]} -gt 0 ]; then echo "  snap:    ${SNAPSEL[*]}"; fi
  if [ ${#FLATSEL[@]} -gt 0 ]; then echo "  flatpak: ${FLATSEL[*]}"; fi
  read -r -p "Продолжить? [y/N]: " CONF
  if [ "$CONF" != "y" ] && [ "$CONF" != "Y" ] && [ "$CONF" != "д" ] && [ "$CONF" != "Д" ]; then
    echo "Отмена."
    return 0
  fi

  local fail=() m
  if [ ${#APTSEL[@]} -gt 0 ]; then
    if [ ! -f /etc/apt/sources.list.d/nikita-repo.list ]; then
      echo "Подключаю репозиторий (пункт 8)..."
      do_attach || return 1
    fi
    sudo apt update
    sudo apt install -y "${APTSEL[@]}" || fail+=("apt-группа")
  fi

  for ((m=0; m<${#SNAPSEL[@]}; m++)); do
    local s="${SNAPSEL[m]}"
    local fx="${SNAPFILE[m]}"
    local b="$REPO/offline/snaps/$fx"
    if [ ! -f "$b" ] && [ -n "$fx" ]; then
      echo "⬇ бандл snap: $fx"
      curl -sf "${REPO_URL}offline/snaps/$fx" -o "$b" 2>/dev/null || b=""
    fi
    if [ -n "$b" ] && [ -f "$b" ]; then
      echo "⬇ snap (офлайн-бандл): $s"
      sudo snap install --dangerous "$b" >/dev/null 2>&1 || fail+=("$s")
    else
      echo "⬇ snap (store): $s"
      sudo snap install "$s" >/dev/null 2>&1 || fail+=("$s")
    fi
  done

  for ((m=0; m<${#FLATSEL[@]}; m++)); do
    local app="${FLATSEL[m]}"
    local fx="${FLATFILE[m]}"
    local b="$REPO/offline/flats/$fx"
    if [ ! -f "$b" ] && [ -n "$fx" ]; then
      echo "⬇ бандл flatpak: $fx"
      curl -sf "${REPO_URL}offline/flats/$fx" -o "$b" 2>/dev/null || b=""
    fi
    if [ -n "$b" ] && [ -f "$b" ]; then
      echo "⬇ flatpak (бандл): $app"
      flatpak install --bundle -y "$b" >/dev/null 2>&1 || fail+=("$app")
    else
      echo "⬇ flatpak (flathub): $app"
      flatpak install -y flathub "$app" >/dev/null 2>&1 || fail+=("$app")
    fi
  done

  echo
  if [ ${#fail[@]} -eq 0 ]; then
    echo "✅ Всё выбрано установлено: apt — из твоего репо (pin 900), snap/flatpak — из офлайн-бандлов"
  else
    echo "⚠️ Не установилось: ${fail[*]}"
  fi
}

# ===================== 10) ДИАГНОСТИКА NAS =====================
do_diag() {
  local LOG="$TRACKER_DIR/nas-diag.log"
  echo "Пароль понадобится ОДИН раз (соединение переиспользуется)."
  {
    echo "===== DIAG $(date '+%F %T') | $(hostname) ====="
    echo "--- 1. Локальные версии ---"
    ssh -V 2>&1
    rsync --version 2>/dev/null | head -1 || echo "локально нет rsync"
    echo "--- 2. Цель ---"
    echo "NAS_TARGET=$NAS_TARGET"
    echo "USER=$NAS_USER HOST=$NAS_HOST PATH=$NAS_PATH"
    echo "--- 3. TCP порт 22 ---"
    if timeout 5 bash -c "echo > /dev/tcp/$NAS_HOST/22" 2>/dev/null; then
      echo "port 22: OPEN"
    else
      echo "port 22: CLOSED/FILTERED"
    fi
    echo "--- 4. Эффективный конфиг ssh ---"
    ssh -G "$NAS_USER@$NAS_HOST" 2>/dev/null | grep -Ei '^(user|hostname|port|identityfile|preferredauthentications|pubkeyauthentication) '
    echo "--- 5. Вход по ключу (BatchMode, без пароля) ---"
    ssh -o ControlMaster=auto -o ControlPath="$CTRL_SOCK" -o ControlPersist=600 -o BatchMode=yes -o ConnectTimeout=8 \
        "$NAS_USER@$NAS_HOST" 'echo KEY_AUTH_OK' 2>&1 | head -3
    echo "--- 6. Вход по паролю + окружение NAS (введи пароль один раз) ---"
    ssh -o ControlMaster=auto -o ControlPath="$CTRL_SOCK" -o ControlPersist=600 -o ConnectTimeout=8 \
        "$NAS_USER@$NAS_HOST" "echo REMOTE_SHELL_OK; id; echo SHELL=\$0; echo PATH=\$PATH; \
        echo 'rsync in PATH:' \$(command -v rsync || echo NO); \
        echo '/usr/syno/bin/rsync:' \$([ -x /usr/syno/bin/rsync ] && echo YES || echo NO); \
        echo '/usr/bin/rsync:' \$([ -x /usr/bin/rsync ] && echo YES || echo NO); \
        echo 'DSM:' \$(cat /etc.defaults/VERSION 2>/dev/null | head -2 | tr '\n' ' '); \
        ls -ld $NAS_PATH 2>&1; \
        mkdir -p $NAS_PATH && touch $NAS_PATH/.writetest && echo WRITE_OK && rm -f $NAS_PATH/.writetest; \
        df -h $NAS_PATH 2>/dev/null | tail -1" 2>&1 | head -25
    echo "--- 7. sshd_config и баннеры (могут ломать протокол rsync) ---"
    ssh -o ControlMaster=auto -o ControlPath="$CTRL_SOCK" -o ControlPersist=600 \
        "$NAS_USER@$NAS_HOST" "grep -Ei '^(PermitRootLogin|PasswordAuthentication|PubkeyAuthentication|AllowUsers|AllowGroups|DenyUsers|Banner|PrintMotd)' /etc/ssh/sshd_config 2>/dev/null || echo CANNOT_READ_SSHD_CONFIG; \
        ls -l /etc/motd 2>/dev/null || echo 'no /etc/motd'" 2>&1 | head -12
    echo "--- 8. Тест rsync dry-run ---"
    local rp
    rp=$(nas_remote_rsync)
    echo "remote rsync: ${rp:-NOT_FOUND}"
    if [ -n "$rp" ]; then
      rsync -avn -e "$RSH" --rsync-path="$rp" "$REPO/manifest.txt" "$NAS_TARGET/" 2>&1 | head -5
    fi
    ssh -O exit -o ControlPath="$CTRL_SOCK" "$NAS_USER@$NAS_HOST" 2>/dev/null
    echo "===== END ====="
  } 2>&1 | tee "$LOG"
  echo
  echo "📄 Лог сохранён: $LOG — пришли его содержимое мне"
}

# ===================== 1) ПОЛНЫЙ ЦИКЛ =====================
do_full() {
  do_tracker
  echo
  do_builder
  echo
  do_offline
  echo
  echo "🎉 ПОЛНЫЙ ЦИКЛ ЗАВЕРШЁН"
}

# ===================== МЕНЮ =====================
while true; do
  clear
  echo "=================================================="
  echo "  🧰 SOFT-REPO MANAGER v1.8 | $(hostname) | $CODENAME"
  echo "  NAS:  $NAS_TARGET"
  echo "  URL:  $REPO_URL"
  echo "  LOG:  $RUN_LOG"
  echo "=================================================="
  echo " 1) 🔄 Полный цикл: отчёт → репо → offline → публикация"
  echo " 2) 📊 Только отчёт (трекер)"
  echo " 3) 📦 Обновить apt-репо (дедуп + индексы + публикация)"
  echo " 4) 💾 Офлайн-копии snap/flatpak (+ манифест + публикация)"
  echo " 5) 📤 Только опубликовать репо на NAS"
  echo " 6) 🌐 Проверить доступность репо"
  echo " 7) 📈 Статистика"
  echo " 8) 🔌 Подключить репо (+ pin 900 + блок snap-заглушек)"
  echo " 9) 🔌 Отключить репо на этой машине"
  echo "10) 🩺 Диагностика NAS (лог для анализа)"
  echo "11) 📥 Установить софт по списку (прогресс + логи)"
  echo "12) ✏️ Создать/править список установки"
  echo "13) 📋 Софт в репо: apt+snap+flatpak — обзор и установка"
  echo " 0) 🚪 Выход"
  echo "--------------------------------------------------"
  read -r -p " Выбор: " choice
  case "$choice" in
    1) do_full ;;
    2) do_tracker ;;
    3) do_builder ;;
    4) do_offline ;;
    5) do_publish_repo ;;
    6) do_check ;;
    7) do_stats ;;
    8) do_attach ;;
    9) do_detach ;;
    10) do_diag ;;
    11) do_install_list ;;
    12) do_edit_list ;;
    13) do_repo_apps ;;
    0) echo "Пока!"; exit 0 ;;
    *) echo "Неверный выбор"; sleep 1 ;;
  esac
  echo
  read -r -p "Нажми Enter для возврата в меню..." _
done