// Command downloadpilot is a persistent, concurrent download queue.
//
// downloadpilot is the "Pro" tier of the same product line as the sibling
// "GrabFlow" tool. Where GrabFlow downloads exactly one URL per invocation
// with segmented/resumable transfer, downloadpilot's distinct job is a
// persistent, crash-survivable job QUEUE: you hand it many URLs (added over
// time or all at once), it works through them with a configurable number of
// concurrent workers, and it tracks each item's status (queued/downloading/
// done/failed) in a JSON queue file that is rewritten to disk after every
// single item finishes -- so the whole queue survives the process being
// killed partway through and can be resumed later with another `run`, and
// failed items can be retried in isolation with --retry-failed. Browser-
// capture integration and true per-item segmented downloading are on the
// roadmap; see README.txt and ../plan.md for the full product plan.
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"text/tabwriter"
	"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
		// one question 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 "add":
		cmdAdd(os.Args[2:])
	case "run":
		cmdRun(os.Args[2:])
	case "status":
		cmdStatus(os.Args[2:])
	case "version", "--version":
		fmt.Println("downloadpilot version " + version)
	default:
		fmt.Fprintf(os.Stderr, "downloadpilot: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `downloadpilot - persistent, concurrent download queue (DownloadPilot CLI prototype)

Usage:
  downloadpilot add --queue <file> <url> --out <file> [--sha256 HASH]
  downloadpilot run --queue <file> [--workers N] [--retry-failed]
  downloadpilot status --queue <file> [--json]
  downloadpilot help
  downloadpilot version

Commands:
  add     Append one item to a queue file (creates it if missing).
  run     Process all queued (and, with --retry-failed, failed) items
          using a pool of concurrent workers. Saves the whole queue to
          disk after every item finishes, so it can survive being
          interrupted and resumed with another "run".
  status  Print the current status of every item in a queue file.
          Read-only; does not download anything.

Examples:
  downloadpilot add --queue q.json https://example.com/a.bin --out a.bin
  downloadpilot add --queue q.json https://example.com/b.bin --out b.bin --sha256 abcd1234...
  downloadpilot run --queue q.json --workers 4
  downloadpilot run --queue q.json --retry-failed
  downloadpilot status --queue q.json
`)
}

// 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.
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])
}

// ---------------------------------------------------------------------
// Queue model and persistence
// ---------------------------------------------------------------------

const (
	statusQueued      = "queued"
	statusDownloading = "downloading"
	statusDone        = "done"
	statusFailed      = "failed"
)

type Item struct {
	ID     string `json:"id"`
	URL    string `json:"url"`
	Out    string `json:"out"`
	SHA256 string `json:"sha256"`
	Status string `json:"status"`
	Error  string `json:"error,omitempty"`
}

type Queue struct {
	Items []*Item `json:"items"`
}

// loadQueue reads a queue file. If the file does not exist and
// allowMissing is true, an empty queue is returned instead of an error.
func loadQueue(path string, allowMissing bool) (*Queue, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) && allowMissing {
			return &Queue{}, nil
		}
		return nil, fmt.Errorf("reading queue file %s: %w", path, err)
	}
	if len(data) == 0 {
		return &Queue{}, nil
	}
	var q Queue
	if err := json.Unmarshal(data, &q); err != nil {
		return nil, fmt.Errorf("parsing queue file %s: %w", path, err)
	}
	return &q, nil
}

// saveQueue writes the queue to path atomically: it marshals to a sibling
// ".tmp" file and renames it into place, so a process killed mid-write
// leaves either the old complete file or the new complete file, never a
// half-written queue file.
func saveQueue(path string, q *Queue) error {
	data, err := json.MarshalIndent(q, "", "  ")
	if err != nil {
		return fmt.Errorf("encoding queue: %w", err)
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0644); err != nil {
		return fmt.Errorf("writing %s: %w", tmp, err)
	}
	if err := os.Rename(tmp, path); err != nil {
		return fmt.Errorf("renaming %s to %s: %w", tmp, path, err)
	}
	return nil
}

// nextID returns a simple incrementing string id, one greater than the
// highest existing numeric id in the queue (or "1" for an empty queue).
func nextID(q *Queue) string {
	max := 0
	for _, it := range q.Items {
		if n, err := strconv.Atoi(it.ID); err == nil && n > max {
			max = n
		}
	}
	return strconv.Itoa(max + 1)
}

// ---------------------------------------------------------------------
// add
// ---------------------------------------------------------------------

func addUsage() {
	fmt.Fprint(os.Stderr, `Usage: downloadpilot add --queue <file> <url> --out <file> [--sha256 HASH]

  --queue FILE   Queue file to append to (created if it doesn't exist)
  --out FILE     Output file path for this item (required)
  --sha256 HASH  Expected SHA-256 hex digest for this item (optional)
`)
}

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

	valueFlags := map[string]bool{"queue": true, "out": true, "sha256": true}
	reordered := reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("add", flag.ExitOnError)
	fs.Usage = addUsage
	queuePath := fs.String("queue", "", "queue file path")
	outFile := fs.String("out", "", "output file path")
	sha256Flag := fs.String("sha256", "", "expected sha256 checksum (hex)")
	fs.Parse(reordered)

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

	if *queuePath == "" {
		fmt.Fprintln(os.Stderr, "downloadpilot add: --queue <file> is required")
		addUsage()
		os.Exit(1)
	}
	if *outFile == "" {
		fmt.Fprintln(os.Stderr, "downloadpilot add: --out <file> is required")
		addUsage()
		os.Exit(1)
	}

	q, err := loadQueue(*queuePath, true)
	if err != nil {
		fmt.Fprintf(os.Stderr, "downloadpilot add: %v\n", err)
		os.Exit(1)
	}

	item := &Item{
		ID:     nextID(q),
		URL:    url,
		Out:    *outFile,
		SHA256: *sha256Flag,
		Status: statusQueued,
	}
	q.Items = append(q.Items, item)

	if err := saveQueue(*queuePath, q); err != nil {
		fmt.Fprintf(os.Stderr, "downloadpilot add: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Added item %s: %s -> %s (status: queued)\n", item.ID, item.URL, item.Out)
}

// ---------------------------------------------------------------------
// run
// ---------------------------------------------------------------------

func runUsage() {
	fmt.Fprint(os.Stderr, `Usage: downloadpilot run --queue <file> [--workers N] [--retry-failed]

  --queue FILE     Queue file to process (required)
  --workers N      Number of concurrent download workers (default 4)
  --retry-failed   Also (re)process items currently marked "failed"
`)
}

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

	valueFlags := map[string]bool{"queue": true, "workers": true}
	reordered := reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("run", flag.ExitOnError)
	fs.Usage = runUsage
	queuePath := fs.String("queue", "", "queue file path")
	workers := fs.Int("workers", 4, "number of concurrent workers")
	retryFailed := fs.Bool("retry-failed", false, "also retry failed items")
	fs.Parse(reordered)

	if *queuePath == "" {
		fmt.Fprintln(os.Stderr, "downloadpilot run: --queue <file> is required")
		runUsage()
		os.Exit(1)
	}
	if *workers < 1 {
		*workers = 1
	}

	if err := runQueue(*queuePath, *workers, *retryFailed); err != nil {
		fmt.Fprintf(os.Stderr, "downloadpilot run: %v\n", err)
		os.Exit(1)
	}
}

func runQueue(queuePath string, workers int, retryFailed bool) error {
	q, err := loadQueue(queuePath, false)
	if err != nil {
		return err
	}

	var toProcess []*Item
	for _, it := range q.Items {
		switch {
		case it.Status == statusQueued:
			toProcess = append(toProcess, it)
		case it.Status == statusDownloading:
			// A "downloading" status found on load can only mean a previous
			// run was killed while this item was in flight (downloadpilot
			// never persists that status as a deliberate final state) --
			// always pick such items back up, same as "queued", regardless
			// of --retry-failed.
			toProcess = append(toProcess, it)
		case retryFailed && it.Status == statusFailed:
			toProcess = append(toProcess, it)
		}
	}

	if len(toProcess) == 0 {
		fmt.Println("Nothing to do: no queued items" + retrySuffix(retryFailed))
		printSummary(q)
		return nil
	}

	fmt.Printf("Processing %d item(s) with %d worker(s)...\n", len(toProcess), workers)

	client := &http.Client{Timeout: 60 * time.Second}

	var saveMu sync.Mutex // guards writes to the on-disk queue file and stdout progress lines
	work := make(chan *Item)
	var wg sync.WaitGroup

	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for it := range work {
				saveMu.Lock()
				it.Status = statusDownloading // in-memory only; never persisted mid-flight
				saveMu.Unlock()

				n, err := downloadItem(client, it)

				saveMu.Lock()
				if err != nil {
					it.Status = statusFailed
					it.Error = err.Error()
					fmt.Printf("[failed]  id=%s  %s (%v)\n", it.ID, it.URL, err)
				} else {
					it.Status = statusDone
					it.Error = ""
					fmt.Printf("[done]    id=%s  %s -> %s (%s)\n", it.ID, it.URL, it.Out, humanBytes(n))
				}
				if saveErr := saveQueue(queuePath, q); saveErr != nil {
					fmt.Fprintf(os.Stderr, "downloadpilot run: warning: failed to save queue after item %s: %v\n", it.ID, saveErr)
				}
				saveMu.Unlock()
			}
		}()
	}

	for _, it := range toProcess {
		work <- it
	}
	close(work)
	wg.Wait()

	fmt.Println()
	printSummary(q)
	return nil
}

func retrySuffix(retryFailed bool) string {
	if retryFailed {
		return " or failed items"
	}
	return ""
}

func printSummary(q *Queue) {
	var done, failed, queued, other int
	for _, it := range q.Items {
		switch it.Status {
		case statusDone:
			done++
		case statusFailed:
			failed++
		case statusQueued:
			queued++
		default:
			other++
		}
	}
	fmt.Printf("Summary: %d done, %d failed, %d queued", done, failed, queued)
	if other > 0 {
		fmt.Printf(", %d other", other)
	}
	fmt.Println()
}

// downloadItem downloads a single queue item to a ".part" file, verifies
// its SHA-256 if one was provided, and only on success renames it to its
// final Out path. On any failure the ".part" file (if any) is removed so
// no stray partial file is left behind at a non-final name, and nothing is
// ever left at the final Out path unless the download fully succeeded.
func downloadItem(client *http.Client, it *Item) (int64, error) {
	if it.Out == "" {
		return 0, fmt.Errorf("item has no output path")
	}
	if dir := filepath.Dir(it.Out); dir != "." && dir != "" {
		if err := os.MkdirAll(dir, 0755); err != nil {
			return 0, fmt.Errorf("creating output directory %s: %w", dir, err)
		}
	}

	partPath := it.Out + ".part"

	req, err := http.NewRequest(http.MethodGet, it.URL, nil)
	if err != nil {
		return 0, fmt.Errorf("building request: %w", err)
	}

	resp, err := client.Do(req)
	if err != nil {
		return 0, fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		io.Copy(io.Discard, resp.Body)
		return 0, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
	}

	f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	if err != nil {
		return 0, fmt.Errorf("creating %s: %w", partPath, err)
	}
	n, copyErr := io.Copy(f, resp.Body)
	closeErr := f.Close()
	if copyErr != nil {
		os.Remove(partPath)
		return 0, fmt.Errorf("downloading body: %w", copyErr)
	}
	if closeErr != nil {
		os.Remove(partPath)
		return 0, fmt.Errorf("closing %s: %w", partPath, closeErr)
	}
	if resp.ContentLength >= 0 && n != resp.ContentLength {
		os.Remove(partPath)
		return 0, fmt.Errorf("downloaded %d bytes, expected %d", n, resp.ContentLength)
	}

	if it.SHA256 != "" {
		sum, err := sha256File(partPath)
		if err != nil {
			os.Remove(partPath)
			return 0, fmt.Errorf("computing sha256: %w", err)
		}
		if !strings.EqualFold(sum, it.SHA256) {
			os.Remove(partPath)
			return 0, fmt.Errorf("sha256 mismatch: expected %s, got %s", strings.ToLower(it.SHA256), sum)
		}
	}

	if err := os.Rename(partPath, it.Out); err != nil {
		return 0, fmt.Errorf("renaming %s to %s: %w", partPath, it.Out, 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
}

// ---------------------------------------------------------------------
// status
// ---------------------------------------------------------------------

func statusUsage() {
	fmt.Fprint(os.Stderr, `Usage: downloadpilot status --queue <file> [--json]

  --queue FILE  Queue file to inspect (required)
  --json        Print machine-readable JSON instead of a table
`)
}

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

	valueFlags := map[string]bool{"queue": true}
	reordered := reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("status", flag.ExitOnError)
	fs.Usage = statusUsage
	queuePath := fs.String("queue", "", "queue file path")
	jsonOut := fs.Bool("json", false, "print JSON output")
	fs.Parse(reordered)

	if *queuePath == "" {
		fmt.Fprintln(os.Stderr, "downloadpilot status: --queue <file> is required")
		statusUsage()
		os.Exit(1)
	}

	q, err := loadQueue(*queuePath, false)
	if err != nil {
		fmt.Fprintf(os.Stderr, "downloadpilot status: %v\n", err)
		os.Exit(1)
	}

	if *jsonOut {
		var done, failed, queued, downloading int
		for _, it := range q.Items {
			switch it.Status {
			case statusDone:
				done++
			case statusFailed:
				failed++
			case statusQueued:
				queued++
			case statusDownloading:
				downloading++
			}
		}
		out := struct {
			Items  []*Item `json:"items"`
			Counts struct {
				Done        int `json:"done"`
				Failed      int `json:"failed"`
				Queued      int `json:"queued"`
				Downloading int `json:"downloading"`
			} `json:"counts"`
		}{Items: q.Items}
		out.Counts.Done = done
		out.Counts.Failed = failed
		out.Counts.Queued = queued
		out.Counts.Downloading = downloading

		data, _ := json.MarshalIndent(out, "", "  ")
		fmt.Println(string(data))
		return
	}

	if len(q.Items) == 0 {
		fmt.Println("Queue is empty.")
		return
	}

	tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
	fmt.Fprintln(tw, "ID\tSTATUS\tURL\tOUT")
	for _, it := range q.Items {
		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", it.ID, it.Status, it.URL, it.Out)
	}
	tw.Flush()

	printSummary(q)
}
