package main

import (
	"archive/zip"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	diskfs "github.com/diskfs/go-diskfs"
	"github.com/diskfs/go-diskfs/disk"
	"github.com/diskfs/go-diskfs/filesystem"
	"github.com/diskfs/go-diskfs/filesystem/iso9660"
)

func main() {
	bin := flag.String("bin", "", "path to darwin Navis binary")
	outDir := flag.String("out", "", "output directory (e.g. dist/navis-release/darwin-arm64)")
	version := flag.String("version", "1.4.1", "CFBundleShortVersionString")
	arch := flag.String("arch", "arm64", "arch label for volume name")
	flag.Parse()
	if *bin == "" || *outDir == "" {
		fmt.Fprintln(os.Stderr, "usage: packmac -bin <Navis> -out <dir> [-version 1.4.1] [-arch arm64]")
		os.Exit(2)
	}
	if err := run(*bin, *outDir, *version, *arch); err != nil {
		fmt.Fprintf(os.Stderr, "packmac: %v\n", err)
		os.Exit(1)
	}
}

func run(binPath, outDir, version, arch string) error {
	if err := os.MkdirAll(outDir, 0o755); err != nil {
		return err
	}
	stage, err := os.MkdirTemp("", "navis-packmac-*")
	if err != nil {
		return err
	}
	defer os.RemoveAll(stage)

	appRoot := filepath.Join(stage, "Navis.app")
	if err := writeAppBundle(appRoot, binPath, version); err != nil {
		return err
	}
	readme := filepath.Join(stage, "README.txt")
	if err := os.WriteFile(readme, []byte(readmeText(version, arch)), 0o644); err != nil {
		return err
	}

	zipPath := filepath.Join(outDir, "Navis.app.zip")
	if err := zipDir(zipPath, stage, []string{"Navis.app", "README.txt"}); err != nil {
		return fmt.Errorf("zip: %w", err)
	}
	fmt.Println("wrote", zipPath)

	dmgPath := filepath.Join(outDir, "Navis.dmg")
	if err := writeDMG(dmgPath, appRoot, readme, "Navis-"+arch); err != nil {
		return fmt.Errorf("dmg: %w", err)
	}
	fmt.Println("wrote", dmgPath)
	return nil
}

func writeAppBundle(appRoot, binPath, version string) error {
	macOSDir := filepath.Join(appRoot, "Contents", "MacOS")
	if err := os.MkdirAll(macOSDir, 0o755); err != nil {
		return err
	}
	plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>CFBundleExecutable</key><string>Navis</string>
  <key>CFBundleIdentifier</key><string>win.evilfox.navis</string>
  <key>CFBundleName</key><string>Navis</string>
  <key>CFBundlePackageType</key><string>APPL</string>
  <key>CFBundleShortVersionString</key><string>%s</string>
  <key>CFBundleVersion</key><string>%s</string>
  <key>LSMinimumSystemVersion</key><string>11.0</string>
  <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
</dict>
</plist>
`, version, version)
	if err := os.WriteFile(filepath.Join(appRoot, "Contents", "Info.plist"), []byte(plist), 0o644); err != nil {
		return err
	}
	dest := filepath.Join(macOSDir, "Navis")
	if err := copyFile(binPath, dest, 0o755); err != nil {
		return err
	}
	return nil
}

func writeDMG(dmgPath, appRoot, readmePath, volume string) error {
	_ = os.Remove(dmgPath)
	appSize, err := dirSize(appRoot)
	if err != nil {
		return err
	}
	readmeSize, err := fileSize(readmePath)
	if err != nil {
		return err
	}
	need := appSize + readmeSize + 4*1024*1024
	if need < 12*1024*1024 {
		need = 12 * 1024 * 1024
	}
	// round up to 2048
	need = ((need + 2047) / 2048) * 2048

	mydisk, err := diskfs.Create(dmgPath, need, diskfs.SectorSizeDefault)
	if err != nil {
		return err
	}
	mydisk.LogicalBlocksize = 2048
	fs, err := mydisk.CreateFilesystem(disk.FilesystemSpec{
		Partition:   0,
		FSType:      filesystem.TypeISO9660,
		VolumeLabel: sanitizeVol(volume),
	})
	if err != nil {
		return err
	}

	if err := copyTreeToISO(fs, appRoot, "/Navis.app"); err != nil {
		_ = fs.Close()
		return err
	}
	if err := writeISOFile(fs, "/README.txt", readmePath, 0o644); err != nil {
		_ = fs.Close()
		return err
	}

	iso, ok := fs.(*iso9660.FileSystem)
	if !ok {
		_ = fs.Close()
		return fmt.Errorf("not iso9660")
	}
	if err := iso.Finalize(iso9660.FinalizeOptions{
		RockRidge:          true,
		Joliet:             true,
		DeepDirectories:    true,
		VolumeIdentifier:   sanitizeVol(volume),
		PublisherIdentifier: "Navis",
	}); err != nil {
		return err
	}
	return fs.Close()
}

func copyTreeToISO(fs filesystem.FileSystem, srcRoot, dstRoot string) error {
	return filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(srcRoot, path)
		if err != nil {
			return err
		}
		rel = filepath.ToSlash(rel)
		dst := dstRoot
		if rel != "." {
			dst = dstRoot + "/" + rel
		}
		if info.IsDir() {
			if dst == dstRoot {
				return fs.Mkdir(dst)
			}
			return fs.Mkdir(dst)
		}
		mode := info.Mode()
		if mode&0o111 != 0 || strings.HasSuffix(rel, "MacOS/Navis") || strings.HasSuffix(rel, "MacOS\\Navis") {
			mode = 0o755
		} else {
			mode = 0o644
		}
		return writeISOFile(fs, dst, path, mode)
	})
}

func writeISOFile(fs filesystem.FileSystem, dst, src string, mode os.FileMode) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	rw, err := fs.OpenFile(dst, os.O_CREATE|os.O_RDWR|os.O_TRUNC)
	if err != nil {
		return err
	}
	if _, err := io.Copy(rw, in); err != nil {
		_ = rw.Close()
		return err
	}
	if err := rw.Close(); err != nil {
		return err
	}
	if err := fs.Chmod(dst, mode); err != nil {
		_ = err // best-effort
	}
	return nil
}

func zipDir(zipPath, stage string, entries []string) error {
	_ = os.Remove(zipPath)
	f, err := os.Create(zipPath)
	if err != nil {
		return err
	}
	defer f.Close()
	zw := zip.NewWriter(f)
	defer zw.Close()

	for _, name := range entries {
		root := filepath.Join(stage, name)
		info, err := os.Stat(root)
		if err != nil {
			return err
		}
		if !info.IsDir() {
			if err := addZipFile(zw, root, name, 0o644); err != nil {
				return err
			}
			continue
		}
		err = filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
			if err != nil {
				return err
			}
			rel, err := filepath.Rel(stage, path)
			if err != nil {
				return err
			}
			rel = filepath.ToSlash(rel)
			if fi.IsDir() {
				_, err := zw.Create(rel + "/")
				return err
			}
			mode := os.FileMode(0o644)
			if strings.Contains(rel, "Contents/MacOS/") {
				mode = 0o755
			}
			return addZipFile(zw, path, rel, mode)
		})
		if err != nil {
			return err
		}
	}
	return nil
}

func addZipFile(zw *zip.Writer, path, name string, mode os.FileMode) error {
	h, err := zip.FileInfoHeader(mustStat(path))
	if err != nil {
		return err
	}
	h.Name = name
	h.Method = zip.Deflate
	h.SetMode(mode)
	w, err := zw.CreateHeader(h)
	if err != nil {
		return err
	}
	in, err := os.Open(path)
	if err != nil {
		return err
	}
	defer in.Close()
	_, err = io.Copy(w, in)
	return err
}

func mustStat(path string) os.FileInfo {
	fi, err := os.Stat(path)
	if err != nil {
		panic(err)
	}
	return fi
}

func copyFile(src, dst string, mode os.FileMode) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
	if err != nil {
		return err
	}
	defer out.Close()
	_, err = io.Copy(out, in)
	return err
}

func dirSize(root string) (int64, error) {
	var n int64
	err := filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if !info.IsDir() {
			n += info.Size()
		}
		return nil
	})
	return n, err
}

func fileSize(path string) (int64, error) {
	fi, err := os.Stat(path)
	if err != nil {
		return 0, err
	}
	return fi.Size(), nil
}

func sanitizeVol(s string) string {
	s = strings.ToUpper(s)
	s = strings.Map(func(r rune) rune {
		if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
			return r
		}
		return '-'
	}, s)
	if len(s) > 32 {
		s = s[:32]
	}
	if s == "" {
		s = "NAVIS"
	}
	return s
}

func readmeText(version, arch string) string {
	return fmt.Sprintf(`Navis %s for macOS (%s)

1) Open this disk image
2) Drag Navis.app to Applications (or any folder)
3) First launch may need:
   xattr -dr com.apple.quarantine /Applications/Navis.app
4) Run from Terminal:
   /Applications/Navis.app/Contents/MacOS/Navis init
   /Applications/Navis.app/Contents/MacOS/Navis install-core
   /Applications/Navis.app/Contents/MacOS/Navis connect

Or use the CLI binary next to this DMG on git (file named Navis).

Shop: https://evilfox.win/
`, version, arch)
}
