package lkgateway

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"

	"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
)

// crypto-сертификаты на текущих токенах (для отображения на странице).
func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate {
	s := h.rc.Snapshot()
	if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" || s.Crypto.JCPPath == "" {
		return nil
	}
	cli := cryptocli.New(cryptocli.Config{
		Provider:   cryptocli.Provider(s.Crypto.Provider),
		ModulePath: s.Crypto.JCPPath,
	})
	defer cli.Close()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	certs, _ := cli.FindCertificates(ctx)
	return certs
}

// setupHandlers — обработчики /admin/setup/*.
type setupHandlers struct {
	rc  *RuntimeConfig
	tpl *adminTemplates
	svc *Service
}

// adminTemplates — обёртка, чтобы передать набор шаблонов в setup.
type adminTemplates struct {
	a *admin
}

// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux.
func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) {
	h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc}
	mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			http.Error(w, "method", http.StatusMethodNotAllowed)
			return
		}
		h.renderSetup(w, r, "")
	})
	mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
	mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
	mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
	mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
	mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense)
	mux.HandleFunc("/admin/setup/crypto/install", h.installCryptoPro)
	mux.HandleFunc("/admin/setup/crypto/import-cert", h.importCertificate)
	mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
	mux.HandleFunc("/admin/setup/lk", h.saveLK)
	mux.HandleFunc("/admin/setup/test-run", h.testRun)

	// Авто-загрузка сертификатов УЦ НРД и нашего УЦ.
	mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts)
	mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow)

	// Копирование контейнера КриптоПро с флешки в локальное хранилище.
	mux.HandleFunc("/admin/setup/crypto/copy-container", h.copyContainer)

	// Новости / события системы.
	mux.HandleFunc("/admin/news", h.renderNews)
	mux.HandleFunc("/admin/news/add", h.addManualNews)
	mux.HandleFunc("/admin/news/dismiss", h.dismissNews)
	mux.HandleFunc("/admin/news/check-docs", h.checkDocsNow)

	// Пошаговый мастер настройки для нетехнических пользователей.
	mux.HandleFunc("/admin/wizard", h.renderWizard)
}

// renderNews — GET /admin/news.
func (h *setupHandlers) renderNews(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	s := h.rc.Snapshot()
	data := struct {
		page
		Settings Settings
		Flash    string
	}{
		page:     nowPage("Новости", "news"),
		Settings: s,
		Flash:    r.URL.Query().Get("flash"),
	}
	render(w, h.tpl.a.news, data)
}

// WizardData — данные для шаблона /admin/wizard.
type WizardData struct {
	page
	Step             int
	Settings         Settings
	Certs            []cryptocli.Certificate
	FlashContainers  []FlashContainer
	Flash            string
	CryptoProInstalled bool
	CryptoProVersion string
	Done             struct {
		Postgres bool
		Crypto   bool
		Certs    bool
		NSD      bool
		TestRun  bool
	}
}

// renderWizard рисует одну из 5 страниц мастера. Шаг управляется query
// параметром ?step=N (1..5). По умолчанию шаг определяется автоматически
// по первому незавершённому пункту — это даёт «продолжить с того места».
func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	s := h.rc.Snapshot()
	d := WizardData{
		page:            nowPage("Мастер настройки", "wizard"),
		Settings:        s,
		Certs:           h.listCertsForUI(),
		FlashContainers: scanFlashContainers(),
		Flash:           r.URL.Query().Get("flash"),
	}
	d.Done.Postgres = s.Postgres.DSN != ""
	d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != ""
	d.Done.Certs = len(d.Certs) > 0
	d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
	d.Done.TestRun = s.LastTest != nil

	// Проверяем установлен ли КриптоПро CSP.
	if _, err := os.Stat("/opt/cprocsp/sbin/amd64/cpconfig"); err == nil {
		d.CryptoProInstalled = true
		ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
		defer cancel()
		if ver, _ := runCmd(ctx, "/opt/cprocsp/sbin/amd64/cpconfig", "-license", "-view"); ver != "" {
			d.CryptoProVersion = firstLine(ver)
		}
	}

	// Определяем текущий шаг.
	step := 1
	if v := r.URL.Query().Get("step"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 5 {
			step = n
		}
	} else {
		// Авто: первый незавершённый.
		switch {
		case !d.Done.Postgres:
			step = 1
		case !d.Done.Crypto:
			step = 2
		case !d.Done.Certs:
			step = 3
		case !d.Done.NSD:
			step = 4
		default:
			step = 5
		}
	}
	d.Step = step
	render(w, h.tpl.a.wizard, d)
}

func firstLine(s string) string {
	if i := strings.IndexByte(s, '\n'); i >= 0 {
		return strings.TrimSpace(s[:i])
	}
	return strings.TrimSpace(s)
}

// installCryptoPro — POST /admin/setup/crypto/install (multipart).
// Принимает tar или tar.gz архив с дистрибутивом КриптоПро CSP (как
// linux-amd64.tgz с cryptopro.ru), распаковывает в /tmp/bj-cryptopro,
// находит все .rpm файлы и устанавливает через sudo rpm -i.
// На РЕД ОС / ALT / ROSA это даёт рабочий /opt/cprocsp/.
func (h *setupHandlers) installCryptoPro(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	// Архив КриптоПро ~50-100 МБ — поднимем лимит до 256 МБ.
	if err := r.ParseMultipartForm(256 << 20); err != nil {
		setupFlash(w, r, "Установка: ошибка чтения формы: "+err.Error())
		return
	}
	file, header, err := r.FormFile("dist")
	if err != nil {
		setupFlash(w, r, "Установка: выберите файл архива дистрибутива (.tar/.tgz/.tar.gz/.rpm)")
		return
	}
	defer file.Close()

	dir := "/tmp/bj-cryptopro"
	_ = os.RemoveAll(dir)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		setupFlash(w, r, "Установка: не получилось создать "+dir+": "+err.Error())
		return
	}
	dst := filepath.Join(dir, filepath.Base(header.Filename))
	out, err := os.Create(dst)
	if err != nil {
		setupFlash(w, r, "Установка: не получилось создать "+dst+": "+err.Error())
		return
	}
	if _, err := io.Copy(out, file); err != nil {
		out.Close()
		setupFlash(w, r, "Установка: ошибка записи файла: "+err.Error())
		return
	}
	out.Close()

	// Распаковка (если .tar/.tgz/.tar.gz).
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
	defer cancel()
	lower := strings.ToLower(dst)
	switch {
	case strings.HasSuffix(lower, ".rpm"):
		// Один rpm — установим напрямую без распаковки.
	case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"):
		if untar, err := runCmdInDir(ctx, dir, "tar", "-xzf", dst); err != nil {
			setupFlash(w, r, "Установка: распаковка .tgz упала: "+err.Error()+" / вывод: "+untar)
			return
		}
	case strings.HasSuffix(lower, ".tar"):
		if untar, err := runCmdInDir(ctx, dir, "tar", "-xf", dst); err != nil {
			setupFlash(w, r, "Установка: распаковка .tar упала: "+err.Error()+" / вывод: "+untar)
			return
		}
	default:
		setupFlash(w, r, "Установка: неизвестный формат файла, нужен .tar/.tgz/.tar.gz/.rpm")
		return
	}

	// Найти все .rpm в директории.
	var rpms []string
	_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
		if err == nil && !info.IsDir() && strings.HasSuffix(p, ".rpm") {
			rpms = append(rpms, p)
		}
		return nil
	})
	if len(rpms) == 0 {
		setupFlash(w, r, "Установка: после распаковки .rpm файлы не найдены в "+dir)
		return
	}

	// sudo rpm -i <все rpm>. На РЕД ОС иногда нужен --nosignature --nodeps.
	args := append([]string{"rpm", "-Uvh", "--replacepkgs", "--nosignature"}, rpms...)
	output, err := runCmd(ctx, "sudo", args...)
	if err != nil {
		setupFlash(w, r, "Установка: rpm -i упал: "+err.Error()+" / вывод: "+strings.TrimSpace(output))
		return
	}
	setupFlash(w, r, "КриптоПро CSP установлен. Файлов rpm: "+fmt.Sprint(len(rpms))+". Теперь введите серийник и нажмите «Активировать лицензию». Вывод rpm: "+strings.TrimSpace(output))
}

// importCertificate — POST /admin/setup/crypto/import-cert (multipart).
// Принимает .pfx (PKCS#12 — приватный ключ + сертификат + опц. PIN) или
// .cer/.crt (только публичный сертификат). Импортирует через certmgr
// КриптоПро. Сертификат добавляется в хранилище uMy (либо mroot для
// корневых).
func (h *setupHandlers) importCertificate(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	if err := r.ParseMultipartForm(64 << 20); err != nil {
		setupFlash(w, r, "Импорт сертификата: ошибка чтения формы: "+err.Error())
		return
	}
	file, header, err := r.FormFile("cert")
	if err != nil {
		setupFlash(w, r, "Импорт сертификата: выберите файл .pfx/.cer/.crt")
		return
	}
	defer file.Close()
	pin := strings.TrimSpace(r.FormValue("pin"))
	store := strings.TrimSpace(r.FormValue("store")) // "uMy" по умолчанию, "mroot" для корневых

	dir := "/tmp/bj-certs"
	_ = os.MkdirAll(dir, 0o755)
	safeName := filepath.Base(header.Filename)
	dst := filepath.Join(dir, safeName)
	out, err := os.Create(dst)
	if err != nil {
		setupFlash(w, r, "Импорт сертификата: не получилось создать "+dst+": "+err.Error())
		return
	}
	if _, err := io.Copy(out, file); err != nil {
		out.Close()
		setupFlash(w, r, "Импорт сертификата: ошибка записи: "+err.Error())
		return
	}
	out.Close()

	certmgr := "/opt/cprocsp/bin/amd64/certmgr"
	if _, err := os.Stat(certmgr); err != nil {
		setupFlash(w, r, "Импорт сертификата: certmgr не найден. Сначала установите КриптоПро CSP.")
		return
	}
	if store == "" {
		store = "uMy"
	}
	ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
	defer cancel()

	lower := strings.ToLower(safeName)
	var cmdOut string
	switch {
	case strings.HasSuffix(lower, ".pfx") || strings.HasSuffix(lower, ".p12"):
		// PKCS#12 — импорт через certmgr -inst с PIN
		args := []string{"-inst", "-pfx", "-file", dst, "-store", store}
		if pin != "" {
			args = append(args, "-pin", pin)
		}
		cmdOut, err = runCmd(ctx, certmgr, args...)
	case strings.HasSuffix(lower, ".cer") || strings.HasSuffix(lower, ".crt"):
		// Голый сертификат — импорт в хранилище без приватного ключа
		cmdOut, err = runCmd(ctx, certmgr, "-inst", "-file", dst, "-store", store)
	default:
		setupFlash(w, r, "Импорт сертификата: неизвестное расширение, нужен .pfx/.p12/.cer/.crt")
		return
	}
	if err != nil {
		setupFlash(w, r, "Импорт сертификата: certmgr упал: "+err.Error()+" / вывод: "+strings.TrimSpace(cmdOut))
		return
	}
	setupFlash(w, r, "Сертификат «"+safeName+"» импортирован в хранилище "+store+". Вывод certmgr: "+strings.TrimSpace(cmdOut))
}

// runCmdInDir выполняет команду в указанной рабочей директории.
func runCmdInDir(ctx context.Context, dir, name string, args ...string) (string, error) {
	cmd := exec.CommandContext(ctx, name, args...)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	return string(out), err
}

// runCmd выполняет команду и возвращает stdout+stderr строкой.
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
	cmd := exec.CommandContext(ctx, name, args...)
	out, err := cmd.CombinedOutput()
	return string(out), err
}

// activateLicense — POST /admin/setup/crypto/activate. Принимает серийный
// номер из формы, вызывает cpconfig -license -set, возвращает результат
// во flash. Если КриптоПро CSP не установлен — даёт ссылку на инструкцию.
func (h *setupHandlers) activateLicense(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	serial := strings.TrimSpace(r.FormValue("license_key"))
	if serial == "" {
		setupFlash(w, r, "Активация лицензии: введите серийный номер в поле выше")
		return
	}
	cpconfig := "/opt/cprocsp/sbin/amd64/cpconfig"
	if _, err := os.Stat(cpconfig); err != nil {
		setupFlash(w, r, "КриптоПро CSP не установлен ("+cpconfig+" не найден). Раздел /admin/help/cryptopro — команды установки и копирования дистрибутива на ВМ.")
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()
	out, err := runCmd(ctx, cpconfig, "-license", "-set", serial)
	if err != nil {
		setupFlash(w, r, "Активация лицензии не прошла: "+err.Error()+" / вывод: "+strings.TrimSpace(out))
		return
	}
	cur := h.rc.Snapshot().Crypto
	cur.LicenseKey = serial
	if err := h.rc.UpdateCrypto(cur); err != nil {
		log.Printf("activateLicense: UpdateCrypto: %v", err)
	}
	setupFlash(w, r, "Лицензия КриптоПро активирована. Вывод cpconfig: "+strings.TrimSpace(out))
}

// checkCrypto — POST /admin/setup/crypto/check. Запускает Health()
// текущего провайдера PKCS#11 без изменения настроек.
func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	s := h.rc.Snapshot()
	cli := cryptocli.New(cryptocli.Config{
		Provider:   cryptocli.Provider(s.Crypto.Provider),
		ModulePath: s.Crypto.JCPPath, // унаследовано — теперь путь к PKCS#11 .so
	})
	defer cli.Close()
	info, err := cli.Health(r.Context())
	if err != nil {
		setupFlash(w, r, "СКЗИ: проверка не прошла — "+err.Error())
		return
	}
	msg := fmt.Sprintf("СКЗИ %s: %s", info.Provider, info.Message)
	if info.CryptokiVersion != "" {
		msg += fmt.Sprintf(" (PKCS#11 v%s, %s)", info.CryptokiVersion, info.ManufacturerID)
	}
	if len(info.Tokens) > 0 {
		msg += ". Токены:"
		for _, t := range info.Tokens {
			msg += fmt.Sprintf(" «%s» (%s);", t.Label, t.Model)
		}
	}
	setupFlash(w, r, msg)
}

// SetupData — данные для шаблона admin_setup.html.
type SetupData struct {
	page
	Settings        Settings
	Readiness       []Readiness
	ReadyCount      int
	TotalCount      int
	Certificates    []cryptocli.Certificate
	FlashContainers []FlashContainer
	Flash           string
	Error           string
}

func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
	s := h.rc.Snapshot()
	r := h.rc.ReadinessSummary()
	ready := 0
	for _, x := range r {
		if x.Configured {
			ready++
		}
	}
	data := SetupData{
		page:            nowPage("Настройка", "setup"),
		Settings:        s,
		Readiness:       r,
		ReadyCount:      ready,
		TotalCount:      len(r),
		Certificates:    h.listCertsForUI(),
		FlashContainers: scanFlashContainers(),
		Flash:           flash,
	}
	if errVal := errMsgFromQuery(_q(w)); errVal != "" {
		data.Error = errVal
	}
	render(w, h.tpl.a.setup, data)
}

func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	dsn := strings.TrimSpace(r.FormValue("dsn"))
	if dsn != "" {
		// Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше
		// просто ошибка — это нормально, важно показать что DSN сохранён).
		if err := tryPingPostgres(dsn); err != nil {
			setupFlash(w, r, "postgres: тест соединения упал: "+err.Error())
			return
		}
	}
	if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
		setupFlash(w, r, "postgres: ошибка сохранения: "+err.Error())
		return
	}
	setupFlash(w, r, "PostgreSQL настройки сохранены")
}

// quickStartPostgres — POST /admin/setup/postgres/quick-start.
// «Большая зелёная кнопка» для пользователя без IT-навыков: поднимает
// локальный postgres-контейнер через podman-compose, ждёт pg_isready,
// накатывает все миграции (fansy-store + m2m-core), сохраняет дефолтный
// DSN в runtime-конфиге. После этого пользователю остаётся перезапустить
// bj-server (или мы сделаем это автоматически в дальнейших версиях).
func (h *setupHandlers) quickStartPostgres(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
	defer cancel()

	// 1. Поднимаем postgres контейнер через podman-compose.
	composePath := "deploy/docker-compose/docker-compose.yml"
	if out, err := runCmd(ctx, "podman-compose", "-f", composePath, "up", "-d", "postgres"); err != nil {
		setupFlash(w, r, "Шаг 1/3: podman-compose не смог поднять контейнер. "+
			"Установите podman-compose или проверьте docker-compose.yml. Подсказка: "+
			"sudo dnf install -y podman-compose. Вывод: "+strings.TrimSpace(out))
		return
	}

	// 2. Ждём pg_isready.
	dsn := "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"
	deadline := time.Now().Add(30 * time.Second)
	for time.Now().Before(deadline) {
		if err := tryPingPostgres(dsn); err == nil {
			break
		}
		time.Sleep(time.Second)
	}
	if err := tryPingPostgres(dsn); err != nil {
		setupFlash(w, r, "Шаг 2/3: контейнер запущен, но БД не отвечает за 30 сек. Ошибка: "+err.Error())
		return
	}

	// 3. Применяем миграции через podman exec.
	migrations := []string{
		"migrations/fansy-store/000__roles.sql",
		"migrations/fansy-store/001__schemas.sql",
		"migrations/fansy-store/002__working.sql",
		"migrations/fansy-store/003__staging.sql",
		"migrations/fansy-store/004__seed_participants.sql",
		"migrations/m2m-core/001__deals.sql",
		"migrations/m2m-core/002__stages.sql",
	}
	for _, mig := range migrations {
		if err := applyMigration(ctx, mig); err != nil {
			// Миграция могла быть уже применена ранее (например, ROLE уже
			// существует) — это не критично, продолжаем.
			log.Printf("quick-start: миграция %s: %v (продолжаем)", mig, err)
		}
	}

	// 4. Сохраняем DSN в runtime-конфиг.
	if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
		setupFlash(w, r, "Шаг 3/3: не получилось сохранить DSN: "+err.Error())
		return
	}

	setupFlash(w, r, "Локальный PostgreSQL поднят и настроен. DSN сохранён. "+
		"Перезапустите bj-server (или подождите пока systemd сам перезапустит сервис), "+
		"чтобы Repository подключился к БД. После этого статус PostgreSQL будет зелёным.")
}

// applyMigration выполняет одну SQL-миграцию через podman exec в bj-postgres.
func applyMigration(ctx context.Context, path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	cmd := exec.CommandContext(ctx, "podman", "exec", "-i", "bj-postgres",
		"psql", "-U", "bj", "-d", "bj", "-v", "ON_ERROR_STOP=1")
	cmd.Stdin = strings.NewReader(string(data))
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("%w / output: %s", err, strings.TrimSpace(string(out)))
	}
	return nil
}

func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	s := CryptoSettings{
		Provider:   strings.TrimSpace(r.FormValue("provider")),
		SocketPath: strings.TrimSpace(r.FormValue("socket_path")),
		JCPPath:    strings.TrimSpace(r.FormValue("jcp_path")),
		LicenseKey: strings.TrimSpace(r.FormValue("license_key")),
	}
	if s.Provider == "" {
		s.Provider = "stub"
	}
	if s.SocketPath == "" {
		s.SocketPath = "/run/bj/crypto.sock"
	}
	// Если указан JCP-путь — проверим что файл существует.
	if s.JCPPath != "" {
		if _, err := os.Stat(s.JCPPath); err != nil {
			setupFlash(w, r, "crypto: jcp_path не найден: "+err.Error())
			return
		}
	}
	if err := h.rc.UpdateCrypto(s); err != nil {
		setupFlash(w, r, "crypto: ошибка сохранения: "+err.Error())
		return
	}
	setupFlash(w, r, "Криптография: настройки сохранены ("+s.Provider+")")
}

func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	s := NSDSettings{
		Profile:      strings.TrimSpace(r.FormValue("profile")),
		IGWBaseURL:   strings.TrimSpace(r.FormValue("igw_base_url")),
		KeyContainer: strings.TrimSpace(r.FormValue("key_container")),
	}
	if s.IGWBaseURL != "" {
		if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil {
			setupFlash(w, r, "nsd: невалидный URL: "+err.Error())
			return
		}
		if err := tryHTTPHealth(s.IGWBaseURL + "/healthz"); err != nil {
			setupFlash(w, r, "nsd: ИШ не отвечает на /healthz: "+err.Error())
			return
		}
	}
	if err := h.rc.UpdateNSD(s); err != nil {
		setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error())
		return
	}
	setupFlash(w, r, "nsd-adapter: настройки сохранены")
}

func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	s := LKSettings{CallbackURL: strings.TrimSpace(r.FormValue("callback_url"))}
	if s.CallbackURL != "" {
		if _, err := url.ParseRequestURI(s.CallbackURL); err != nil {
			setupFlash(w, r, "lk: невалидный URL: "+err.Error())
			return
		}
		if err := tryHTTPHealth(s.CallbackURL + "/healthz"); err != nil {
			setupFlash(w, r, "lk: callback URL не отвечает на /healthz: "+err.Error())
			return
		}
	}
	if err := h.rc.UpdateLK(s); err != nil {
		setupFlash(w, r, "lk: ошибка сохранения: "+err.Error())
		return
	}
	if s.CallbackURL != "" {
		h.svc.callbackURL = s.CallbackURL
	}
	setupFlash(w, r, "Callback в ЛК сохранён и применён")
}

// testRun запускает тестовую заявку с предустановленными данными,
// ждёт изменения статуса до confirmed/rejected/timed_out и сохраняет
// результат в RuntimeConfig.LastTest.
func (h *setupHandlers) testRun(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	go h.runTestClaim()
	setupFlash(w, r, "Тестовая заявка запущена, обновите страницу через 5 секунд")
}

// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
func (h *setupHandlers) runTestClaim() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	whole := uint64(1500)
	req := CreateClaimRequest{
		Investor: Investor{
			ID:       "11111111-1111-1111-1111-111111111111",
			LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович",
			Document: Document{DocumentType: "21", Series: "4512", Number: "654321"},
		},
		TransferringDepositoryINN: "0702345678",
		ReceivingDepositoryINN:    "0710987654",
		CostInfo:                  CostInfo{No: &struct{}{}},
		IIAAgreement: &IIAAgreement{
			AgreementType: "T03", AgreementNumber: "ИИС78/2024",
			AgreementDate: "2026-01-15", BrokerINN: "0707083893",
		},
		Securities: []ClaimSec{
			{
				SecurityCode:    "MM0766162534",
				SecurityDetails: SecurityDetails{ISIN: "RU0007661625"},
				Quantity:        Quantity{Whole: whole},
				SettlementAccounts: []SettlementAccount{
					{
						SettlementRequisitesINN: "7702070139",
						SettlementLocation: SettlementLocation{
							DeponentCode: "DP789456", AccountID: "31MC0021900000F01", SectionID: "P001",
						},
					},
				},
			},
		},
		SignedDocument:  "dGVzdC1zaWduYXR1cmU=",
		SignatureFormat: "XMLDSig-GOST",
	}

	res := TestRunResult{StartedAt: time.Now().UTC()}
	created, err := h.svc.CreateClaim(ctx, req)
	if err != nil {
		res.FinishedAt = time.Now().UTC()
		res.OK = false
		res.Message = "CreateClaim упал: " + err.Error()
		_ = h.rc.RecordTestRun(res)
		return
	}
	res.ClaimID = created.ID

	// Опрашиваем статус каждые 200ms до перехода в финал.
	deadline := time.Now().Add(25 * time.Second)
	for time.Now().Before(deadline) {
		view, err := h.svc.GetClaim(ctx, created.ID)
		if err != nil {
			res.FinishedAt = time.Now().UTC()
			res.OK = false
			res.FinalStatus = "lookup_failed"
			res.Message = err.Error()
			_ = h.rc.RecordTestRun(res)
			return
		}
		switch view.Status {
		case "confirmed", "awaiting_sub16", "done":
			res.FinishedAt = time.Now().UTC()
			res.OK = true
			res.FinalStatus = view.Status
			res.Message = "Заявка подтверждена принимающей стороной (mock или реальный НРД)."
			_ = h.rc.RecordTestRun(res)
			return
		case "rejected", "timed_out":
			res.FinishedAt = time.Now().UTC()
			res.OK = false
			res.FinalStatus = view.Status
			res.Message = "Заявка не прошла: статус " + view.Status
			_ = h.rc.RecordTestRun(res)
			return
		}
		time.Sleep(200 * time.Millisecond)
	}
	res.FinishedAt = time.Now().UTC()
	res.OK = false
	res.FinalStatus = "timeout_waiting"
	res.Message = "Не дождались финального статуса за 25 сек (mock-задержка обычно 3 сек; проверьте лог lk-gateway)"
	_ = h.rc.RecordTestRun(res)
}

// tryPingPostgres делает короткое подключение через pgx и Ping.
func tryPingPostgres(dsn string) error {
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	conn, err := pgx.Connect(ctx, dsn)
	if err != nil {
		return err
	}
	defer conn.Close(ctx)
	return conn.Ping(ctx)
}

// tryHTTPHealth делает GET и ждёт 2xx.
func tryHTTPHealth(u string) error {
	c := &http.Client{Timeout: 3 * time.Second}
	resp, err := c.Get(u)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 400 {
		return fmt.Errorf("HTTP %d", resp.StatusCode)
	}
	return nil
}

// setupFlash шлёт 303 с flash-сообщением в query. Если запрос пришёл
// со страницы мастера (/admin/wizard), возвращаем туда же с сохранением
// номера шага — пользователь не должен «выпадать» из визарда после POST.
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
	target := "/admin/setup"
	if ref := r.Header.Get("Referer"); ref != "" {
		if u, err := url.Parse(ref); err == nil && strings.HasPrefix(u.Path, "/admin/wizard") {
			q := u.Query()
			q.Set("flash", msg)
			target = u.Path + "?" + q.Encode()
			http.Redirect(w, r, target, http.StatusSeeOther)
			return
		}
	}
	http.Redirect(w, r, target+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}

// _q извлекает Request из ResponseWriter trick — здесь не нужно
// (всегда работаем через chain).
func _q(_ http.ResponseWriter) string { return "" }
func errMsgFromQuery(_ string) string { return "" }

// guard — заглушка для совместимости с возможным расширением.
var _ = errors.New
