// Command grabflow is a segmented, resumable HTTP download engine.
//
// grabflow is the download-engine core of the "GrabFlow" concept: a
// smart download manager. This prototype focuses on the genuinely
// CLI-native heart of that idea -- parallel range-based segmented
// downloading, resume support, and post-download checksum verification.
// Browser-capture integration and auto-unpack/file-rules from the full
// product concept require a browser extension and are out of scope for
// a CLI; see README.txt and ../plan.md for the roadmap.
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"
)

const version = "0.1.0"

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "get":
		cmdGet(os.Args[2:])
	case "version", "--version":
		fmt.Println("grabflow version " + version)
	default:
		fmt.Fprintf(os.Stderr, "grabflow: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `grabflow - segmented, resumable HTTP downloader (GrabFlow CLI prototype)

Usage:
  grabflow get <url> -o <outfile> [--segments N] [--sha256 EXPECTED_HASH] [--resume] [--timeout 30s]
  grabflow help
  grabflow version

Commands:
  get      Download a file, optionally in parallel segments, with resume
           support and post-download SHA-256 verification.

Flags for "get":
  -o FILE            Output file path (required)
  --segments N        Number of parallel range-based segments (default 1)
  --sha256 HASH       Expected SHA-256 hex digest to verify after download
  --resume            Resume from an existing <outfile>.part if possible
  --timeout DURATION  Per-HTTP-request timeout (default 30s)

Examples:
  grabflow get https://example.com/file.iso -o file.iso --segments 8
  grabflow get https://example.com/file.iso -o file.iso --resume
  grabflow get https://example.com/file.iso -o file.iso --sha256 abcd1234...
`)
}

// reorderFlags moves all flag tokens (and their values, for flags listed in
// valueFlags) to the front of args and all positional tokens to the back.
// This works around Go's flag package stopping parsing at the first
// positional argument -- grabflow's URL argument legitimately comes before
// its flags.
func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

// humanBytes formats a byte count as a human-readable IEC size string.
func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

func getUsage() {
	fmt.Fprint(os.Stderr, `Usage: grabflow get <url> -o <outfile> [--segments N] [--sha256 HASH] [--resume] [--timeout 30s]

  -o FILE            Output file path (required)
  --segments N        Number of parallel range-based segments (default 1)
  --sha256 HASH       Expected SHA-256 hex digest to verify after download
  --resume            Resume from an existing <outfile>.part if possible
  --timeout DURATION  Per-HTTP-request timeout (default 30s)
`)
}

func cmdGet(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			getUsage()
			return
		}
	}

	valueFlags := map[string]bool{"o": true, "segments": true, "sha256": true, "timeout": true}
	reordered := reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("get", flag.ExitOnError)
	fs.Usage = getUsage
	outFile := fs.String("o", "", "output file path")
	segmentsFlag := fs.Int("segments", 1, "number of parallel segments")
	sha256Flag := fs.String("sha256", "", "expected sha256 checksum (hex)")
	resumeFlag := fs.Bool("resume", false, "resume partial download if possible")
	timeoutFlag := fs.Duration("timeout", 30*time.Second, "per-request timeout")
	fs.Parse(reordered)

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "grabflow get: missing <url>")
		getUsage()
		os.Exit(1)
	}
	url := positional[0]

	if *outFile == "" {
		fmt.Fprintln(os.Stderr, "grabflow get: -o <outfile> is required")
		getUsage()
		os.Exit(1)
	}

	if err := runGet(url, *outFile, *segmentsFlag, *sha256Flag, *resumeFlag, *timeoutFlag); err != nil {
		fmt.Fprintf(os.Stderr, "grabflow get: %v\n", err)
		os.Exit(1)
	}
}

func runGet(rawURL, outFile string, segments int, expectedSHA string, resume bool, timeout time.Duration) error {
	client := &http.Client{Timeout: timeout}
	partPath := outFile + ".part"

	length, rangesSupported, err := probeServer(client, rawURL)
	if err != nil {
		return fmt.Errorf("probing server: %w", err)
	}

	if length >= 0 {
		fmt.Printf("Server reports content length: %s\n", humanBytes(length))
	} else {
		fmt.Println("Server did not report a content length (unknown size)")
	}
	if rangesSupported {
		fmt.Println("Server supports byte-range requests (confirmed via 206 Partial Content)")
	} else {
		fmt.Println("Server does NOT support byte-range requests; segmented/resume downloading is unavailable")
	}

	var resumeOffset int64
	if resume {
		if info, statErr := os.Stat(partPath); statErr == nil {
			if rangesSupported {
				resumeOffset = info.Size()
				fmt.Printf("Resuming: found existing %s (%s), continuing from that offset\n", partPath, humanBytes(resumeOffset))
			} else {
				fmt.Println("Resume requested but server does not support byte ranges; restarting download from scratch")
				if rmErr := os.Remove(partPath); rmErr != nil && !os.IsNotExist(rmErr) {
					return fmt.Errorf("removing stale partial file: %w", rmErr)
				}
			}
		}
	}

	if segments < 1 {
		segments = 1
	}
	if length > 0 && int64(segments) > length {
		segments = int(length)
	}

	useSegmented := resumeOffset == 0 && segments > 1 && rangesSupported && length > 0
	if segments > 1 && !useSegmented && resumeOffset == 0 {
		reason := "server does not support ranges or content length is unknown"
		fmt.Printf("Cannot use %d segments (%s); falling back to single-stream download\n", segments, reason)
	}
	if segments > 1 && resumeOffset > 0 {
		fmt.Println("Segmented download is not combined with resume; falling back to single-stream resume")
	}

	segmentsUsed := 1
	start := time.Now()
	var totalBytes int64

	if useSegmented {
		segmentsUsed = segments
		fmt.Printf("Downloading %s in %d parallel segments...\n", humanBytes(length), segments)
		if err := downloadSegmented(client, rawURL, partPath, length, segments); err != nil {
			return fmt.Errorf("segmented download failed: %w", err)
		}
		totalBytes = length
	} else {
		if resumeOffset > 0 {
			fmt.Println("Downloading remainder as a single stream...")
			n, err := downloadSingle(client, rawURL, partPath, resumeOffset)
			if err != nil {
				return fmt.Errorf("resumed download failed: %w", err)
			}
			totalBytes = resumeOffset + n
		} else {
			fmt.Println("Downloading as a single stream...")
			n, err := downloadSingle(client, rawURL, partPath, 0)
			if err != nil {
				return fmt.Errorf("download failed: %w", err)
			}
			totalBytes = n
		}
	}

	elapsed := time.Since(start)

	info, err := os.Stat(partPath)
	if err != nil {
		return fmt.Errorf("stat partial file after download: %w", err)
	}
	actualSize := info.Size()
	if length >= 0 && actualSize != length {
		return fmt.Errorf("downloaded size %d does not match expected content length %d; leaving %s in place", actualSize, length, partPath)
	}

	checksumResult := "not requested"
	if expectedSHA != "" {
		sum, err := sha256File(partPath)
		if err != nil {
			return fmt.Errorf("computing sha256 of downloaded file: %w", err)
		}
		if !strings.EqualFold(sum, expectedSHA) {
			fmt.Printf("\nChecksum MISMATCH:\n  expected: %s\n  actual:   %s\n", strings.ToLower(expectedSHA), sum)
			fmt.Printf("Partial file left at %s (NOT renamed to %s)\n", partPath, outFile)
			return fmt.Errorf("sha256 mismatch")
		}
		checksumResult = "OK (" + sum + ")"
	}

	if err := os.Rename(partPath, outFile); err != nil {
		return fmt.Errorf("renaming %s to %s: %w", partPath, outFile, err)
	}

	speed := float64(0)
	if elapsed.Seconds() > 0 {
		speed = float64(totalBytes) / elapsed.Seconds() / (1024 * 1024)
	}

	fmt.Printf(`
Download complete: %s
  Bytes downloaded: %d (%s)
  Elapsed:          %s
  Throughput:       %.2f MiB/s
  Segments used:    %d
  SHA-256 check:    %s
`, outFile, totalBytes, humanBytes(totalBytes), elapsed.Round(time.Millisecond), speed, segmentsUsed, checksumResult)

	return nil
}

// probeServer issues a HEAD request (best effort, for Content-Length) and a
// confirmatory Range: bytes=0-0 GET request (the reliable signal, since some
// servers honor ranges without advertising Accept-Ranges) to determine the
// total content length and whether byte-range requests are supported.
func probeServer(client *http.Client, rawURL string) (length int64, ranges bool, err error) {
	length = -1

	headReq, herr := http.NewRequest(http.MethodHead, rawURL, nil)
	var headErr error
	if herr != nil {
		headErr = herr
	} else {
		resp, doErr := client.Do(headReq)
		if doErr != nil {
			headErr = doErr
		} else {
			io.Copy(io.Discard, resp.Body)
			resp.Body.Close()
			if resp.ContentLength >= 0 {
				length = resp.ContentLength
			}
		}
	}

	rangeReq, rerr := http.NewRequest(http.MethodGet, rawURL, nil)
	if rerr != nil {
		return length, false, fmt.Errorf("building probe request: %w", rerr)
	}
	rangeReq.Header.Set("Range", "bytes=0-0")
	resp, doErr := client.Do(rangeReq)
	if doErr != nil {
		if headErr != nil {
			return length, false, fmt.Errorf("HEAD probe: %v; GET range probe: %v", headErr, doErr)
		}
		// HEAD succeeded but the confirmatory range probe failed outright;
		// treat as "ranges not supported" rather than a hard error.
		return length, false, nil
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, resp.Body)

	if resp.StatusCode == http.StatusPartialContent {
		ranges = true
		if length < 0 {
			if cr := resp.Header.Get("Content-Range"); cr != "" {
				if idx := strings.LastIndex(cr, "/"); idx != -1 {
					if v, perr := strconv.ParseInt(cr[idx+1:], 10, 64); perr == nil {
						length = v
					}
				}
			}
		}
	} else {
		ranges = false
		if length < 0 && resp.ContentLength >= 0 {
			length = resp.ContentLength
		}
	}

	return length, ranges, nil
}

// downloadSegmented downloads [0, length) from url in `segments` roughly
// equal, concurrently-fetched byte ranges, each written directly to its
// offset in partPath via a per-goroutine offset writer over a single shared
// file handle (the standard technique used by real download managers).
func downloadSegmented(client *http.Client, rawURL, partPath string, length int64, segments int) error {
	f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return fmt.Errorf("creating %s: %w", partPath, err)
	}
	if err := f.Truncate(length); err != nil {
		f.Close()
		return fmt.Errorf("sizing %s to %d bytes: %w", partPath, length, err)
	}

	segSize := length / int64(segments)
	var wg sync.WaitGroup
	errCh := make(chan error, segments)

	for i := 0; i < segments; i++ {
		start := int64(i) * segSize
		end := start + segSize - 1
		if i == segments-1 {
			end = length - 1
		}
		wg.Add(1)
		go func(start, end int64) {
			defer wg.Done()
			if err := downloadRange(client, rawURL, f, start, end); err != nil {
				errCh <- fmt.Errorf("segment [%d-%d]: %w", start, end, err)
			}
		}(start, end)
	}

	wg.Wait()
	close(errCh)
	closeErr := f.Close()

	for e := range errCh {
		if e != nil {
			return e
		}
	}
	if closeErr != nil {
		return fmt.Errorf("closing %s: %w", partPath, closeErr)
	}
	return nil
}

// downloadRange fetches the inclusive byte range [start, end] from rawURL
// and writes it to f at offset start.
func downloadRange(client *http.Client, rawURL string, f *os.File, start, end int64) error {
	req, err := http.NewRequest(http.MethodGet, rawURL, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))

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

	if resp.StatusCode != http.StatusPartialContent {
		return fmt.Errorf("expected HTTP 206 Partial Content, got %d", resp.StatusCode)
	}

	sw := io.NewOffsetWriter(f, start)
	n, err := io.Copy(sw, resp.Body)
	if err != nil {
		return err
	}
	expected := end - start + 1
	if n != expected {
		return fmt.Errorf("wrote %d bytes, expected %d", n, expected)
	}
	return nil
}

// downloadSingle performs a plain sequential download. If resumeFrom > 0 it
// issues a Range: bytes=resumeFrom- request and appends to the existing
// partial file; otherwise it truncates and writes from scratch. It returns
// the number of bytes written by this call (not including any pre-existing
// bytes when resuming).
func downloadSingle(client *http.Client, rawURL, partPath string, resumeFrom int64) (int64, error) {
	req, err := http.NewRequest(http.MethodGet, rawURL, nil)
	if err != nil {
		return 0, err
	}

	var f *os.File
	if resumeFrom > 0 {
		req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeFrom))
		f, err = os.OpenFile(partPath, os.O_WRONLY|os.O_APPEND, 0644)
	} else {
		f, err = os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	}
	if err != nil {
		return 0, fmt.Errorf("opening %s: %w", partPath, err)
	}
	defer f.Close()

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

	if resumeFrom > 0 && resp.StatusCode != http.StatusPartialContent {
		return 0, fmt.Errorf("server did not honor resume range request (status %d)", resp.StatusCode)
	}
	if resumeFrom == 0 && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
		return 0, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
	}

	n, err := io.Copy(f, resp.Body)
	if err != nil {
		return n, err
	}
	return n, nil
}

func sha256File(path string) (string, error) {
	f, err := os.Open(path)
	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
}
