package cli

import (
	"fmt"
	"image"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"github.com/tamnd/kage/clone"
	"github.com/tamnd/kage/pack"
)

// packFlags holds the parsed flags for one invocation of kage pack.
type packFlags struct {
	format      string
	out         string
	base        string
	app         bool
	icon        string
	noCompress  bool
	title       string
	description string
	language    string
	date        string
	incremental bool
}

// cacheSuffix names the cluster-cache sidecar kage writes next to a packed
// artifact when --incremental is set.
const cacheSuffix = ".kagecache"

func newPackCmd() *cobra.Command {
	f := &packFlags{}
	cmd := &cobra.Command{
		Use:   "pack <mirror-dir>",
		Short: "Pack a cloned mirror into a ZIM file or a self-contained viewer",
		Long: "pack turns a cloned folder into one distributable file. With --format zim\n" +
			"it writes an open ZIM archive (the format Kiwix uses) that kage open or any\n" +
			"ZIM reader can browse. With --format binary it appends that archive to a copy\n" +
			"of kage, producing a single executable that serves the site offline when run.\n" +
			"Add --app to wrap that executable in a double-click desktop app (a .app bundle\n" +
			"on macOS, an AppImage-style .AppDir on Linux) with the site's favicon as the icon.\n" +
			"Add --incremental to keep a cache sidecar so re-packing a mirror only compresses\n" +
			"the clusters that changed, not the whole archive.",
		Args: cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			return runPack(args[0], f)
		},
	}
	fs := cmd.Flags()
	fs.StringVar(&f.format, "format", "zim", "output format: zim or binary")
	fs.StringVarP(&f.out, "out", "o", "", "output path (default per format)")
	fs.StringVar(&f.base, "base", "", "base kage binary for the viewer (default this kage)")
	fs.BoolVar(&f.app, "app", false, "wrap the viewer in a double-click desktop app (.app on macOS, .AppImage/.AppDir on Linux)")
	fs.StringVar(&f.icon, "icon", "", "icon file for --app (default the site's favicon)")
	fs.BoolVar(&f.noCompress, "no-compress", false, "store every cluster raw, no zstd")
	fs.BoolVar(&f.incremental, "incremental", false, "reuse compression from a cache sidecar so re-packing a mirror only compresses what changed")
	fs.StringVar(&f.title, "title", "", "archive title (default the main page's <title>)")
	fs.StringVar(&f.description, "description", "", "archive description")
	fs.StringVar(&f.language, "language", "eng", "archive language code")
	fs.StringVar(&f.date, "date", time.Now().UTC().Format("2006-01-02"), "archive date (YYYY-MM-DD)")
	return cmd
}

func runPack(mirrorArg string, f *packFlags) error {
	dir := resolveMirror(mirrorArg)
	zopts := pack.ZIMOptions{
		Out:         f.out,
		NoCompress:  f.noCompress,
		Title:       f.title,
		Description: f.description,
		Language:    f.language,
		Date:        f.date,
		Version:     Version,
	}

	// --app wraps the packed viewer in a desktop bundle. It builds on the binary
	// format, so it owns the flow rather than being one more --format value.
	if f.app {
		return runPackApp(dir, f, zopts)
	}

	switch f.format {
	case "zim":
		out := f.out
		if out == "" {
			out = filepath.Base(dir) + ".zim"
		}
		zopts.Out = out
		var st pack.PackStats
		if f.incremental {
			zopts.CachePath = out + cacheSuffix
			zopts.Stats = &st
		}
		outPath, size, err := pack.BuildZIM(dir, zopts)
		if err != nil {
			return err
		}
		printPackResult(outPath, size)
		printCacheLine(f.incremental, st)
		fmt.Fprintf(os.Stderr, "  open %s\n", styleAccent.Render("kage open "+outPath))
		return nil

	case "binary":
		target := resolveTargetOS(f.base)
		out := f.out
		if out == "" {
			out = defaultBinaryName(dir)
		}
		// A Windows viewer must end in .exe to run, whether the name came from
		// --out or the default, so make sure it does.
		if target == "windows" && !strings.HasSuffix(strings.ToLower(out), ".exe") {
			out += ".exe"
		}
		var st pack.PackStats
		if f.incremental {
			zopts.CachePath = out + cacheSuffix
			zopts.Stats = &st
		}
		zbytes, err := pack.BuildZIMBytes(dir, zopts)
		if err != nil {
			return err
		}
		path, size, err := pack.BuildBinary(zbytes, pack.BinaryOptions{Out: out, Base: f.base})
		if err != nil {
			return err
		}
		printPackResult(path, size)
		printCacheLine(f.incremental, st)
		printRunHint(path, target)
		return nil

	default:
		return fmt.Errorf("unknown --format %q (want zim or binary)", f.format)
	}
}

// runPackApp builds a double-clickable desktop app around the packed viewer,
// shaped for whichever OS the base targets: a .app bundle on macOS, an
// AppImage-style .AppDir on Linux. Windows needs no bundle (the .exe is the
// app), so it is redirected to --format binary with a GUI base.
func runPackApp(dir string, f *packFlags, zopts pack.ZIMOptions) error {
	target := resolveTargetOS(f.base)
	switch target {
	case "windows":
		return fmt.Errorf("a Windows app is just the .exe, with no bundle to build: use --format binary and a GUI base (kage built with -ldflags -H=windowsgui)")
	case "":
		if f.base != "" {
			return fmt.Errorf("--app could not tell which OS %q is for; pass a macOS or Linux kage as --base", f.base)
		}
		// No base and an unknown runtime: fall through with the host's GOOS.
		target = runtime.GOOS
	}

	prog := defaultBinaryName(dir)
	name := f.title
	if name == "" {
		name = prog
	}
	icon, iconSrc, err := resolveIcon(dir, f.icon)
	if err != nil {
		return err
	}
	var st pack.PackStats
	if f.incremental {
		zopts.CachePath = prog + cacheSuffix
		zopts.Stats = &st
	}
	zbytes, err := pack.BuildZIMBytes(dir, zopts)
	if err != nil {
		return err
	}
	printCacheLine(f.incremental, st)

	switch target {
	case "darwin":
		return packMacApp(zbytes, dir, f, prog, name, icon, iconSrc)
	case "linux":
		return packLinuxApp(zbytes, dir, f, prog, name, icon, iconSrc)
	default:
		return fmt.Errorf("--app supports macOS and Linux bases; %s is not one of them", osLabel(target))
	}
}

// packMacApp writes the .app bundle and prints how to launch it.
func packMacApp(zbytes []byte, dir string, f *packFlags, prog, name string, icon image.Image, iconSrc string) error {
	out := f.out
	if out == "" {
		out = prog + ".app"
	} else if !strings.HasSuffix(strings.ToLower(out), ".app") {
		out += ".app"
	}
	path, size, err := pack.BuildApp(zbytes, pack.AppOptions{
		Out:        out,
		Base:       f.base,
		Name:       name,
		ExecName:   prog,
		Identifier: bundleID(prog),
		Version:    appVersion(),
		Icon:       icon,
	})
	if err != nil {
		return err
	}
	printPackResult(path, size)
	printIconLine(iconSrc)
	fmt.Fprintf(os.Stderr, "  double-click %s to open the site offline\n", styleAccent.Render(filepath.Base(path)))
	if f.base == "" {
		fmt.Fprintln(os.Stderr, styleDim.Render("  (built around this kage; pass --base a webview build to open a native window instead of the browser)"))
	}
	fmt.Fprintln(os.Stderr, styleDim.Render("  (macOS may quarantine it: xattr -dr com.apple.quarantine "+path+")"))
	return nil
}

// packLinuxApp writes the .AppDir and, when appimagetool is installed, folds it
// into a single double-clickable .AppImage.
func packLinuxApp(zbytes []byte, dir string, f *packFlags, prog, name string, icon image.Image, iconSrc string) error {
	out := f.out
	if out == "" {
		out = prog + ".AppDir"
	} else if !strings.HasSuffix(out, ".AppDir") {
		out += ".AppDir"
	}
	path, size, hasIcon, err := pack.BuildAppDir(zbytes, pack.LinuxAppOptions{
		Out:      out,
		Base:     f.base,
		Name:     name,
		ExecName: prog,
		Comment:  f.description,
		Version:  appVersion(),
		Icon:     icon,
	})
	if err != nil {
		return err
	}
	printPackResult(path, size)
	printIconLine(iconSrc)

	// appimagetool turns the directory into one portable file. It needs an icon,
	// so only attempt it when the mirror gave us one.
	if hasIcon {
		if img, ok := tryAppImage(path, prog); ok {
			fmt.Fprintf(os.Stderr, "  built %s\n", styleTitle.Render(img))
			fmt.Fprintf(os.Stderr, "  double-click %s to open the site offline\n", styleAccent.Render(filepath.Base(img)))
			return nil
		}
	}
	fmt.Fprintf(os.Stderr, "  run %s to open the site offline\n", styleAccent.Render("./"+filepath.Join(filepath.Base(path), "AppRun")))
	fmt.Fprintln(os.Stderr, styleDim.Render("  (install appimagetool to fold this .AppDir into one double-clickable .AppImage)"))
	return nil
}

// tryAppImage runs appimagetool over the AppDir if it is installed, returning
// the .AppImage path on success. A missing tool or a build failure is not fatal:
// the caller falls back to the AppDir.
func tryAppImage(appDir, prog string) (string, bool) {
	tool, err := exec.LookPath("appimagetool")
	if err != nil {
		return "", false
	}
	out := prog + ".AppImage"
	cmd := exec.Command(tool, appDir, out)
	// appimagetool reads the target arch from the AppRun ELF; suppress its noisy
	// progress so kage's own output stays clean, but surface a real failure.
	if err := cmd.Run(); err != nil {
		return "", false
	}
	if _, err := os.Stat(out); err != nil {
		return "", false
	}
	return out, true
}

func printIconLine(iconSrc string) {
	if iconSrc != "" {
		fmt.Fprintf(os.Stderr, "  icon %s\n", styleDim.Render(iconSrc))
	}
}

// resolveIcon picks the bundle icon: an explicit --icon path if given (an error
// there is fatal, since the user asked for that file), otherwise the site's
// favicon discovered in the mirror. A mirror with no usable icon is fine; the
// bundle just ships without a custom one.
func resolveIcon(dir, iconFlag string) (img image.Image, src string, err error) {
	if iconFlag != "" {
		img, err = pack.DecodeIcon(iconFlag)
		if err != nil {
			return nil, "", err
		}
		return img, iconFlag, nil
	}
	if img, src, ok := pack.FindIcon(dir); ok {
		return img, src, nil
	}
	return nil, "", nil
}

// bundleID builds a reverse-DNS CFBundleIdentifier from the program name,
// keeping only characters Apple allows in an identifier.
func bundleID(prog string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(prog) {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
			b.WriteRune(r)
		default:
			b.WriteRune('-')
		}
	}
	id := strings.Trim(b.String(), "-")
	if id == "" {
		id = "app"
	}
	return "com.kage." + id
}

// appVersion uses kage's own version for the bundle, falling back to 1.0 for a
// dev build whose version is the default "dev".
func appVersion() string {
	if Version == "" || Version == "dev" {
		return "1.0"
	}
	return strings.TrimPrefix(Version, "v")
}

// resolveMirror accepts either a path to a mirror dir or a bare host. A bare
// host that is not a directory in the working dir is resolved against the
// default out dir, so "kage pack paulgraham.com" works right after a clone.
func resolveMirror(arg string) string {
	if info, err := os.Stat(arg); err == nil && info.IsDir() {
		return arg
	}
	candidate := filepath.Join(clone.DefaultOutDir(), arg)
	if info, err := os.Stat(candidate); err == nil && info.IsDir() {
		return candidate
	}
	return arg
}

// defaultBinaryName derives a clean program name from the mirror's host by
// stripping a trailing dot-suffix (paulgraham.com -> paulgraham). The caller
// appends .exe for Windows targets.
func defaultBinaryName(dir string) string {
	host := filepath.Base(dir)
	if i := strings.IndexByte(host, '.'); i > 0 {
		return host[:i]
	}
	return host
}

// resolveTargetOS reports which OS the packed viewer will run on. With no
// --base it is this kage's OS; with one, we sniff the base's executable header
// so detection does not hinge on the file being named ".exe". If the header is
// unrecognised we fall back to that name heuristic.
func resolveTargetOS(base string) string {
	if base == "" {
		return runtime.GOOS
	}
	if os := pack.SniffOS(base); os != "" {
		return os
	}
	if strings.HasSuffix(strings.ToLower(base), ".exe") {
		return "windows"
	}
	return ""
}

func printPackResult(path string, size int64) {
	fmt.Fprintln(os.Stderr, styleOK.Render("packed")+" "+styleTitle.Render(path))
	fmt.Fprintf(os.Stderr, "  %s %s\n", styleAccent.Render("size"), humanBytes(size))
}

// printCacheLine reports how much compression the incremental cache reused. On
// the first pack everything is compressed fresh; on a re-pack after a small
// change most clusters are reused and only the rest are compressed.
func printCacheLine(incremental bool, st pack.PackStats) {
	if !incremental {
		return
	}
	total := st.ClustersReused + st.ClustersCompressed
	fmt.Fprintf(os.Stderr, "  %s %d reused, %d compressed of %d clusters\n",
		styleDim.Render("cache"), st.ClustersReused, st.ClustersCompressed, total)
}

func printRunHint(path, target string) {
	rel := path
	if !strings.ContainsAny(path, "/\\") {
		rel = "./" + path
	}
	// A viewer built for another OS cannot run here, so say where it goes
	// instead of printing a run command that would not work.
	if target != "" && target != runtime.GOOS {
		fmt.Fprintf(os.Stderr, "  this is a %s viewer; copy %s to that machine to run it\n",
			osLabel(target), styleAccent.Render(filepath.Base(path)))
		return
	}
	fmt.Fprintf(os.Stderr, "  run %s to view the site offline\n", styleAccent.Render(rel))
	if target == "darwin" {
		fmt.Fprintln(os.Stderr, styleDim.Render("  (macOS may quarantine it: xattr -d com.apple.quarantine "+rel+")"))
	}
}

// osLabel turns a GOOS value into a friendly name for the run hint.
func osLabel(goos string) string {
	switch goos {
	case "windows":
		return "Windows"
	case "darwin":
		return "macOS"
	case "linux":
		return "Linux"
	default:
		return goos
	}
}

// humanBytes renders a byte count in B, KiB, MiB, or GiB.
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])
}
