package updater

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strings"
	"time"

	archivepkg "github.com/dbledeez/panel/agent/internal/archive"
	modulepkg "github.com/dbledeez/panel/pkg/module"
	"github.com/dbledeez/panel/pkg/version"
)

// DirectProvider fetches a URL and writes the response body to a target
// path under the instance. Used for vanilla Minecraft jars, custom mod
// packs hosted on any HTTP server, etc.
type DirectProvider struct{}

// Name identifies this provider kind in logs / manifest.
func (DirectProvider) Name() string { return "direct" }

const maxDirectDownloadBytes = 1 << 30 // 1 GiB sanity cap

// Update downloads spec.URL and writes it to spec.TargetPath relative
// to the instance. Optional SHA256 verification is reserved for a future
// `sha256:` field on the spec.
func (DirectProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
	if spec.URL == "" {
		return fmt.Errorf("direct provider: url is required")
	}
	if spec.TargetPath == "" {
		return fmt.Errorf("direct provider: target_path is required")
	}

	req, err := http.NewRequestWithContext(ctx, "GET", spec.URL, nil)
	if err != nil {
		return fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("User-Agent", version.UserAgent())

	client := &http.Client{Timeout: 5 * time.Minute}
	uc.Log(fmt.Sprintf("GET %s", spec.URL))
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("http: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return fmt.Errorf("http status %d", resp.StatusCode)
	}
	if resp.ContentLength > 0 {
		uc.Log(fmt.Sprintf("Content-Length: %d bytes", resp.ContentLength))
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectDownloadBytes+1))
	if err != nil {
		return fmt.Errorf("read body: %w", err)
	}
	if int64(len(data)) > maxDirectDownloadBytes {
		return fmt.Errorf("response exceeds %d-byte cap", maxDirectDownloadBytes)
	}
	sum := sha256.Sum256(data)
	uc.Log(fmt.Sprintf("downloaded %d bytes (sha256:%s)", len(data), hex.EncodeToString(sum[:])))

	// Archive handling. Some direct URLs serve a whole game tree as an
	// archive (factorio's headless .tar.xz) — dumping those bytes as a
	// single file at TargetPath is never what the module wants. Extract
	// when the module opted in (spec.Extract) or when the content is a
	// recognized archive AND TargetPath looks like a directory (no file
	// extension). A TargetPath with an extension is always written
	// verbatim: minecraft server.jar IS a zip by magic bytes, and
	// terraria's entrypoint expects its .zip left on disk.
	format := archivepkg.DetectFormat(data, spec.URL)
	if spec.Extract && format == "" {
		return fmt.Errorf("extract requested but content is not a recognized archive (url %s)", spec.URL)
	}
	if format != "" && (spec.Extract || path.Ext(path.Base(spec.TargetPath)) == "") {
		uc.Log(fmt.Sprintf("content is a %s archive — extracting into %s", format, spec.TargetPath))
		entries, bytesOut, err := extractToInstance(ctx, uc, spec.TargetPath, data, spec.URL)
		if err != nil {
			return fmt.Errorf("extract into %s: %w", spec.TargetPath, err)
		}
		uc.Log(fmt.Sprintf("extracted %d entries (%d bytes) into %s", entries, bytesOut, spec.TargetPath))
		return nil
	}

	if err := writeToInstance(ctx, uc, spec.TargetPath, data); err != nil {
		return fmt.Errorf("write target: %w", err)
	}
	uc.Log(fmt.Sprintf("wrote %s", spec.TargetPath))
	return nil
}

// containerTargetAbs resolves a provider target_path to a container-
// absolute path. An absolute target that is equal to or under one of
// the module's declared browseable roots or volume mount points is
// used as-is (multi-root modules: factorio's /game volume sits BESIDE
// its /game-saves default root). Anything else keeps the historical
// behavior of joining under BrowseableRoot — terraria's entrypoint,
// for one, expects /terraria-server.zip to land at
// /game-saves/terraria-server.zip via exactly that join.
func containerTargetAbs(uc *Context, targetPath string) string {
	if strings.HasPrefix(targetPath, "/") && uc.Manifest != nil {
		cleaned := path.Clean(targetPath)
		var roots []string
		if d := uc.Manifest.Runtime.Docker; d != nil {
			for _, r := range d.BrowseableRoots {
				if r.Path != "" {
					roots = append(roots, r.Path)
				}
			}
			for _, v := range d.Volumes {
				if v.Container != "" {
					roots = append(roots, v.Container)
				}
			}
		}
		for _, r := range roots {
			if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") {
				return cleaned
			}
		}
	}
	return path.Join(uc.BrowseableRoot, targetPath)
}

// extractToInstance expands archive `data` into the instance-relative
// directory targetPath. Mirrors writeToInstance's storage preference:
// container-path ops when the instance has a BrowseableRoot and a
// container (re-encode the archive as one tar stream and let Docker
// extract it in place — same pattern as the file manager's FsExtract),
// falling back to plain filesystem extraction under DataPath.
func extractToInstance(ctx context.Context, uc *Context, targetPath string, data []byte, originalName string) (int64, int64, error) {
	if targetPath == "" {
		return 0, 0, fmt.Errorf("target_path is required")
	}
	if uc.BrowseableRoot != "" && uc.ContainerID != "" {
		destAbs := containerTargetAbs(uc, targetPath)
		// Best-effort mkdir; only possible on a running container, and
		// the usual target is a volume mount point that already exists.
		_, _, _, _ = uc.Runtime.ExecCapture(ctx, uc.ContainerID, []string{"sh", "-c", "mkdir -p -- '" + destAbs + "'"})
		pr, pw := io.Pipe()
		emitErr := make(chan error, 1)
		var entries, bytesOut int64
		go func() {
			n, b, err := archivepkg.WriteAsTar(data, originalName, pw)
			entries, bytesOut = n, b
			_ = pw.CloseWithError(err)
			emitErr <- err
		}()
		if err := uc.Runtime.CopyTarToContainer(ctx, uc.ContainerID, destAbs, pr); err != nil {
			// Prefer the encoder's error when it carries archive-format
			// detail — but NOT when it's just the pipe slamming shut
			// because Docker aborted the copy (that would mask the real
			// error, e.g. "destination directory does not exist").
			if encErr := <-emitErr; encErr != nil && !errors.Is(encErr, io.ErrClosedPipe) {
				return entries, bytesOut, encErr
			}
			return entries, bytesOut, fmt.Errorf("copy to container %s: %w", destAbs, err)
		}
		if encErr := <-emitErr; encErr != nil {
			return entries, bytesOut, encErr
		}
		return entries, bytesOut, nil
	}
	if uc.DataPath == "" {
		return 0, 0, fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath")
	}
	destAbs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath))
	if err := os.MkdirAll(destAbs, 0o755); err != nil {
		return 0, 0, fmt.Errorf("mkdir %s: %w", destAbs, err)
	}
	return archivepkg.Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error {
		safe := archivepkg.SafeEntryPath(name)
		if safe == "" {
			return nil
		}
		out := filepath.Join(destAbs, filepath.FromSlash(safe))
		if isDir {
			return os.MkdirAll(out, 0o755)
		}
		if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
			return err
		}
		perm := os.FileMode(mode & 0o777)
		if perm == 0 {
			perm = 0o644
		}
		f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
		if err != nil {
			return err
		}
		if _, err := io.Copy(f, body); err != nil {
			_ = f.Close()
			return err
		}
		return f.Close()
	})
}
