// Package qbt — клиент qBittorrent WebUI API (v2): сессия, добавление
// торрента, опрос задач по категории.
//
// Логин ленивый: cookie-сессия устанавливается при первом 403 и повторно
// при её протухании. Источник (magnet/.torrent) отдаём qBittorrent — он сам
// качает, jellybit не делает исходящих запросов на пользовательский URL.
package qbt

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"mime/multipart"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

	"git.vakhrushev.me/av/jellybit/internal/logctx"
	"git.vakhrushev.me/av/jellybit/internal/logging"
)

// Config — параметры подключения к qBittorrent WebUI.
type Config struct {
	URL      string
	Username string
	Password string
	Timeout  time.Duration
}

// Client — клиент qBittorrent WebUI API.
type Client struct {
	base *url.URL
	hc   *http.Client
	user string
	pass string
	log  *slog.Logger
	mu   sync.Mutex // сериализует логин
}

// Torrent — подмножество полей /torrents/info, нужное jellybit. Помимо полей
// для машины состояний и сопоставления несёт живую телеметрию (скорости, ETA,
// статистика раздачи) — её собирает снимок воркера для веб-UI; все эти поля
// приходят в том же ответе, отдельного вызова не нужно.
type Torrent struct {
	Hash        string  `json:"hash"`
	Name        string  `json:"name"`
	State       string  `json:"state"`
	SavePath    string  `json:"save_path"`
	ContentPath string  `json:"content_path"`
	Category    string  `json:"category"`
	Tags        string  `json:"tags"` // через запятую
	Progress    float64 `json:"progress"`
	AmountLeft  int64   `json:"amount_left"`
	TotalSize   int64   `json:"total_size"` // полный размер раздачи, байт
	AddedOn     int64   `json:"added_on"`
	// LastActivity — Unix-время последнего движения данных по торренту (скачан/
	// отдан кусок). Базис измерения простоя для stuck_after: простой = now −
	// last_activity (а не возраст от added_on), поэтому долго качавшийся торрент,
	// на миг зашедший в stalledDL, не помечается «зависшим».
	LastActivity int64  `json:"last_activity"`
	InfohashV1   string `json:"infohash_v1"`
	InfohashV2   string `json:"infohash_v2"`

	// Живая телеметрия (для снимка воркера и веб-UI).
	Dlspeed   int64   `json:"dlspeed"`    // скорость загрузки, байт/с
	Upspeed   int64   `json:"upspeed"`    // скорость отдачи, байт/с
	Eta       int64   `json:"eta"`        // оценка до завершения, с (8640000 ≈ ∞)
	Ratio     float64 `json:"ratio"`      // рейтинг отдачи (может быть -1 = ∞/н/д)
	NumSeeds  int     `json:"num_seeds"`  // подключённые сиды
	NumLeechs int     `json:"num_leechs"` // подключённые личи (пиры)
	Uploaded  int64   `json:"uploaded"`   // отдано всего, байт
}

// File — элемент /torrents/files: путь файла относительно save_path
// (включая корневую папку торрента для многофайловых раздач) и его размер.
// Абсолютный путь на диске = filepath.Join(save_path, Name) — НЕ content_path:
// для многофайловой раздачи это удвоило бы корневую папку, для однофайловой
// дало бы путь под самим файлом.
type File struct {
	Name string `json:"name"`
	Size int64  `json:"size"`
}

// AddRequest — параметры добавления торрента.
type AddRequest struct {
	URLs     []string // magnet/URL-ссылки
	Torrents [][]byte // .torrent-файлы (Ф1 не использует)
	Category string
	SavePath string
	Rename   string // отображаемое имя торрента (param rename); пустое — не задаём
	Paused   bool
}

// New создаёт клиент с собственным cookie-jar. logger nil → slog.Default().
func New(cfg Config, logger *slog.Logger) (*Client, error) {
	base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
	if err != nil {
		return nil, fmt.Errorf("parse qbittorrent url %q: %w", cfg.URL, err)
	}
	jar, err := cookiejar.New(nil)
	if err != nil {
		return nil, fmt.Errorf("cookie jar: %w", err)
	}
	timeout := cfg.Timeout
	if timeout == 0 {
		timeout = 30 * time.Second
	}
	if logger == nil {
		logger = slog.Default()
	}
	return &Client{
		base: base,
		hc:   &http.Client{Jar: jar, Timeout: timeout},
		user: cfg.Username,
		pass: cfg.Password,
		log:  logger,
	}, nil
}

func (c *Client) endpoint(path string) string { return c.base.String() + path }

// login устанавливает cookie-сессию. Сериализован, чтобы параллельные
// вызовы (поллинг + приём) не логинились наперегонки.
func (c *Client) login(ctx context.Context) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	form := url.Values{"username": {c.user}, "password": {c.pass}}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		c.endpoint("/api/v2/auth/login"), strings.NewReader(form.Encode()))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Referer", c.base.String()) // qBit проверяет Referer/Host
	log := logctx.FromOr(ctx, c.log)
	call := logging.StartCall(logging.ServiceQBittorrent, "auth/login")
	resp, err := c.hc.Do(req)
	if err != nil {
		call.Failure(log, err)
		return fmt.Errorf("qbittorrent login: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()
	call.Status = resp.StatusCode
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
	if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." {
		// Тело логина не содержит секретов (qBit отвечает "Ok."/"Fails."), но
		// учётные данные (логин/пароль) в лог не идут — только факт неуспеха.
		err := fmt.Errorf("qbittorrent login failed: status %d body %q",
			resp.StatusCode, strings.TrimSpace(string(body)))
		call.Failure(log, err)
		return err
	}
	call.Success(log, "authenticated", true)
	return nil
}

// do выполняет запрос; при 403 один раз перелогинивается и повторяет.
// build вызывается заново для повтора, т.к. тело запроса одноразовое.
func (c *Client) do(ctx context.Context, build func() (*http.Request, error)) (*http.Response, error) {
	req, err := build()
	if err != nil {
		return nil, err
	}
	resp, err := c.hc.Do(req)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode == http.StatusForbidden {
		_ = resp.Body.Close()
		logctx.FromOr(ctx, c.log).Debug("qbittorrent session expired, re-login")
		if err := c.login(ctx); err != nil {
			return nil, err
		}
		req2, err := build()
		if err != nil {
			return nil, err
		}
		return c.hc.Do(req2)
	}
	return resp, nil
}

// Add добавляет торрент(ы) в qBittorrent.
func (c *Client) Add(ctx context.Context, ar AddRequest) error {
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	if len(ar.URLs) > 0 {
		_ = mw.WriteField("urls", strings.Join(ar.URLs, "\n"))
	}
	if ar.Category != "" {
		_ = mw.WriteField("category", ar.Category)
	}
	if ar.SavePath != "" {
		_ = mw.WriteField("savepath", ar.SavePath)
	}
	if ar.Rename != "" {
		_ = mw.WriteField("rename", ar.Rename)
	}
	_ = mw.WriteField("paused", strconv.FormatBool(ar.Paused))
	for i, data := range ar.Torrents {
		fw, err := mw.CreateFormFile("torrents", fmt.Sprintf("file%d.torrent", i))
		if err != nil {
			return fmt.Errorf("qbittorrent add: form file: %w", err)
		}
		if _, err := fw.Write(data); err != nil {
			return fmt.Errorf("qbittorrent add: write file: %w", err)
		}
	}
	if err := mw.Close(); err != nil {
		return fmt.Errorf("qbittorrent add: close multipart: %w", err)
	}
	contentType := mw.FormDataContentType()
	payload := buf.Bytes()

	log := logctx.FromOr(ctx, c.log)
	call := logging.StartCall(logging.ServiceQBittorrent, "torrents/add")
	resp, err := c.do(ctx, func() (*http.Request, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost,
			c.endpoint("/api/v2/torrents/add"), bytes.NewReader(payload))
		if err != nil {
			return nil, err
		}
		req.Header.Set("Content-Type", contentType)
		req.Header.Set("Referer", c.base.String())
		return req, nil
	})
	if err != nil {
		call.Failure(log, err, "category", ar.Category)
		return fmt.Errorf("qbittorrent add: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()
	call.Status = resp.StatusCode
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
	if resp.StatusCode != http.StatusOK {
		err := fmt.Errorf("qbittorrent add: status %d body %q",
			resp.StatusCode, strings.TrimSpace(string(body)))
		call.Failure(log, err, "category", ar.Category)
		return err
	}
	if strings.TrimSpace(string(body)) == "Fails." {
		err := fmt.Errorf("qbittorrent add: rejected (Fails.)")
		call.Failure(log, err, "category", ar.Category,
			"urls", len(ar.URLs), "torrents", len(ar.Torrents))
		return err
	}
	call.Success(log, "category", ar.Category, "save_path", ar.SavePath,
		"urls", len(ar.URLs), "torrents", len(ar.Torrents), "paused", ar.Paused)
	return nil
}

// Delete удаляет раздачу(и) из qBittorrent. deleteFiles=true сносит и файлы на
// диске (освобождая место — библиотечный хардлинк jellybit к тому же иноду
// снимается отдельно). Идемпотентно: неизвестный хеш qBittorrent просто
// игнорирует, ошибкой это не считаем. jellybit сам файлы под downloads не
// трогает — их удаляет qBittorrent по этому вызову.
func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool) error {
	nonEmpty := hashes[:0:0]
	for _, h := range hashes {
		if h = strings.TrimSpace(h); h != "" {
			nonEmpty = append(nonEmpty, h)
		}
	}
	if len(nonEmpty) == 0 {
		return fmt.Errorf("qbittorrent delete: no hashes")
	}
	form := url.Values{
		"hashes":      {strings.Join(nonEmpty, "|")},
		"deleteFiles": {strconv.FormatBool(deleteFiles)},
	}
	body := form.Encode()

	log := logctx.FromOr(ctx, c.log)
	call := logging.StartCall(logging.ServiceQBittorrent, "torrents/delete")
	resp, err := c.do(ctx, func() (*http.Request, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost,
			c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body))
		if err != nil {
			return nil, err
		}
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Referer", c.base.String())
		return req, nil
	})
	if err != nil {
		call.Failure(log, err)
		return fmt.Errorf("qbittorrent delete: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()
	call.Status = resp.StatusCode
	if resp.StatusCode != http.StatusOK {
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
		err := fmt.Errorf("qbittorrent delete: status %d body %q",
			resp.StatusCode, strings.TrimSpace(string(respBody)))
		call.Failure(log, err)
		return err
	}
	call.Success(log, "hashes", len(nonEmpty), "delete_files", deleteFiles)
	return nil
}

// RenameTorrent задаёт отображаемое имя уже добавленной раздачи (в отличие от
// параметра rename при Add, действующего только в момент добавления). hash —
// ключ раздачи в qBittorrent (Torrent.Hash), а не сырой infohash загрузки:
// вызывающий резолвит его листингом. Косметика: имя раздачи не влияет на файлы.
func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error {
	hash = strings.TrimSpace(hash)
	if hash == "" {
		return fmt.Errorf("qbittorrent rename: empty hash")
	}
	form := url.Values{"hash": {hash}, "name": {name}}
	body := form.Encode()

	log := logctx.FromOr(ctx, c.log)
	call := logging.StartCall(logging.ServiceQBittorrent, "torrents/rename")
	resp, err := c.do(ctx, func() (*http.Request, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost,
			c.endpoint("/api/v2/torrents/rename"), strings.NewReader(body))
		if err != nil {
			return nil, err
		}
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Referer", c.base.String())
		return req, nil
	})
	if err != nil {
		call.Failure(log, err)
		return fmt.Errorf("qbittorrent rename: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()
	call.Status = resp.StatusCode
	if resp.StatusCode != http.StatusOK {
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
		err := fmt.Errorf("qbittorrent rename: status %d body %q",
			resp.StatusCode, strings.TrimSpace(string(respBody)))
		call.Failure(log, err)
		return err
	}
	call.Success(log)
	return nil
}

// Torrents возвращает задачи указанной категории (пустая — все).
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
	log := logctx.FromOr(ctx, c.log)
	call := logging.StartCall(logging.ServiceQBittorrent, "torrents/info")
	resp, err := c.do(ctx, func() (*http.Request, error) {
		u := c.endpoint("/api/v2/torrents/info")
		if category != "" {
			u += "?category=" + url.QueryEscape(category)
		}
		return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	})
	if err != nil {
		call.Failure(log, err)
		return nil, fmt.Errorf("qbittorrent info: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()
	call.Status = resp.StatusCode
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
		err := fmt.Errorf("qbittorrent info: status %d body %q",
			resp.StatusCode, strings.TrimSpace(string(body)))
		call.Failure(log, err)
		return nil, err
	}
	var ts []Torrent
	if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil {
		call.Failure(log, err)
		return nil, fmt.Errorf("decode qbittorrent info: %w", err)
	}
	// Поллинг частый — на DEBUG, чтобы не зашумлять INFO (как healthcheck).
	call.SuccessDebug(log, "category", category, "count", len(ts))
	return ts, nil
}

// Files возвращает список файлов торрента (имена относительно save_path,
// включая корневую папку для многофайловых раздач, и размеры). Нужен
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
	log := logctx.FromOr(ctx, c.log)
	call := logging.StartCall(logging.ServiceQBittorrent, "torrents/files")
	resp, err := c.do(ctx, func() (*http.Request, error) {
		u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
		return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	})
	if err != nil {
		call.Failure(log, err)
		return nil, fmt.Errorf("qbittorrent files: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()
	call.Status = resp.StatusCode
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
		err := fmt.Errorf("qbittorrent files: status %d body %q",
			resp.StatusCode, strings.TrimSpace(string(body)))
		call.Failure(log, err)
		return nil, err
	}
	var fs []File
	if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil {
		call.Failure(log, err)
		return nil, fmt.Errorf("decode qbittorrent files: %w", err)
	}
	call.SuccessDebug(log, "count", len(fs))
	return fs, nil
}
