package lkgateway

import (
	"context"
	"encoding/base64"
	"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-сертификаты на текущих токенах (для отображения на странице).
// В gRPC-режиме сертификатами управляет сам сайдкар через профиль
// Валидаты — здесь временно возвращаем пустой список, пока
// crypto-service не получит метод ListCertificates.
func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate {
	s := h.rc.Snapshot()
	if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" {
		return nil
	}
	cli := cryptocli.New(cryptocli.Config{
		Provider:   cryptocli.Provider(s.Crypto.Provider),
		SocketPath: s.Crypto.SocketPath,
		ModulePath: s.Crypto.ModulePath,
	})
	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
	keyWiz *keyWizardState // состояние мастера установки ключа
}

// 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/test-sign", h.testSignCrypto)
	mux.HandleFunc("/admin/setup/test-nsd", h.testNSD)
	mux.HandleFunc("/admin/setup/update", h.saveUpdate)
	mux.HandleFunc("/admin/setup/update/check", h.checkUpdate)
	mux.HandleFunc("/admin/setup/update/apply", h.applyUpdate)
	mux.HandleFunc("/admin/setup/license", h.saveLicense)
	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)

	// Носители ключей (USB-флешки, загруженные ISO).
	mux.HandleFunc("/admin/setup/media/iso/upload", h.uploadISO)
	mux.HandleFunc("/admin/setup/media/iso/unmount", h.unmountISO)
	mux.HandleFunc("/admin/setup/media/import-container", h.importContainer)
	mux.HandleFunc("/admin/setup/media/import-profile", h.importProfile)
	mux.HandleFunc("/admin/setup/media/activate-profile", h.activateProfile)
	mux.HandleFunc("/admin/setup/media/delete-profile", h.deleteProfile)
	mux.HandleFunc("/admin/setup/media/delete-container", h.deleteContainer)

	// Перезапуск сервисов из UI.
	mux.HandleFunc("/admin/setup/restart-crypto", h.restartCrypto)
	mux.HandleFunc("/admin/setup/restart-server", h.restartServer)

	// Новости / события системы.
	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)

	// Пошаговый мастер установки ключа Валидаты на флешку.
	h.registerKeyWizard(mux)
}

// 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
	Flash    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(),
		Flash:    r.URL.Query().Get("flash"),
	}
	d.Done.Postgres = s.Postgres.DSN != ""
	d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != ""
	d.Done.Certs = len(d.Certs) > 0
	d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
	d.Done.TestRun = s.LastTest != nil

	// Определяем текущий шаг.
	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)
}

// 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
}

// checkCrypto — POST /admin/setup/crypto/check. Делает Health-вызов
// к crypto-service через UDS-сокет.
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),
		SocketPath: s.Crypto.SocketPath,
		ModulePath: s.Crypto.ModulePath,
	})
	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)
}

// testSignCrypto — POST /admin/setup/crypto/test-sign. Подписывает фиксированный
// тестовый XML через активный профиль Валидаты и возвращает в toast длину
// + первые байты base64 CMS. Файл сохраняется в /var/lib/bj/.bj/test-sign.p7s
// чтобы оператор мог проверить сам через openssl/zpki1utl.
func (h *setupHandlers) testSignCrypto(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),
		SocketPath: s.Crypto.SocketPath,
		ModulePath: s.Crypto.ModulePath,
	})
	defer cli.Close()

	payload := []byte("<bj-test ts=\"" + time.Now().UTC().Format(time.RFC3339) + "\">Bridge-and-Join-s sign smoke test</bj-test>")
	sig, err := cli.SignXMLDSig(r.Context(), payload, "", s.Crypto.Profile)
	if err != nil {
		setupFlash(w, r, "Тестовая подпись не удалась: "+err.Error())
		return
	}
	outPath := "/var/lib/bj/.bj/test-sign.p7s"
	if err := os.MkdirAll(filepath.Dir(outPath), 0o700); err == nil {
		_ = os.WriteFile(outPath, sig, 0o600)
	}
	b64Preview := base64.StdEncoding.EncodeToString(sig)
	if len(b64Preview) > 80 {
		b64Preview = b64Preview[:80] + "..."
	}
	setupFlash(w, r, fmt.Sprintf("Тестовая подпись OK: CMS detached, %d байт. Сохранена в %s. Префикс base64: %s",
		len(sig), outPath, b64Preview))
}

// SetupData — данные для шаблона admin_setup.html.
type SetupData struct {
	page
	Settings           Settings
	Readiness          []Readiness
	ReadyCount         int
	TotalCount         int
	Certificates       []cryptocli.Certificate
	Media              []Medium       // USB-флешки и распакованные ISO
	ImportedContainers []KeyContainer // уже импортированные контейнеры
	ImportedProfiles   []string       // уже импортированные профили Валидаты
	CurrentVersion     string         // версия bj-server (для раздела «Обновления»)
	License            LicenseStatus  // статус лицензии
	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(),
		Media:              ScanMedia(),
		ImportedContainers: ListImportedContainers(),
		ImportedProfiles:   ListImportedProfiles(),
		CurrentVersion:     BuildVersion,
		License:            licenseStatus(h.rc),
		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")),
		ModulePath: strings.TrimSpace(r.FormValue("module_path")),
	}
	if s.Provider == "" {
		s.Provider = "stub"
	}
	if s.SocketPath == "" {
		s.SocketPath = "/run/bj/crypto.sock"
	}
	if s.ModulePath != "" {
		if _, err := os.Stat(s.ModulePath); err != nil {
			setupFlash(w, r, "crypto: module_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")),
		DeponentCode: strings.TrimSpace(r.FormValue("deponent_code")),
		AccountID:    strings.TrimSpace(r.FormValue("account_id")),
		SectionID:    strings.TrimSpace(r.FormValue("section_id")),
	}
	if s.IGWBaseURL != "" {
		if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil {
			setupFlash(w, r, "nsd: невалидный URL: "+err.Error())
			return
		}
		// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API.
		if err := tryHTTPHealth(s.IGWBaseURL + "/api/admin/engine/state"); err != nil {
			setupFlash(w, r, "nsd: ИШ не отвечает (engine/state): "+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 секунд")
}

// robotScenarios — тестовые сценарии робота НРД (код → описание).
// Код сценария задаётся в DocumentSeries (серия ДУЛ инвестора).
var robotScenarios = map[string]string{
	"2001": "Принять все бумаги",
	"2002": "Принять бумаги частично",
	"1111": "Ответ с отказом",
	"3333": "Робот — принимающая сторона",
}

// NSDRobotCode — код робота-тестировщика НРД (получатель тестовых пакетов).
const NSDRobotCode = "MC0012500000"

// robotDocumentNumbers — DocumentNumber (номер ДУЛ) по сценарию, как в
// официальных образцах НРД (m2m_robot_samples). Пара DocumentSeries (=код
// сценария) + DocumentNumber выбирает конкретное поведение робота.
var robotDocumentNumbers = map[string]string{
	"2001": "111111", // принять все бумаги (all_to_1st_depo)
	"2002": "100000", // принять частично (accept1st)
	"1111": "111101", // ошибочная ситуация (error01)
	"3333": "100000", // робот — принимающая сторона (one_settle_depo, шаг 1)
}

// robotReferenceSecurity — одна бумага из эталонного набора робота НРД:
// ISIN и ИНН расчётного депозитария (SettlementRequisites). Набор одинаков
// во всех сценариях; робот различает их по ReferenceId (генерируется нами).
type robotReferenceSecurity struct {
	isin string
	inn  string
}

// robotReferenceSecurities — эталонный набор из 4 бумаг (m2m_robot_samples):
// две через расчётный депозитарий 7702165310, две — через 7831000034.
var robotReferenceSecurities = []robotReferenceSecurity{
	{isin: "RU0007661625", inn: "7702165310"},
	{isin: "RU000A0JP5V6", inn: "7702165310"},
	{isin: "RU000A0JPKH7", inn: "7831000034"},
	{isin: "RU000A0JPGP8", inn: "7831000034"},
}

// buildRobotSecurities собирает эталонный набор бумаг с НАШИМИ депо-реквизитами
// списания (из настроек НРД) в SettlementLocation.
func buildRobotSecurities(depCode, accID, sectID string) []ClaimSec {
	out := make([]ClaimSec, 0, len(robotReferenceSecurities))
	for _, s := range robotReferenceSecurities {
		out = append(out, ClaimSec{
			SecurityCode:    s.isin, // эталон робота: SecurityCode = ISIN
			SecurityDetails: SecurityDetails{ISIN: s.isin},
			Quantity:        Quantity{Whole: 1},
			SettlementAccounts: []SettlementAccount{{
				SettlementRequisitesINN: s.inn,
				SettlementLocation: SettlementLocation{
					DeponentCode: depCode, AccountID: accID, SectionID: sectID,
				},
			}},
		})
	}
	return out
}

// testNSD — POST /admin/setup/test-nsd. Отправляет эталонный M2MTransferRequest
// роботу НРД (MC0012500000) с выбранным сценарием. Ответ робота приходит
// асинхронно во входящие ИШ и применяется поллером (статус заявки меняется).
func (h *setupHandlers) testNSD(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	scenario := r.FormValue("scenario")
	if _, ok := robotScenarios[scenario]; !ok {
		scenario = "2001"
	}
	// Отправляем синхронно, чтобы получить ID заявки и сразу открыть её карточку.
	// Ответ робота придёт асинхронно — его подхватит поллер входящих ИШ.
	claimID, err := h.runRobotTest(scenario)
	if err != nil {
		setupFlash(w, r, fmt.Sprintf("Сценарий %s «%s»: отправка роботу не удалась: %v",
			scenario, robotScenarios[scenario], err))
		return
	}
	msg := fmt.Sprintf("Пакет (сценарий %s «%s») отправлен роботу НРД %s. Ответ придёт через ~30-60 сек — обновите страницу.",
		scenario, robotScenarios[scenario], NSDRobotCode)
	http.Redirect(w, r, "/admin/claims/"+claimID+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}

// runRobotTest формирует и синхронно отправляет эталонную заявку роботу НРД.
// Возвращает ID созданной заявки (для редиректа в карточку) и ошибку отправки.
// Сам ответ робота приходит асинхронно — его подхватывает поллер входящих ИШ.
func (h *setupHandlers) runRobotTest(scenario string) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
	defer cancel()

	// Депо-реквизиты клиента из настроек (откуда списываются бумаги).
	// Если не заданы — подставляем плейсхолдеры (робот отвергнет по формату,
	// но запрос уйдёт; реальные значения вводятся в админке → раздел НРД).
	nsd := h.rc.Snapshot().NSD
	depCode := nsd.DeponentCode
	accID := nsd.AccountID
	sectID := nsd.SectionID

	// DocumentNumber по сценарию (эталон m2m_robot_samples). Неизвестный
	// сценарий — дефолт 2001.
	docNumber, ok := robotDocumentNumbers[scenario]
	if !ok {
		docNumber = robotDocumentNumbers["2001"]
	}

	req := CreateClaimRequest{
		Investor: Investor{
			ID:       "22222222-2222-2222-2222-222222222222",
			LastName: "Толкин", FirstName: "Никита", MiddleName: "Анатольевич",
			// Series = код сценария робота, Number = вариант теста (эталон НРД).
			Document: Document{DocumentType: "21", Series: scenario, Number: docNumber},
		},
		TransferringDepositoryINN: "7702165310",
		ReceivingDepositoryINN:    "7722061076", // принимающий депозитарий из набора робота
		// Эталон робота: CostInfo=Yes с НАШИМ депкодом (код передачи налоговой
		// информации сверяется со справочником участников M2M).
		CostInfo:             CostInfo{Yes: &CostInfoYes{Code: depCode}},
		Securities:           buildRobotSecurities(depCode, accID, sectID),
		SignedDocument:       "dGVzdC1zaWduYXR1cmU=",
		SignatureFormat:      "XMLDSig-GOST",
		ReceiverCodeOverride: NSDRobotCode, // отправляем роботу, не дефолтному получателю
		// Серия ДУЛ = код сценария, номер = вариант теста. Иначе паспорт
		// берётся из анкеты и робот не распознаёт сценарий.
		InvestorDocumentOverride: &Document{DocumentType: "21", Series: scenario, Number: docNumber},
	}

	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.FinalStatus = "send_failed"
		res.Message = fmt.Sprintf("Сценарий %s: отправка роботу не удалась: %v", scenario, err)
		_ = h.rc.RecordTestRun(res)
		return "", err
	}
	res.ClaimID = created.ID
	res.OK = true
	res.FinalStatus = created.Status
	res.Message = fmt.Sprintf("Сценарий %s «%s»: пакет отправлен роботу %s (GUID %s). Ждём ответ робота во входящих ИШ.",
		scenario, robotScenarios[scenario], NSDRobotCode, created.ID)
	res.FinishedAt = time.Now().UTC()
	_ = h.rc.RecordTestRun(res)
	return created.ID, nil
}

// saveLicense — POST /admin/setup/license. Сохраняет лицензионный ключ и
// сразу проверяет его (подпись + срок).
func (h *setupHandlers) saveLicense(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	cfg := h.rc.Snapshot().License
	cfg.Key = strings.TrimSpace(r.FormValue("key"))
	if pk := strings.TrimSpace(r.FormValue("public_key")); pk != "" {
		cfg.PublicKey = pk
	}
	if err := h.rc.SaveLicense(cfg); err != nil {
		setupFlash(w, r, "лицензия: ошибка сохранения: "+err.Error())
		return
	}
	st := licenseStatus(h.rc)
	if st.Present && st.Valid {
		setupFlash(w, r, fmt.Sprintf("Лицензия принята: %s, план %s, %s", st.Tenant, st.Plan, st.Message))
	} else {
		setupFlash(w, r, "Лицензия сохранена, но: "+st.Message)
	}
}

// saveUpdate — POST /admin/setup/update. Сохраняет настройки обновлений.
func (h *setupHandlers) saveUpdate(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	cfg := h.rc.Snapshot().Update
	cfg.BaseURL = strings.TrimSpace(r.FormValue("base_url"))
	cfg.Channel = strings.TrimSpace(r.FormValue("channel"))
	cfg.PublicKey = strings.TrimSpace(r.FormValue("public_key"))
	cfg.AutoCheck = r.FormValue("auto_check") != ""
	if err := h.rc.SaveUpdateSettings(cfg); err != nil {
		setupFlash(w, r, "обновления: ошибка сохранения: "+err.Error())
		return
	}
	setupFlash(w, r, "Настройки обновлений сохранены")
}

// checkUpdate — POST /admin/setup/update/check. Проверяет наличие обновления.
func (h *setupHandlers) checkUpdate(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	st, err := NewUpdater(h.rc).CheckForUpdate(r.Context())
	if err != nil {
		setupFlash(w, r, "Проверка обновлений: "+err.Error())
		return
	}
	setupFlash(w, r, "Проверка обновлений: "+st.Message)
}

// applyUpdate — POST /admin/setup/update/apply. Скачивает и применяет.
func (h *setupHandlers) applyUpdate(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	if err := NewUpdater(h.rc).ApplyUpdate(r.Context()); err != nil {
		setupFlash(w, r, "Обновление не применено: "+err.Error())
		return
	}
	setupFlash(w, r, "Обновление скачано и применено — bj-server перезапускается, обновите страницу через 10 секунд")
}

// 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. Если запрос пришёл
// с какой-то «принимающей flash» страницы (/admin/wizard, /admin/news,
// /admin/setup) — возвращаем туда же. Иначе дефолт — /admin/setup.
// Это нужно чтобы пользователь не «выпадал» из текущего контекста после
// POST-действия (нажал кнопку «Проверить обновления» в Новостях — должен
// остаться в Новостях со флешем).
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
	if ref := r.Header.Get("Referer"); ref != "" {
		if u, err := url.Parse(ref); err == nil {
			for _, prefix := range []string{"/admin/wizard", "/admin/news", "/admin/setup"} {
				if strings.HasPrefix(u.Path, prefix) {
					q := u.Query()
					q.Set("flash", msg)
					http.Redirect(w, r, u.Path+"?"+q.Encode(), http.StatusSeeOther)
					return
				}
			}
		}
	}
	http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}

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

// uploadISO — POST /admin/setup/media/iso/upload (multipart).
// Принимает .iso или .img файл, сохраняет в /var/lib/bj/iso/<timestamp>-<name>,
// распаковывает через 7z в /var/lib/bj/media/iso/<id>/.
func (h *setupHandlers) uploadISO(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	// Лимит 500 МБ.
	if err := r.ParseMultipartForm(500 << 20); err != nil {
		setupFlash(w, r, "Образ: ошибка чтения формы: "+err.Error())
		return
	}
	file, header, err := r.FormFile("iso")
	if err != nil {
		setupFlash(w, r, "Образ: выберите файл .iso или .img")
		return
	}
	defer file.Close()
	lower := strings.ToLower(header.Filename)
	if !(strings.HasSuffix(lower, ".iso") || strings.HasSuffix(lower, ".img") ||
		strings.HasSuffix(lower, ".zip") || strings.HasSuffix(lower, ".7z")) {
		setupFlash(w, r, "Образ: расширение файла должно быть .iso, .img, .zip или .7z")
		return
	}
	password := r.FormValue("password")
	isoDir := "/var/lib/bj/iso"
	if err := os.MkdirAll(isoDir, 0o755); err != nil {
		setupFlash(w, r, "Образ: не удалось создать "+isoDir+": "+err.Error())
		return
	}
	safeName := filepath.Base(header.Filename)
	dst := filepath.Join(isoDir, time.Now().UTC().Format("20060102-150405-")+safeName)
	out, err := os.Create(dst)
	if err != nil {
		setupFlash(w, r, "Образ: запись: "+err.Error())
		return
	}
	if _, err := io.Copy(out, file); err != nil {
		out.Close()
		_ = os.Remove(dst)
		setupFlash(w, r, "Образ: запись файла: "+err.Error())
		return
	}
	out.Close()

	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
	defer cancel()
	m, err := ExtractISO(ctx, dst, password)
	if err != nil {
		setupFlash(w, r, "Образ: распаковка: "+err.Error())
		return
	}
	setupFlash(w, r, fmt.Sprintf("Образ «%s» загружен и распакован: %d контейнер(ов), %d сертификат(ов)",
		safeName, len(m.Containers), len(m.Certificates)))
}

// unmountISO — POST /admin/setup/media/iso/unmount. Удаляет распакованную ISO.
func (h *setupHandlers) unmountISO(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	id := strings.TrimSpace(r.FormValue("id"))
	if id == "" {
		setupFlash(w, r, "Образ: не указан id")
		return
	}
	if err := UnmountISO(id); err != nil {
		setupFlash(w, r, "Образ: удаление не удалось — "+err.Error())
		return
	}
	setupFlash(w, r, "Образ удалён полностью: распаковка из /var/lib/bj/media/iso/"+id+" и исходный файл .img/.iso из /var/lib/bj/iso/.")
}

// importContainer — POST /admin/setup/media/import-container.
// Принимает path — путь до директории с *.key. Копирует в
// /var/lib/bj/containers/<name>/, при успехе зовёт crypto-service для
// импорта контейнера в Валидата-профиль (если сайдкар запущен).
func (h *setupHandlers) importContainer(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	src := strings.TrimSpace(r.FormValue("path"))
	if src == "" {
		setupFlash(w, r, "Импорт контейнера: не указан path")
		return
	}
	// Защита: путь должен быть внутри известных media-roots.
	if !allowedMediaPath(src) {
		setupFlash(w, r, "Импорт контейнера: путь вне допустимых зон (USB/ISO)")
		return
	}
	dst, err := ImportKeyContainer(src)
	if err != nil {
		setupFlash(w, r, "Импорт контейнера: "+err.Error())
		return
	}
	setupFlash(w, r, "Контейнер скопирован в "+dst+". Перезапустите bj-crypto с BJ_VALIDATA_PROFILE — Валидата подхватит контейнер.")
}

// importProfile — POST /admin/setup/media/import-profile.
// Параметры: root (путь до корня профиля), name (произвольное имя).
// Копирует pse/gdbm/vdkeys в /var/lib/bj/profiles/<name>/.
func (h *setupHandlers) importProfile(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	root := strings.TrimSpace(r.FormValue("root"))
	name := strings.TrimSpace(r.FormValue("name"))
	if root == "" {
		setupFlash(w, r, "Импорт профиля: не указан root")
		return
	}
	if !allowedMediaPath(root) {
		setupFlash(w, r, "Импорт профиля: путь вне допустимых зон (USB/ISO)")
		return
	}
	res, err := ImportProfile(root, name)
	if err != nil {
		setupFlash(w, r, "Импорт профиля: "+err.Error())
		return
	}
	profileName := filepath.Base(res.Path)
	if res.ConfWritten {
		setupFlash(w, r, "Профиль «"+profileName+"» импортирован: "+res.Path+
			". Секция дописана в /opt/Validata/VDCSP/etc/pki1.conf. "+
			"Теперь нажмите «Активировать» в таблице ниже — Валидата переключится на профиль БЕЗ перезапуска сайдкара.")
	} else {
		setupFlash(w, r, "Профиль «"+profileName+"» импортирован: "+res.Path+
			". НО в pki1.conf дописать не удалось ("+res.ConfWriteError+"). "+
			"Готовая секция лежит в "+res.Path+"/pki1.conf-section.txt — "+
			"допишите её через sudo, потом нажмите «Активировать».")
	}
}

// activateProfile — POST /admin/setup/media/activate-profile.
// Параметр: name — имя профиля из pki1.conf. Делает gRPC-вызов
// crypto-service.Activate(name) — без рестарта сайдкара. После
// успеха сохраняет имя в runtime-конфиге.
func (h *setupHandlers) activateProfile(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	name := strings.TrimSpace(r.FormValue("name"))
	s := h.rc.Snapshot()
	cli := cryptocli.New(cryptocli.Config{
		Provider:   cryptocli.Provider(s.Crypto.Provider),
		SocketPath: s.Crypto.SocketPath,
	})
	defer cli.Close()
	res, err := cli.Activate(r.Context(), name)
	if err != nil {
		setupFlash(w, r, "Активация: "+err.Error())
		return
	}
	if !res.OK {
		setupFlash(w, r, "Активация не удалась: "+res.Message)
		return
	}
	// Сохраняем активный профиль в runtime-конфиге (для UI и для
	// автозагрузки при следующем рестарте сайдкара).
	cur := h.rc.Snapshot().Crypto
	cur.Profile = name
	if err := h.rc.UpdateCrypto(cur); err != nil {
		log.Printf("activateProfile: UpdateCrypto: %v", err)
	}
	if name == "" {
		setupFlash(w, r, "Профиль сброшен — Валидата в minimal-режиме. "+res.Message)
	} else {
		setupFlash(w, r, "Профиль «"+name+"» активирован: "+res.Message)
	}
}

// deleteContainer — POST /admin/setup/media/delete-container.
// Параметр: name — имя контейнера в /var/lib/bj/containers/.
func (h *setupHandlers) deleteContainer(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	name := strings.TrimSpace(r.FormValue("name"))
	if name == "" {
		setupFlash(w, r, "Удаление контейнера: не указано имя")
		return
	}
	if err := DeleteImportedContainer(name); err != nil {
		setupFlash(w, r, "Удаление контейнера: "+err.Error())
		return
	}
	setupFlash(w, r, "Контейнер «"+name+"» удалён из /var/lib/bj/containers/.")
}

// deleteProfile — POST /admin/setup/media/delete-profile.
// Параметр: name — имя профиля в /var/lib/bj/profiles/.
// Сносит директорию + чистит секцию из pki1.conf. Если профиль был
// активным — переключает crypto-service в minimal через Activate("").
func (h *setupHandlers) deleteProfile(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	name := strings.TrimSpace(r.FormValue("name"))
	if name == "" {
		setupFlash(w, r, "Удаление профиля: не указано имя")
		return
	}
	s := h.rc.Snapshot()
	wasActive := s.Crypto.Profile == name
	if wasActive {
		// Сначала переключаем сайдкар в minimal — иначе он держит
		// открытый контекст на файлы, которые мы сейчас удалим.
		cli := cryptocli.New(cryptocli.Config{
			Provider:   cryptocli.Provider(s.Crypto.Provider),
			SocketPath: s.Crypto.SocketPath,
		})
		_, _ = cli.Activate(r.Context(), "")
		_ = cli.Close()
		cur := s.Crypto
		cur.Profile = ""
		_ = h.rc.UpdateCrypto(cur)
	}
	if err := DeleteImportedProfile(name); err != nil {
		setupFlash(w, r, "Удаление профиля: "+err.Error())
		return
	}
	if wasActive {
		setupFlash(w, r, "Профиль «"+name+"» удалён (директория, секция в pki1.conf). Был активным — crypto-service переведён в minimal.")
	} else {
		setupFlash(w, r, "Профиль «"+name+"» удалён (директория, секция в pki1.conf).")
	}
}

// restartCrypto — POST /admin/setup/restart-crypto. Отправляет
// gRPC.Shutdown сайдкару — он завершится с exit(2), systemd
// поднимет его через RestartSec=5.
func (h *setupHandlers) restartCrypto(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),
		SocketPath: s.Crypto.SocketPath,
	})
	defer cli.Close()
	if err := cli.Shutdown(r.Context()); err != nil {
		// Сайдкар мог уже закрыть соединение до отправки — это нормально.
		log.Printf("restartCrypto: Shutdown: %v (ожидаемо, если уже закрылся)", err)
	}
	setupFlash(w, r, "crypto-service остановлен. systemd поднимет его обратно через ~5 секунд. Через 7-10 секунд обновите страницу.")
}

// restartServer — POST /admin/setup/restart-server. Делает os.Exit(2)
// через короткую задержку, systemd поднимает bj-server обратно.
func (h *setupHandlers) restartServer(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method", http.StatusMethodNotAllowed)
		return
	}
	setupFlash(w, r, "bj-server остановлен. systemd поднимет его обратно через ~5 секунд. Через 7-10 секунд обновите страницу.")
	// Даём 500мс чтобы редирект и flash доставились браузеру.
	go func() {
		time.Sleep(500 * time.Millisecond)
		log.Printf("restartServer: os.Exit(2) по запросу из UI")
		os.Exit(2)
	}()
}

// allowedMediaPath проверяет, что путь к контейнеру внутри одной из
// известных media-точек: USB-mount, наша /var/lib/bj/media/iso/.
func allowedMediaPath(p string) bool {
	abs, err := filepath.Abs(p)
	if err != nil {
		return false
	}
	allowed := []string{"/run/media/", "/media/", "/mnt/", "/var/lib/bj/media/iso/"}
	for _, prefix := range allowed {
		if strings.HasPrefix(abs, prefix) {
			return true
		}
	}
	return false
}

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