package tracker

import (
	"context"
	"crypto/rand"
	"encoding/binary"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/jackpal/bencode-go"
	"github.com/veggiedefender/torrent-client/internal/torrentfile"
)

type TrackerResponse struct {
	FailureReason string `bencode:"failure reason"`
	Interval      int    `bencode:"interval"`
	Peers         string `bencode:"peers"`
}

type Peer struct {
	IP   net.IP
	Port uint16
}

type AnnounceOptions struct {
	PeerID     [20]byte
	Port       uint16
	Uploaded   int64
	Downloaded int64
	NumWant    int
	Timeout    time.Duration
}

type httpDoer interface {
	Do(req *http.Request) (*http.Response, error)
}

func GetPeers(ctx context.Context, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
	if tf == nil {
		return nil, errors.New("torrent metadata is nil")
	}
	return GetPeersFromURL(ctx, tf.Announce, tf, opts)
}

func GetPeersFromURL(ctx context.Context, announce string, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
	if tf == nil {
		return nil, errors.New("torrent metadata is nil")
	}

	opts = normalizeOptions(opts)

	announceURL, err := url.Parse(announce)
	if err != nil {
		return nil, fmt.Errorf("invalid tracker URL %q: %w", announce, err)
	}

	switch announceURL.Scheme {
	case "http", "https":
		client := &http.Client{Timeout: opts.Timeout}
		return getPeersWithClient(ctx, client, announceURL, tf, opts)
	case "udp":
		return getPeersUDP(ctx, announceURL, tf, opts)
	default:
		return nil, fmt.Errorf("unsupported tracker scheme %q (only http/https/udp are supported)", announceURL.Scheme)
	}
}

func getPeersWithClient(ctx context.Context, client httpDoer, announceURL *url.URL, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
	opts = normalizeOptions(opts)

	if client == nil {
		client = &http.Client{Timeout: opts.Timeout}
	}

	announceURLString, err := buildAnnounceURL(announceURL, tf, opts)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, announceURLString, nil)
	if err != nil {
		return nil, err
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("tracker returned HTTP %d", resp.StatusCode)
	}

	var tr TrackerResponse

	err = bencode.Unmarshal(resp.Body, &tr)
	if err != nil {
		return nil, err
	}

	if tr.FailureReason != "" {
		return nil, fmt.Errorf("tracker failure: %s", tr.FailureReason)
	}

	return parsePeers([]byte(tr.Peers))
}

func getPeersUDP(ctx context.Context, announceURL *url.URL, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
	if announceURL == nil {
		return nil, errors.New("tracker URL is nil")
	}
	if announceURL.Scheme != "udp" {
		return nil, fmt.Errorf("tracker URL scheme %q is not udp", announceURL.Scheme)
	}
	if announceURL.Host == "" {
		return nil, errors.New("tracker URL host is empty")
	}

	dialer := net.Dialer{Timeout: opts.Timeout}
	conn, err := dialer.DialContext(ctx, "udp", announceURL.Host)
	if err != nil {
		return nil, err
	}
	defer conn.Close()

	deadline := time.Now().Add(opts.Timeout)
	if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
		deadline = ctxDeadline
	}
	if err := conn.SetDeadline(deadline); err != nil {
		return nil, err
	}

	connectTx, err := randomUint32()
	if err != nil {
		return nil, err
	}

	connectReq := buildUDPConnectRequest(connectTx)
	if _, err := conn.Write(connectReq[:]); err != nil {
		return nil, err
	}

	resp := make([]byte, 65535)
	n, err := conn.Read(resp)
	if err != nil {
		return nil, err
	}

	connectionID, err := parseUDPConnectResponse(resp[:n], connectTx)
	if err != nil {
		return nil, err
	}

	announceTx, err := randomUint32()
	if err != nil {
		return nil, err
	}

	key, err := randomUint32()
	if err != nil {
		return nil, err
	}

	announceReq, err := buildUDPAnnounceRequest(connectionID, announceTx, tf, opts, key)
	if err != nil {
		return nil, err
	}
	if _, err := conn.Write(announceReq); err != nil {
		return nil, err
	}

	n, err = conn.Read(resp)
	if err != nil {
		return nil, err
	}

	return parseUDPAnnounceResponse(resp[:n], announceTx)
}

func normalizeOptions(opts AnnounceOptions) AnnounceOptions {
	if opts.Port == 0 {
		opts.Port = 6881
	}
	if opts.NumWant <= 0 {
		opts.NumWant = 50
	}
	if opts.Timeout <= 0 {
		opts.Timeout = 10 * time.Second
	}
	return opts
}

func buildAnnounceURL(baseURL *url.URL, tf *torrentfile.TorrentFile, opts AnnounceOptions) (string, error) {
	if tf == nil {
		return "", errors.New("torrent metadata is nil")
	}
	if baseURL == nil {
		return "", errors.New("tracker URL is nil")
	}
	if baseURL.Scheme != "http" && baseURL.Scheme != "https" {
		return "", fmt.Errorf("unsupported tracker scheme %q (only http/https are supported)", baseURL.Scheme)
	}
	if baseURL.Host == "" {
		return "", errors.New("tracker URL host is empty")
	}

	uploaded := opts.Uploaded
	if uploaded < 0 {
		uploaded = 0
	}

	downloaded := opts.Downloaded
	if downloaded < 0 {
		downloaded = 0
	}

	left := int64(tf.Length) - downloaded
	if left < 0 {
		left = 0
	}

	parts := make([]string, 0, 10)
	if baseURL.RawQuery != "" {
		parts = append(parts, baseURL.RawQuery)
	}

	parts = append(parts,
		"info_hash="+escapeBinary(tf.InfoHash[:]),
		"peer_id="+escapeBinary(opts.PeerID[:]),
		"port="+strconv.Itoa(int(opts.Port)),
		"uploaded="+strconv.FormatInt(uploaded, 10),
		"downloaded="+strconv.FormatInt(downloaded, 10),
		"left="+strconv.FormatInt(left, 10),
		"compact=1",
		"numwant="+strconv.Itoa(opts.NumWant),
	)

	cloned := *baseURL
	cloned.RawQuery = strings.Join(parts, "&")
	return cloned.String(), nil
}

func buildUDPConnectRequest(transactionID uint32) [16]byte {
	var req [16]byte
	binary.BigEndian.PutUint64(req[0:8], 0x41727101980)
	binary.BigEndian.PutUint32(req[8:12], 0)
	binary.BigEndian.PutUint32(req[12:16], transactionID)
	return req
}

func parseUDPConnectResponse(payload []byte, expectedTransactionID uint32) (uint64, error) {
	if len(payload) < 8 {
		return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
	}

	action := binary.BigEndian.Uint32(payload[0:4])
	transactionID := binary.BigEndian.Uint32(payload[4:8])
	if transactionID != expectedTransactionID {
		return 0, fmt.Errorf("udp tracker connect transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
	}

	if action == 3 {
		return 0, parseUDPTrackerError(payload)
	}
	if action != 0 {
		return 0, fmt.Errorf("unexpected udp tracker connect action %d", action)
	}
	if len(payload) < 16 {
		return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
	}

	return binary.BigEndian.Uint64(payload[8:16]), nil
}

func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, tf *torrentfile.TorrentFile, opts AnnounceOptions, key uint32) ([]byte, error) {
	if tf == nil {
		return nil, errors.New("torrent metadata is nil")
	}

	uploaded := opts.Uploaded
	if uploaded < 0 {
		uploaded = 0
	}
	downloaded := opts.Downloaded
	if downloaded < 0 {
		downloaded = 0
	}
	left := int64(tf.Length) - downloaded
	if left < 0 {
		left = 0
	}

	req := make([]byte, 98)
	binary.BigEndian.PutUint64(req[0:8], connectionID)
	binary.BigEndian.PutUint32(req[8:12], 1)
	binary.BigEndian.PutUint32(req[12:16], transactionID)
	copy(req[16:36], tf.InfoHash[:])
	copy(req[36:56], opts.PeerID[:])
	binary.BigEndian.PutUint64(req[56:64], uint64(downloaded))
	binary.BigEndian.PutUint64(req[64:72], uint64(left))
	binary.BigEndian.PutUint64(req[72:80], uint64(uploaded))
	binary.BigEndian.PutUint32(req[80:84], 0)
	binary.BigEndian.PutUint32(req[84:88], 0)
	binary.BigEndian.PutUint32(req[88:92], key)
	binary.BigEndian.PutUint32(req[92:96], uint32(int32(opts.NumWant)))
	binary.BigEndian.PutUint16(req[96:98], opts.Port)

	return req, nil
}

func parseUDPAnnounceResponse(payload []byte, expectedTransactionID uint32) ([]Peer, error) {
	if len(payload) < 8 {
		return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
	}

	action := binary.BigEndian.Uint32(payload[0:4])
	transactionID := binary.BigEndian.Uint32(payload[4:8])
	if transactionID != expectedTransactionID {
		return nil, fmt.Errorf("udp tracker announce transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
	}

	if action == 3 {
		return nil, parseUDPTrackerError(payload)
	}
	if action != 1 {
		return nil, fmt.Errorf("unexpected udp tracker announce action %d", action)
	}
	if len(payload) < 20 {
		return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
	}

	return parsePeers(payload[20:])
}

func parseUDPTrackerError(payload []byte) error {
	if len(payload) > 8 {
		return fmt.Errorf("udp tracker failure: %s", strings.TrimSpace(string(payload[8:])))
	}
	return errors.New("udp tracker failure")
}

func escapeBinary(data []byte) string {
	const hex = "0123456789ABCDEF"

	var b strings.Builder
	for _, c := range data {
		if isURLUnreserved(c) {
			b.WriteByte(c)
			continue
		}
		b.WriteByte('%')
		b.WriteByte(hex[c>>4])
		b.WriteByte(hex[c&0x0F])
	}
	return b.String()
}

func isURLUnreserved(c byte) bool {
	switch {
	case c >= 'a' && c <= 'z':
		return true
	case c >= 'A' && c <= 'Z':
		return true
	case c >= '0' && c <= '9':
		return true
	case c == '-', c == '.', c == '_', c == '~':
		return true
	default:
		return false
	}
}

func parsePeers(data []byte) ([]Peer, error) {
	if len(data) == 0 {
		return []Peer{}, nil
	}
	if len(data)%6 != 0 {
		return nil, fmt.Errorf("invalid compact peers length %d", len(data))
	}

	var peers []Peer

	for i := 0; i < len(data); i += 6 {

		ip := net.IP(data[i : i+4])

		port := uint16(data[i+4])<<8 |
			uint16(data[i+5])

		peers = append(peers, Peer{
			IP:   ip,
			Port: port,
		})
	}

	return peers, nil
}

func randomUint32() (uint32, error) {
	var b [4]byte
	if _, err := rand.Read(b[:]); err != nil {
		return 0, err
	}
	return binary.BigEndian.Uint32(b[:]), nil
}
