package lkgateway

import (
	"context"
	"crypto/sha256"
	"crypto/x509"
	"encoding/asn1"
	"encoding/hex"
	"encoding/pem"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"strings"
	"time"
)

// MediaRoot — где bj-server хранит свои носители: распакованные ISO,
// импортированные ключевые контейнеры. На прод-машине пользователь bj
// должен быть владельцем этой директории (создаётся install.sh).
const (
	mediaRoot        = "/var/lib/bj/media"
	mediaISODir      = "/var/lib/bj/media/iso"
	containersDir    = "/var/lib/bj/containers"
	profilesDir      = "/var/lib/bj/profiles"
	keyFileMinPerDir = 2 // считаем директорию контейнером, если в ней >= 2 *.key файлов
)

// Medium — один носитель: USB-флешка или распакованная ISO.
type Medium struct {
	// ID — стабильный идентификатор (для USB — sha1 от пути монтирования,
	// для ISO — sha256-prefix от исходного файла).
	ID string `json:"id"`
	// Kind — "usb" или "iso".
	Kind string `json:"kind"`
	// Mountpoint — корень, по которому сейчас доступен носитель.
	Mountpoint string `json:"mountpoint"`
	// Source — для ISO: путь до исходного .iso на сервере.
	Source string `json:"source,omitempty"`
	// Profile — полный профиль Валидаты (pse + gdbm + vdkeys), если найден.
	Profile *ValidataProfile `json:"profile,omitempty"`
	// Containers — найденные ключевые контейнеры (директории с *.key/*.vdk).
	Containers []KeyContainer `json:"containers"`
	// Certificates — отдельно лежащие сертификаты (.cer/.crt/.pem/.pfx/.p12).
	Certificates []CertFile `json:"certificates"`
}

// ValidataProfile — полный профиль АПК «Валидата Клиент L»: ПСП (.pse),
// ЛСП (.gdbm) и ключи (vdkeys/*.vdk).
type ValidataProfile struct {
	Root       string   `json:"root"`        // mountpoint, где найден профиль
	PSEFiles   []string `json:"pse_files"`   // относительные пути до .pse
	GDBMFiles  []string `json:"gdbm_files"`  // относительные пути до .gdbm
	KeyFiles   []string `json:"key_files"`   // относительные пути до .vdk
	Imported   bool     `json:"imported"`    // уже скопирован в /var/lib/bj/profiles/
}

// KeyContainer — ключевой контейнер: директория с *.key или *.vdk.
type KeyContainer struct {
	Path     string   `json:"path"`
	Name     string   `json:"name"`     // имя последней компоненты пути
	Files    []string `json:"files"`    // имена файлов в контейнере
	Imported bool     `json:"imported"` // уже скопирован в /var/lib/bj/containers/
}

// CertFile — публичный или PKCS#12 сертификат.
type CertFile struct {
	Path          string    `json:"path"`
	Name          string    `json:"name"`
	Format        string    `json:"format"`     // "cer" | "pem" | "pfx"
	SubjectCN     string    `json:"subject_cn"`
	IssuerCN      string    `json:"issuer_cn"`
	Serial        string    `json:"serial"`
	NotBefore     time.Time `json:"not_before"`
	NotAfter      time.Time `json:"not_after"`
	INN           string    `json:"inn,omitempty"`
	HasPrivateKey bool      `json:"has_private_key"` // true для .pfx/.p12
	ParseError    string    `json:"parse_error,omitempty"`
}

// ScanMedia собирает список всех видимых носителей: USB + распакованные
// ISO. Безопасна для частых вызовов — IO ограничен директориями верхнего
// уровня в типичных mount-точках.
func ScanMedia() []Medium {
	var out []Medium
	out = append(out, scanUSB()...)
	out = append(out, listExtractedISOs()...)
	return out
}

// scanUSB ищет USB-монтирования в /run/media/$USER, /media/$USER, /media, /mnt.
func scanUSB() []Medium {
	u, err := user.Current()
	if err != nil {
		return nil
	}
	roots := []string{
		filepath.Join("/run/media", u.Username),
		filepath.Join("/media", u.Username),
		"/media",
		"/mnt",
	}
	var out []Medium
	seen := map[string]bool{}
	for _, root := range roots {
		entries, err := os.ReadDir(root)
		if err != nil {
			continue
		}
		for _, e := range entries {
			if !e.IsDir() {
				continue
			}
			mountpoint := filepath.Join(root, e.Name())
			// Не лезем в наши собственные /var/lib/bj/media/iso/*.
			if strings.HasPrefix(mountpoint, mediaISODir) {
				continue
			}
			if seen[mountpoint] {
				continue
			}
			seen[mountpoint] = true
			out = append(out, scanMountpoint("usb", mountpoint, ""))
		}
	}
	return out
}

// listExtractedISOs возвращает все ранее распакованные ISO в /var/lib/bj/media/iso/.
func listExtractedISOs() []Medium {
	entries, err := os.ReadDir(mediaISODir)
	if err != nil {
		return nil
	}
	var out []Medium
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		id := e.Name()
		mountpoint := filepath.Join(mediaISODir, id)
		source := readISOSource(id)
		m := scanMountpoint("iso", mountpoint, source)
		m.ID = id
		out = append(out, m)
	}
	return out
}

// scanMountpoint сканирует точку монтирования на 3 уровня вглубь.
func scanMountpoint(kind, mountpoint, source string) Medium {
	m := Medium{
		ID:         sha1Path(mountpoint),
		Kind:       kind,
		Mountpoint: mountpoint,
		Source:     source,
	}
	containers, certs, profile := walkForArtifacts(mountpoint)
	m.Containers = containers
	m.Certificates = certs
	m.Profile = profile
	// Отмечаем контейнеры, уже импортированные в /var/lib/bj/containers/.
	for i := range m.Containers {
		if _, err := os.Stat(filepath.Join(containersDir, m.Containers[i].Name)); err == nil {
			m.Containers[i].Imported = true
		}
	}
	// Профиль помечается импортированным, если в /var/lib/bj/profiles/
	// есть директория с тем же именем (имя берётся от носителя).
	if m.Profile != nil {
		name := filepath.Base(mountpoint)
		if _, err := os.Stat(filepath.Join(profilesDir, name)); err == nil {
			m.Profile.Imported = true
		}
	}
	return m
}

// walkForArtifacts проходит дерево mountpoint (до 3 уровней) и собирает:
//   - директории-контейнеры (>=2 *.key или >=1 *.vdk файла);
//   - отдельные сертификаты (.cer/.pfx/...);
//   - полный профиль Валидаты (наличие *.pse + *.gdbm + *.vdk в дереве).
func walkForArtifacts(root string) ([]KeyContainer, []CertFile, *ValidataProfile) {
	var containers []KeyContainer
	var certs []CertFile
	prof := &ValidataProfile{Root: root}

	_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}
		rel, _ := filepath.Rel(root, p)
		depth := strings.Count(rel, string(filepath.Separator))
		if depth > 4 {
			return filepath.SkipDir
		}
		if info.IsDir() {
			if p != root {
				if c, ok := classifyContainer(p); ok {
					containers = append(containers, c)
					// НЕ делаем SkipDir: внутри vdkeys/ нужно собрать
					// .vdk-файлы для определения профиля Валидаты.
				}
			}
			return nil
		}
		lower := strings.ToLower(info.Name())
		switch {
		case strings.HasSuffix(lower, ".pse"):
			prof.PSEFiles = append(prof.PSEFiles, rel)
		case strings.HasSuffix(lower, ".gdbm"):
			prof.GDBMFiles = append(prof.GDBMFiles, rel)
		case strings.HasSuffix(lower, ".vdk"):
			prof.KeyFiles = append(prof.KeyFiles, rel)
		default:
			if cert := classifyCertFile(p); cert != nil {
				certs = append(certs, *cert)
			}
		}
		return nil
	})

	// Профилем считаем носитель если есть и pse, и vdk (gdbm
	// опционален — но обычно тоже присутствует).
	if len(prof.PSEFiles) == 0 || len(prof.KeyFiles) == 0 {
		prof = nil
	}
	return containers, certs, prof
}

// classifyContainer — директория является ключевым контейнером, если:
//   - в ней >=2 файлов *.key (старый формат КриптоПро/Валидата); или
//   - в ней >=1 файл *.vdk (Валидата Linux).
func classifyContainer(dir string) (KeyContainer, bool) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return KeyContainer{}, false
	}
	var keyFiles, vdkFiles, allFiles []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		allFiles = append(allFiles, name)
		lower := strings.ToLower(name)
		switch {
		case strings.HasSuffix(lower, ".vdk"):
			vdkFiles = append(vdkFiles, name)
		case strings.HasSuffix(lower, ".key"):
			keyFiles = append(keyFiles, name)
		}
	}
	if len(vdkFiles) == 0 && len(keyFiles) < keyFileMinPerDir {
		return KeyContainer{}, false
	}
	return KeyContainer{
		Path:  dir,
		Name:  filepath.Base(dir),
		Files: allFiles,
	}, true
}

// classifyCertFile парсит один файл — возвращает CertFile если это
// похоже на сертификат.
func classifyCertFile(path string) *CertFile {
	lower := strings.ToLower(path)
	var format string
	switch {
	case strings.HasSuffix(lower, ".cer"), strings.HasSuffix(lower, ".crt"):
		format = "cer"
	case strings.HasSuffix(lower, ".pem"):
		format = "pem"
	case strings.HasSuffix(lower, ".pfx"), strings.HasSuffix(lower, ".p12"):
		format = "pfx"
	default:
		return nil
	}
	cf := &CertFile{
		Path:   path,
		Name:   filepath.Base(path),
		Format: format,
	}
	if format == "pfx" {
		// PKCS#12 шифрован PIN'ом — мета без него не вытащить.
		cf.HasPrivateKey = true
		return cf
	}
	data, err := os.ReadFile(path)
	if err != nil {
		cf.ParseError = "read: " + err.Error()
		return cf
	}
	if len(data) > 32*1024 {
		// Странно большой файл для сертификата — режем.
		data = data[:32*1024]
	}
	der := data
	if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
		der = block.Bytes
	}
	cert, err := x509.ParseCertificate(der)
	if err != nil {
		cf.ParseError = "x509: " + err.Error()
		return cf
	}
	cf.SubjectCN = cert.Subject.CommonName
	cf.IssuerCN = cert.Issuer.CommonName
	cf.Serial = cert.SerialNumber.Text(16)
	cf.NotBefore = cert.NotBefore
	cf.NotAfter = cert.NotAfter
	cf.INN = extractCertINN(cert)
	return cf
}

// extractCertINN — ИНН из OID 1.2.643.3.131.1.1 в Subject.
func extractCertINN(c *x509.Certificate) string {
	innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
	for _, name := range c.Subject.Names {
		if name.Type.Equal(innOID) {
			if s, ok := name.Value.(string); ok {
				return s
			}
		}
	}
	return ""
}

// ExtractISO распаковывает образ диска (.iso/.img/.zip и т.п.) в
// /var/lib/bj/media/iso/<id>/ через 7z. password — опциональный пароль
// архива (пустая строка = без пароля). id — sha256-prefix от исходного
// пути. Возвращает Medium или ошибку.
func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
	abs, err := filepath.Abs(isoPath)
	if err != nil {
		return Medium{}, fmt.Errorf("ISO путь: %w", err)
	}
	info, err := os.Stat(abs)
	if err != nil {
		return Medium{}, fmt.Errorf("ISO не найден: %w", err)
	}
	if info.IsDir() {
		return Medium{}, errors.New("ISO путь — это директория, нужен файл")
	}

	id := isoID(abs)
	dst := filepath.Join(mediaISODir, id)
	if err := os.MkdirAll(dst, 0o755); err != nil {
		return Medium{}, fmt.Errorf("создать %s: %w", dst, err)
	}
	if isEmpty, _ := dirEmpty(dst); !isEmpty {
		// Уже распакован раньше — просто пересканируем.
		writeISOSource(id, abs)
		m := scanMountpoint("iso", dst, abs)
		m.ID = id
		return m, nil
	}

	cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
	defer cancel()
	// 7z x -y -o<dst> [-p<pass>] <archive> — рекурсивное извлечение.
	args := []string{"x", "-y", "-o" + dst}
	if password != "" {
		// 7z требует пароль через -p без пробела.
		args = append(args, "-p"+password)
	} else {
		// -p- запрещает интерактивный запрос пароля (нам нечего вводить).
		args = append(args, "-p-")
	}
	args = append(args, abs)
	cmd := exec.CommandContext(cctx, "7z", args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		_ = os.RemoveAll(dst)
		return Medium{}, fmt.Errorf("7z x: %w / %s", err, strings.TrimSpace(string(out)))
	}
	writeISOSource(id, abs)

	m := scanMountpoint("iso", dst, abs)
	m.ID = id
	return m, nil
}

// UnmountISO удаляет всё, что относится к загруженному образу:
//   - распакованную директорию /var/lib/bj/media/iso/<id>/;
//   - .src-meta файл с записанным источником;
//   - сам исходный .img/.iso в /var/lib/bj/iso/, если он находится
//     в наших границах (защита: путь должен начинаться с /var/lib/bj/iso/).
//
// Безопасно только для тех id, что лежат в нашем mediaISODir.
func UnmountISO(id string) error {
	if strings.ContainsAny(id, "/.") {
		return errors.New("неверный id")
	}
	dst := filepath.Join(mediaISODir, id)
	if !strings.HasPrefix(dst, mediaISODir+"/") {
		return errors.New("путь вне media-root")
	}
	// Сначала забираем путь исходника, потом удаляем .src.
	src := readISOSource(id)
	if err := os.RemoveAll(dst); err != nil {
		return err
	}
	_ = os.Remove(filepath.Join(mediaISODir, id+".src"))
	// Если запись об источнике существовала и путь — внутри /var/lib/bj/iso/,
	// удаляем и сам файл .img/.iso.
	if src != "" {
		abs, _ := filepath.Abs(src)
		if strings.HasPrefix(abs, "/var/lib/bj/iso/") {
			_ = os.Remove(abs)
		}
	}
	return nil
}

// ImportKeyContainer копирует контейнер в /var/lib/bj/containers/<name>/.
// Возвращает целевой путь.
func ImportKeyContainer(src string) (string, error) {
	info, err := os.Stat(src)
	if err != nil {
		return "", fmt.Errorf("источник: %w", err)
	}
	if !info.IsDir() {
		return "", errors.New("источник не директория")
	}
	if _, ok := classifyContainer(src); !ok {
		return "", errors.New("в директории не найдено >=2 файлов *.key — не похоже на контейнер")
	}
	if err := os.MkdirAll(containersDir, 0o755); err != nil {
		return "", fmt.Errorf("создать %s: %w", containersDir, err)
	}
	name := filepath.Base(src)
	dst := filepath.Join(containersDir, name)
	if _, err := os.Stat(dst); err == nil {
		return "", fmt.Errorf("контейнер %q уже импортирован", name)
	}
	if err := os.MkdirAll(dst, 0o700); err != nil {
		return "", fmt.Errorf("создать %s: %w", dst, err)
	}
	entries, err := os.ReadDir(src)
	if err != nil {
		return "", err
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		sp := filepath.Join(src, e.Name())
		dp := filepath.Join(dst, e.Name())
		data, err := os.ReadFile(sp)
		if err != nil {
			return "", fmt.Errorf("чтение %s: %w", e.Name(), err)
		}
		if err := os.WriteFile(dp, data, 0o600); err != nil {
			return "", fmt.Errorf("запись %s: %w", e.Name(), err)
		}
	}
	return dst, nil
}

// ImportProfileResult — результат импорта профиля Валидаты.
type ImportProfileResult struct {
	Path            string // /var/lib/bj/profiles/<name>/
	Pki1ConfSection string // готовая секция для pki1.conf
	ConfWritten     bool   // удалось ли дописать в /opt/Validata/VDCSP/etc/pki1.conf
	ConfWriteError  string // если не удалось — причина
}

const validataPki1Conf = "/opt/Validata/VDCSP/etc/pki1.conf"

// ImportProfile копирует профиль Валидаты (pse/gdbm/vdkeys) в
// /var/lib/bj/profiles/<name>/, генерирует секцию для pki1.conf и
// пробует дописать её в системный конфиг Валидаты. Имя берётся от
// носителя, если name пуст. Возвращает деталь — что получилось.
func ImportProfile(root, name string) (ImportProfileResult, error) {
	if name == "" {
		name = filepath.Base(root)
	}
	if !validProfileName(name) {
		return ImportProfileResult{}, errors.New("имя профиля: допустимы латинские буквы, цифры, '-' и '_'")
	}
	if err := os.MkdirAll(profilesDir, 0o755); err != nil {
		return ImportProfileResult{}, fmt.Errorf("создать %s: %w", profilesDir, err)
	}
	dst := filepath.Join(profilesDir, name)
	if _, err := os.Stat(dst); err == nil {
		return ImportProfileResult{}, fmt.Errorf("профиль %q уже импортирован", name)
	}
	if err := copyTree(root, dst); err != nil {
		_ = os.RemoveAll(dst)
		return ImportProfileResult{}, err
	}

	// Ищем фактический pse и gdbm внутри импортированной папки —
	// обычно spr*/local.pse + spr*/local.gdbm.
	psePath, gdbmPath := findProfileFiles(dst)
	if psePath == "" {
		return ImportProfileResult{}, errors.New("после копирования не найден .pse — формат профиля нестандартный")
	}

	section := buildPki1ConfSection(name, psePath, gdbmPath)
	// Сохраняем секцию рядом с профилем — чтобы оператор мог
	// посмотреть/перечитать.
	_ = os.WriteFile(filepath.Join(dst, "pki1.conf-section.txt"),
		[]byte(section), 0o644)

	res := ImportProfileResult{
		Path:            dst,
		Pki1ConfSection: section,
	}
	// Пробуем дописать в pki1.conf — если файл доступен на запись.
	if err := appendToPki1Conf(name, section); err != nil {
		res.ConfWriteError = err.Error()
	} else {
		res.ConfWritten = true
	}
	return res, nil
}

func validProfileName(s string) bool {
	if s == "" || len(s) > 64 {
		return false
	}
	for _, r := range s {
		ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
			(r >= '0' && r <= '9') || r == '-' || r == '_'
		if !ok {
			return false
		}
	}
	return true
}

// findProfileFiles ищет .pse и .gdbm внутри директории профиля.
// Возвращает абсолютные пути или пустые строки.
func findProfileFiles(dir string) (psePath, gdbmPath string) {
	_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return nil
		}
		lower := strings.ToLower(info.Name())
		if psePath == "" && strings.HasSuffix(lower, ".pse") {
			psePath = p
		}
		if gdbmPath == "" && strings.HasSuffix(lower, ".gdbm") {
			gdbmPath = p
		}
		return nil
	})
	return
}

// buildPki1ConfSection формирует блок pki1.conf для нашего профиля.
func buildPki1ConfSection(name, psePath, gdbmPath string) string {
	var b strings.Builder
	b.WriteString("\n# --- bj-server: профиль " + name + " ---\n")
	b.WriteString("local: " + name + "\n")
	b.WriteString("pse: pse://signed/" + psePath + "\n")
	if gdbmPath != "" {
		b.WriteString("localstore: file://" + gdbmPath + "\n")
	}
	return b.String()
}

// appendToPki1Conf пишет секцию в системный pki1.conf, если процесс
// имеет права. Возвращает ошибку при отсутствии прав или I/O-сбое.
// Дедуп — если в файле уже есть блок с тем же `local: <name>`, не
// пишем повторно.
func appendToPki1Conf(name, section string) error {
	existing, err := os.ReadFile(validataPki1Conf)
	if err != nil {
		return fmt.Errorf("read %s: %w", validataPki1Conf, err)
	}
	marker := "local: " + name
	if strings.Contains(string(existing), marker) {
		return fmt.Errorf("в pki1.conf уже есть секция %q — пропускаем", name)
	}
	f, err := os.OpenFile(validataPki1Conf, os.O_WRONLY|os.O_APPEND, 0)
	if err != nil {
		return fmt.Errorf("open %s: %w", validataPki1Conf, err)
	}
	defer f.Close()
	if _, err := f.WriteString(section); err != nil {
		return fmt.Errorf("write %s: %w", validataPki1Conf, err)
	}
	return nil
}

// copyTree рекурсивно копирует src в dst, сохраняя структуру директорий.
// Права на новые директории — 0700, на файлы — 0600 (приватные ключи).
func copyTree(src, dst string) error {
	return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(src, p)
		if err != nil {
			return err
		}
		target := filepath.Join(dst, rel)
		if info.IsDir() {
			return os.MkdirAll(target, 0o700)
		}
		data, err := os.ReadFile(p)
		if err != nil {
			return err
		}
		return os.WriteFile(target, data, 0o600)
	})
}

// DeleteImportedContainer сносит /var/lib/bj/containers/<name>/.
func DeleteImportedContainer(name string) error {
	if !validProfileName(name) {
		return errors.New("неверное имя контейнера")
	}
	dir := filepath.Join(containersDir, name)
	if _, err := os.Stat(dir); err != nil {
		return fmt.Errorf("контейнер не найден: %w", err)
	}
	return os.RemoveAll(dir)
}

// DeleteImportedProfile сносит и директорию профиля
// /var/lib/bj/profiles/<name>/, и связанную секцию из pki1.conf
// (между маркерами «# --- bj-server: профиль <name> ---» и следующим
// «# --- bj-server: ...» или концом файла).
func DeleteImportedProfile(name string) error {
	if !validProfileName(name) {
		return errors.New("неверное имя профиля")
	}
	dir := filepath.Join(profilesDir, name)
	if _, err := os.Stat(dir); err != nil {
		return fmt.Errorf("профиль не найден: %w", err)
	}
	if err := os.RemoveAll(dir); err != nil {
		return fmt.Errorf("удалить %s: %w", dir, err)
	}
	// Чистим секцию в pki1.conf — best effort, если файл недоступен на
	// запись, профиль всё равно удалён, но в конфиге останется огрызок.
	if err := removeFromPki1Conf(name); err != nil {
		return fmt.Errorf("директория удалена, но pki1.conf не почистился: %w", err)
	}
	return nil
}

// removeFromPki1Conf удаляет блок профиля из pki1.conf.
// Блок начинается с «# --- bj-server: профиль <name> ---» и кончается
// перед следующим таким маркером или до конца файла. Если блок не
// найден — успех (idempotent).
func removeFromPki1Conf(name string) error {
	data, err := os.ReadFile(validataPki1Conf)
	if err != nil {
		return err
	}
	startMarker := "# --- bj-server: профиль " + name + " ---"
	startIdx := strings.Index(string(data), startMarker)
	if startIdx < 0 {
		return nil
	}
	// Найдём конец блока: следующий маркер «# --- bj-server: профиль» или EOF.
	rest := string(data)[startIdx+len(startMarker):]
	endRel := strings.Index(rest, "# --- bj-server: профиль ")
	var newContent string
	if endRel < 0 {
		newContent = string(data)[:startIdx]
	} else {
		newContent = string(data)[:startIdx] + rest[endRel:]
	}
	// Убираем хвостовые пустые строки от секции.
	newContent = strings.TrimRight(newContent, "\n") + "\n"
	return os.WriteFile(validataPki1Conf, []byte(newContent), 0o644)
}

// ListImportedProfiles возвращает имена директорий в /var/lib/bj/profiles/.
func ListImportedProfiles() []string {
	entries, err := os.ReadDir(profilesDir)
	if err != nil {
		return nil
	}
	var out []string
	for _, e := range entries {
		if e.IsDir() {
			out = append(out, e.Name())
		}
	}
	return out
}

// ListImportedContainers возвращает уже импортированные контейнеры.
func ListImportedContainers() []KeyContainer {
	entries, err := os.ReadDir(containersDir)
	if err != nil {
		return nil
	}
	var out []KeyContainer
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		dir := filepath.Join(containersDir, e.Name())
		if c, ok := classifyContainer(dir); ok {
			c.Imported = true
			out = append(out, c)
		}
	}
	return out
}

func isoID(absPath string) string {
	h := sha256.Sum256([]byte(absPath))
	return hex.EncodeToString(h[:8])
}

func sha1Path(s string) string {
	h := sha256.Sum256([]byte(s))
	return hex.EncodeToString(h[:6])
}

func dirEmpty(path string) (bool, error) {
	f, err := os.Open(path)
	if err != nil {
		return false, err
	}
	defer f.Close()
	names, err := f.Readdirnames(1)
	if errors.Is(err, os.ErrInvalid) || err != nil {
		return len(names) == 0, nil
	}
	return len(names) == 0, nil
}

func readISOSource(id string) string {
	data, err := os.ReadFile(filepath.Join(mediaISODir, id+".src"))
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(data))
}

func writeISOSource(id, src string) {
	_ = os.WriteFile(filepath.Join(mediaISODir, id+".src"), []byte(src), 0o644)
}
