// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
// Документ-источник: DOC/instr-ish-rest-api.pdf (НРД, 2026).
//
// ИШ — серверное ПО НРД, которое:
//   - принимает от нас сырой XML/ZIP M2M-документа;
//   - сам подписывает его сертификатом УЦ МБ (ключ настроен в ИШ);
//   - формирует пакет ЭДО по Правилам ЭДО НРД;
//   - отправляет в НРД через Web-сервис ONYX;
//   - принимает входящие пакеты M2MTD/M2MER от НРД;
//   - проверяет подпись НРД (поле signs.status = VALID/INVALID);
//   - выдаёт всё это клиенту через REST API.
//
// REST-эндпоинты (все по 200/JSON):
//   POST   /api/package/{channel}/file     — отправить ZIP, вернёт id
//   GET    /api/package/status/{id}        — статус: NEW | SENT | ERROR
//   GET    /api/package?channel=&type=...  — список входящих
//   GET    /api/package/{id}               — тело пакета (ZIP в base64)
package igw

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

// Client — REST-клиент ИШ НРД.
type Client struct {
	baseURL    string
	httpClient *http.Client
	retryMax   int
	retryWait  time.Duration
}

// Option настраивает Client.
type Option func(*Client)

// WithHTTPClient заменяет стандартный http.Client (для тестов и
// фиксации таймаутов).
func WithHTTPClient(c *http.Client) Option {
	return func(cl *Client) { cl.httpClient = c }
}

// WithRetry задаёт количество ретраев и базовое ожидание (линейный backoff).
func WithRetry(max int, wait time.Duration) Option {
	return func(cl *Client) { cl.retryMax = max; cl.retryWait = wait }
}

// NewClient собирает клиента к ИШ по URL.
func NewClient(baseURL string, opts ...Option) *Client {
	c := &Client{
		baseURL:    strings.TrimRight(baseURL, "/"),
		httpClient: &http.Client{Timeout: 30 * time.Second},
		retryMax:   3,
		retryWait:  time.Second,
	}
	for _, o := range opts {
		o(c)
	}
	return c
}

// --------------- POST /api/package/{channel}/file ---------------

// sendBody — тело запроса на отправку пакета. По спецификации НРД
// (instr-ish-rest-api.pdf, раздел 2.5.1) поля Type/File.
type sendBody struct {
	Type string `json:"Type"`
	File string `json:"File"`
}

// sendResponse — ответ ИШ на отправку. Спецификация: 200 + JSON с ID.
// В новом документе НРД сам формат JSON не зафиксирован детально, поэтому
// принимаем три популярные формы: {id:..}, {package_id:..}, {ID:..}.
type sendResponse struct {
	ID        json.Number `json:"id,omitempty"`
	PackageID json.Number `json:"package_id,omitempty"`
	IDAlt     json.Number `json:"ID,omitempty"`
}

// SendPackage отправляет ZIP-архив (M2MTransferRequest.xml + config.xml)
// в указанный канал ЭДО ИШ. Сигнатура совместима с предыдущей версией —
// packageType остался параметром для backward-compat, но в новом API
// он внутри ZIP'а (в config.xml/<package>), не в HTTP-теле.
//
// Возвращает идентификатор пакета (как строку — может быть числом или
// UUID, зависит от версии ИШ).
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, zipBody []byte) (string, error) {
	if channel == "" {
		return "", errors.New("igw: channel пустой")
	}
	_ = packageType // тип пакета (#M2MTR) кладётся внутрь ZIP/config.xml через pack.go
	if len(zipBody) == 0 {
		return "", errors.New("igw: zipBody пустой")
	}
	// ИШ REST API ждёт multipart/form-data: поле File (binary ZIP) +
	// Type (enum InFileType: FILE | ARCHIVE). Для ZIP-пакета — ARCHIVE.
	// Ответ: {"id": <int64>}. См. Swagger /api/package/{channel}/file.
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	if err := mw.WriteField("Type", "ARCHIVE"); err != nil {
		return "", fmt.Errorf("igw: multipart Type: %w", err)
	}
	fw, err := mw.CreateFormFile("File", "package.zip")
	if err != nil {
		return "", fmt.Errorf("igw: multipart File: %w", err)
	}
	if _, err := fw.Write(zipBody); err != nil {
		return "", fmt.Errorf("igw: write zip в multipart: %w", err)
	}
	if err := mw.Close(); err != nil {
		return "", fmt.Errorf("igw: multipart close: %w", err)
	}
	path := fmt.Sprintf("/api/package/%s/file", url.PathEscape(channel))
	resp, err := c.doRetry(ctx, http.MethodPost, path, &buf, mw.FormDataContentType())
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	var out sendResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return "", fmt.Errorf("igw: decode SendPackage response: %w", err)
	}
	for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
		if s := string(v); s != "" {
			return s, nil
		}
	}
	return "", errors.New("igw: пустой id в ответе ИШ")
}

// --------------- GET /api/package/status/{id} ---------------

// Status — состояние отправленного пакета (раздел 2.5.2 инструкции).
// Status может быть NEW (новый), SENT (отправлен), ERROR (ошибка).
type Status struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Status string `json:"status"`
	Error  string `json:"error,omitempty"`
}

// Status-константы для удобства.
const (
	StatusNew   = "NEW"
	StatusSent  = "SENT"
	StatusError = "ERROR"
)

// GetStatus возвращает текущее состояние пакета по идентификатору.
func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error) {
	if packageID == "" {
		return Status{}, errors.New("igw: packageID пустой")
	}
	path := "/api/package/status/" + url.PathEscape(packageID)
	resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
	if err != nil {
		return Status{}, err
	}
	defer resp.Body.Close()
	// Дублируем альтернативные имена полей (id|ID, status|state) — на
	// случай различий между версиями ИШ.
	var raw map[string]json.RawMessage
	if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
		return Status{}, fmt.Errorf("igw: decode Status: %w", err)
	}
	s := Status{}
	pickStr(raw, []string{"id", "ID", "package_id"}, &s.ID)
	pickStr(raw, []string{"name"}, &s.Name)
	pickStr(raw, []string{"status", "state"}, &s.Status)
	pickStr(raw, []string{"error", "error_text", "error_code"}, &s.Error)
	return s, nil
}

// --------------- GET /api/package?channel=&type=... ---------------

// ListFilter — параметры фильтрации входящих пакетов (раздел 2.6).
type ListFilter struct {
	Channel       string    // обязательный — код канала ЭДО
	Date          time.Time // опц., YYYY-MM-DD
	SinceID       int       // опц., скип до этого id
	Count         int       // опц., лимит
	Type          string    // опц., "M2MTD" | "M2MER" (без #)
	ExcludeErrors bool      // опц., исключать пакеты с ошибкой
}

// Sign — подпись пакета. ИШ сам проверяет и выдаёт результат: VALID/INVALID.
type Sign struct {
	Serial      string `json:"serial"`
	Subject     string `json:"subject"`
	Description string `json:"description"`
	Status      string `json:"status"` // "VALID" | "INVALID"
}

// File — файл внутри пакета.
type File struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

// Package — описание входящего пакета по списку (раздел 2.6).
type Package struct {
	Channel string `json:"channel"`
	ID      int    `json:"id"`
	Name    string `json:"name"`
	Type    string `json:"type"`  // "M2MTD" | "M2MER"
	State   string `json:"state"` // "RECEIVED" | "ERROR" | "DELETED"
	Files   []File `json:"files,omitempty"`
	Signs   []Sign `json:"signs,omitempty"`
}

// ListIncoming возвращает список входящих пакетов от НРД по фильтрам.
// Если filter.Type не задан — возвращает оба типа M2MTD + M2MER.
func (c *Client) ListIncoming(ctx context.Context, filter ListFilter) ([]Package, error) {
	if filter.Channel == "" {
		return nil, errors.New("igw: ListFilter.Channel обязателен")
	}
	q := url.Values{}
	q.Set("channel", filter.Channel)
	if !filter.Date.IsZero() {
		q.Set("date", filter.Date.UTC().Format("2006-01-02"))
	}
	if filter.SinceID > 0 {
		q.Set("id", strconv.Itoa(filter.SinceID))
	}
	if filter.Count > 0 {
		q.Set("count", strconv.Itoa(filter.Count))
	}
	if filter.Type != "" {
		q.Set("type", filter.Type)
	}
	if filter.ExcludeErrors {
		q.Set("excludeErrors", "true")
	}
	path := "/api/package?" + q.Encode()
	resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	// ИШ может вернуть либо массив, либо {items: [...]}. Поддержим оба.
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("igw: read ListIncoming: %w", err)
	}
	body = bytes.TrimSpace(body)
	if len(body) == 0 {
		return nil, nil
	}
	if body[0] == '[' {
		var arr []Package
		if err := json.Unmarshal(body, &arr); err != nil {
			return nil, fmt.Errorf("igw: decode ListIncoming (array): %w", err)
		}
		return arr, nil
	}
	var wrap struct {
		Items []Package `json:"items"`
	}
	if err := json.Unmarshal(body, &wrap); err != nil {
		return nil, fmt.Errorf("igw: decode ListIncoming (object): %w", err)
	}
	return wrap.Items, nil
}

// --------------- GET /api/package/{id} ---------------

// GetPackage возвращает содержимое пакета по ID — ZIP-архив с файлами
// документа и отсоединёнными подписями. По спецификации (раздел 2.6.2)
// ИШ отвечает 200 + body = base64-encoded ZIP. Метод декодирует base64
// и возвращает сырые ZIP-байты.
func (c *Client) GetPackage(ctx context.Context, packageID int) ([]byte, error) {
	if packageID <= 0 {
		return nil, errors.New("igw: packageID должен быть > 0")
	}
	path := "/api/package/" + strconv.Itoa(packageID)
	resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("igw: read GetPackage: %w", err)
	}
	// ИШ возвращает либо чистый ZIP (Content-Type: application/zip),
	// либо JSON с base64. Проверим по сигнатуре ZIP (PK\x03\x04).
	if len(body) >= 4 && body[0] == 'P' && body[1] == 'K' &&
		body[2] == 0x03 && body[3] == 0x04 {
		return body, nil
	}
	// Иначе пробуем base64 (с JSON-обёрткой или без).
	stripped := bytes.TrimSpace(body)
	if len(stripped) > 1 && stripped[0] == '"' && stripped[len(stripped)-1] == '"' {
		stripped = stripped[1 : len(stripped)-1]
	}
	// JSON-объект {"file":"..."} или {"body":"..."}
	if len(stripped) > 0 && stripped[0] == '{' {
		var obj map[string]string
		if err := json.Unmarshal(stripped, &obj); err == nil {
			for _, k := range []string{"file", "File", "body", "Body"} {
				if v, ok := obj[k]; ok {
					return base64.StdEncoding.DecodeString(v)
				}
			}
		}
	}
	decoded, err := base64.StdEncoding.DecodeString(string(stripped))
	if err == nil && len(decoded) >= 4 && decoded[0] == 'P' && decoded[1] == 'K' {
		return decoded, nil
	}
	return body, nil
}

// --------------- общая HTTP-логика ---------------

// doRetry выполняет HTTP-запрос с ретраями на сетевые ошибки и 5xx.
func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
	var lastErr error
	var bodyBytes []byte
	if body != nil {
		b, err := io.ReadAll(body)
		if err != nil {
			return nil, err
		}
		bodyBytes = b
	}
	for attempt := 0; attempt <= c.retryMax; attempt++ {
		req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(bodyBytes))
		if err != nil {
			return nil, err
		}
		if contentType != "" {
			req.Header.Set("Content-Type", contentType)
		}
		req.Header.Set("Accept", "application/json, */*")
		resp, err := c.httpClient.Do(req)
		switch {
		case err != nil:
			lastErr = err
		case resp.StatusCode >= 500:
			_ = resp.Body.Close()
			lastErr = fmt.Errorf("igw: HTTP %d", resp.StatusCode)
		case resp.StatusCode >= 400:
			defer resp.Body.Close()
			b, _ := io.ReadAll(resp.Body)
			return nil, fmt.Errorf("igw: HTTP %d: %s", resp.StatusCode, string(b))
		default:
			return resp, nil
		}
		if attempt < c.retryMax {
			select {
			case <-ctx.Done():
				return nil, ctx.Err()
			case <-time.After(c.retryWait * time.Duration(attempt+1)):
			}
		}
	}
	return nil, fmt.Errorf("igw: исчерпаны ретраи: %w", lastErr)
}

// pickStr заполняет dest первым непустым значением из raw по списку ключей.
func pickStr(raw map[string]json.RawMessage, keys []string, dest *string) {
	for _, k := range keys {
		if v, ok := raw[k]; ok {
			var s string
			if err := json.Unmarshal(v, &s); err == nil && s != "" {
				*dest = s
				return
			}
			// Может быть числом — превращаем в строку.
			var n json.Number
			if err := json.Unmarshal(v, &n); err == nil && string(n) != "" {
				*dest = string(n)
				return
			}
		}
	}
}
