package update

import (
	"archive/tar"
	"archive/zip"
	"compress/gzip"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"gokin/internal/security"
)

// Downloader handles downloading update files.
type Downloader struct {
	httpClient *http.Client
	config     *Config
	tempDir    string
}

// NewDownloader creates a new downloader.
func NewDownloader(config *Config, tempDir string) *Downloader {
	tlsConfig := security.DefaultTLSConfig()
	httpClient, err := security.CreateSecureHTTPClient(tlsConfig, 10*time.Minute)
	if err != nil {
		httpClient = &http.Client{Timeout: 10 * time.Minute}
	}
	return &Downloader{
		httpClient: httpClient,
		config:     config,
		tempDir:    tempDir,
	}
}

// Download downloads a file from the given URL with progress reporting.
// Returns the path to the downloaded file.
func (d *Downloader) Download(ctx context.Context, url string, progress ProgressCallback) (string, error) {
	// Create request
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return "", err
	}

	req.Header.Set("User-Agent", "gokin-updater/1.0")
	req.Header.Set("Accept", "application/octet-stream")

	// Add GitHub token if available
	if token := os.Getenv("GITHUB_TOKEN"); token != "" {
		req.Header.Set("Authorization", "token "+token)
	}

	// Send request
	resp, err := d.httpClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("%w: %w", ErrDownloadFailed, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("%w: HTTP %d", ErrDownloadFailed, resp.StatusCode)
	}

	// Create temp directory if needed
	if err := os.MkdirAll(d.tempDir, 0755); err != nil {
		return "", err
	}

	// Create temp file
	tmpFile, err := os.CreateTemp(d.tempDir, "gokin-update-*")
	if err != nil {
		return "", err
	}
	defer tmpFile.Close()

	// Get total size for progress
	totalSize := resp.ContentLength

	// Create progress writer
	pw := &progressWriter{
		writer:   tmpFile,
		total:    totalSize,
		callback: progress,
	}

	// Download with progress
	written, err := io.Copy(pw, resp.Body)
	if err != nil {
		os.Remove(tmpFile.Name())
		return "", fmt.Errorf("%w: %w", ErrDownloadFailed, err)
	}
	if totalSize > 0 && written != totalSize {
		os.Remove(tmpFile.Name())
		return "", fmt.Errorf("%w: incomplete download (%d/%d bytes)", ErrDownloadFailed, written, totalSize)
	}

	// Explicit close before returning success: a deferred-flush error on a
	// near-full filesystem would otherwise silently produce a truncated
	// binary that passes the rest of the install path (the deferred
	// `tmpFile.Close()` swallows the error). Caught early here means a
	// clean error message and a removed temp file instead of a corrupt
	// installed binary.
	if err := tmpFile.Close(); err != nil {
		os.Remove(tmpFile.Name())
		return "", fmt.Errorf("%w: close: %w", ErrDownloadFailed, err)
	}

	return tmpFile.Name(), nil
}

// DownloadChecksum downloads and parses a checksum file.
func (d *Downloader) DownloadChecksum(ctx context.Context, url string) (map[string]string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("User-Agent", "gokin-updater/1.0")

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

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("failed to download checksum: HTTP %d", resp.StatusCode)
	}

	// Limit size of checksum file
	data, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB max
	if err != nil {
		return nil, err
	}

	return d.parseChecksumFile(string(data)), nil
}

// parseChecksumFile parses a checksum file in common formats.
// Supports: "checksum  filename" and "checksum filename" formats.
func (d *Downloader) parseChecksumFile(content string) map[string]string {
	checksums := make(map[string]string)

	lines := strings.Split(content, "\n")
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		// Try "checksum  filename" format (sha256sum output)
		parts := strings.Fields(line)
		if len(parts) >= 2 {
			checksum := parts[0]
			filename := parts[len(parts)-1]
			// Remove leading * from binary mode indicator
			filename = strings.TrimPrefix(filename, "*")
			checksums[filename] = strings.ToLower(checksum)
		}
	}

	return checksums
}

// VerifyChecksum verifies the checksum of a file.
func (d *Downloader) VerifyChecksum(filePath, expectedChecksum string) error {
	f, err := os.Open(filePath)
	if err != nil {
		return err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return err
	}

	actualChecksum := hex.EncodeToString(h.Sum(nil))

	if !strings.EqualFold(actualChecksum, expectedChecksum) {
		return fmt.Errorf("%w: expected %s, got %s", ErrChecksumMismatch, expectedChecksum, actualChecksum)
	}

	return nil
}

// ComputeChecksum computes the SHA256 checksum of a file.
func (d *Downloader) ComputeChecksum(filePath string) (string, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return "", err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}

	return hex.EncodeToString(h.Sum(nil)), nil
}

// ExtractBinary extracts the binary from an archive.
// Supports: .tar.gz, .tgz, .zip, and raw binaries.
func (d *Downloader) ExtractBinary(archivePath, binaryName string) (string, error) {
	ext := strings.ToLower(filepath.Ext(archivePath))

	// Check for .tar.gz
	if strings.HasSuffix(strings.ToLower(archivePath), ".tar.gz") || ext == ".tgz" {
		return d.extractTarGz(archivePath, binaryName)
	}

	if ext == ".zip" {
		return d.extractZip(archivePath, binaryName)
	}

	// Assume it's a raw binary
	return archivePath, nil
}

// extractTarGz extracts a binary from a tar.gz archive.
func (d *Downloader) extractTarGz(archivePath, binaryName string) (string, error) {
	if err := os.MkdirAll(d.tempDir, 0755); err != nil {
		return "", err
	}

	f, err := os.Open(archivePath)
	if err != nil {
		return "", err
	}
	defer f.Close()

	gzr, err := gzip.NewReader(f)
	if err != nil {
		return "", err
	}
	defer gzr.Close()

	tr := tar.NewReader(gzr)

	for {
		header, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return "", err
		}

		// Skip symlinks and hard links to prevent path traversal attacks
		if header.Typeflag == tar.TypeSymlink || header.Typeflag == tar.TypeLink {
			continue
		}

		// Look for the binary
		baseName := filepath.Base(header.Name)
		if baseName == binaryName || baseName == binaryName+".exe" {
			if header.Typeflag == tar.TypeReg {
				// Extract to unique temp file
				outFile, err := os.CreateTemp(d.tempDir, "gokin-bin-*")
				if err != nil {
					return "", err
				}
				outPath := outFile.Name()

				if _, err := io.Copy(outFile, tr); err != nil {
					_ = outFile.Close()
					os.Remove(outPath)
					return "", err
				}
				// Close error matters here: on a full filesystem the kernel may
				// defer the write failure until close. Ignoring it would
				// silently produce a truncated binary that passes the rest of
				// the install path (chmod doesn't validate content) and then
				// fails at /restart with a confusing exec error.
				if err := outFile.Close(); err != nil {
					os.Remove(outPath)
					return "", fmt.Errorf("close extracted binary: %w", err)
				}
				if err := os.Chmod(outPath, os.FileMode(header.Mode)); err != nil {
					os.Remove(outPath)
					return "", err
				}

				return outPath, nil
			}
		}
	}

	return "", fmt.Errorf("binary %q not found in archive", binaryName)
}

// extractZip extracts a binary from a zip archive.
func (d *Downloader) extractZip(archivePath, binaryName string) (string, error) {
	if err := os.MkdirAll(d.tempDir, 0755); err != nil {
		return "", err
	}

	r, err := zip.OpenReader(archivePath)
	if err != nil {
		return "", err
	}
	defer r.Close()

	for _, f := range r.File {
		// Skip symlinks to prevent path traversal attacks
		if f.FileInfo().Mode()&os.ModeSymlink != 0 {
			continue
		}

		baseName := filepath.Base(f.Name)
		if baseName == binaryName || baseName == binaryName+".exe" {
			if !f.FileInfo().IsDir() {
				rc, err := f.Open()
				if err != nil {
					return "", err
				}

				outFile, err := os.CreateTemp(d.tempDir, "gokin-bin-*")
				if err != nil {
					_ = rc.Close()
					return "", err
				}
				outPath := outFile.Name()

				if _, err := io.Copy(outFile, rc); err != nil {
					_ = outFile.Close()
					os.Remove(outPath)
					_ = rc.Close()
					return "", err
				}

				// See extractTarGz: a deferred-flush failure on close would
				// produce a corrupt binary that silently installs.
				if err := outFile.Close(); err != nil {
					os.Remove(outPath)
					_ = rc.Close()
					return "", fmt.Errorf("close extracted binary: %w", err)
				}
				if err := os.Chmod(outPath, f.Mode()); err != nil {
					os.Remove(outPath)
					_ = rc.Close()
					return "", err
				}
				_ = rc.Close()

				return outPath, nil
			}
		}
	}

	return "", fmt.Errorf("binary %q not found in archive", binaryName)
}

// Cleanup removes temporary files.
func (d *Downloader) Cleanup() error {
	if d.tempDir == "" {
		return nil
	}
	return os.RemoveAll(d.tempDir)
}

// progressWriter wraps an io.Writer to report progress.
type progressWriter struct {
	writer   io.Writer
	total    int64
	written  int64
	callback ProgressCallback
}

func (pw *progressWriter) Write(p []byte) (int, error) {
	n, err := pw.writer.Write(p)
	pw.written += int64(n)

	if pw.callback != nil {
		var percent float64
		if pw.total > 0 {
			percent = float64(pw.written) / float64(pw.total) * 100
		}

		pw.callback(&UpdateProgress{
			Status:          StatusDownloading,
			BytesDownloaded: pw.written,
			TotalBytes:      pw.total,
			Percent:         percent,
		})
	}

	return n, err
}
