package pkg

import (
	"archive/tar"
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"slices"
	"strconv"
	"strings"
	paths "qulay/config"
	"qulay/database"
	"qulay/helpers"

	"codeberg.org/UzbekLinux/uzbekdb-go"
	"github.com/codeclysm/extract/v3"
	"github.com/klauspost/compress/zstd"
)

var _name string = filepath.Base(os.Args[0])

var installing = map[string]bool{}

type Package struct {
	Name    string
	Repo    string
	Version string
	Desc    string

	Depends []string
	Type    string
	URL     string

	Conflicts []string

	After  string
	Remove string

	Message string

	Path string
}

func readPackage(repo string, path string, repoURL string) (*Package, error) {
	f, err := os.Open(filepath.Join(path, "manifest"))
	if err != nil {
		return nil, err
	}
	defer f.Close()

	pkg := &Package{
		Repo: repo,
		Path: path,
	}

	scanner := bufio.NewScanner(f)

	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		switch {
		case strings.HasPrefix(line, "name:"):
			pkg.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))

		case strings.HasPrefix(line, "repo:"):
			if repo == "" {
				repo = strings.TrimSpace(strings.TrimPrefix(line, "repo:"))
			}

		case strings.HasPrefix(line, "ver:"):
			pkg.Version = strings.TrimSpace(strings.TrimPrefix(line, "ver:"))

		case strings.HasPrefix(line, "desc:"):
			pkg.Desc = strings.TrimSpace(strings.TrimPrefix(line, "desc:"))

		case strings.HasPrefix(line, "deps:"):
			pkg.Depends = append(pkg.Depends,
				strings.Fields(strings.TrimSpace(strings.TrimPrefix(line, "deps:")))...)

		case strings.HasPrefix(line, "conflicts:"):
			pkg.Conflicts = append(pkg.Conflicts,
				strings.Fields(strings.TrimSpace(strings.TrimPrefix(line, "conflicts:")))...)

		case strings.HasPrefix(line, "type:"):
			pkg.Type = strings.TrimSpace(strings.TrimPrefix(line, "type:"))

		case strings.HasPrefix(line, "url:"):
			pkg.URL = strings.TrimSpace(strings.TrimPrefix(line, "url:"))

		case strings.HasPrefix(line, "after:"):
			pkg.After = strings.TrimSpace(strings.TrimPrefix(line, "after:"))

		case strings.HasPrefix(line, "remove:"):
			pkg.Remove = strings.TrimSpace(strings.TrimPrefix(line, "remove:"))
		
		case strings.HasPrefix(line, "message:"):
			pkg.Message = strings.TrimSpace(strings.TrimPrefix(line, "message:"))
		}
	}
	if err := scanner.Err(); err != nil {
		return nil, err
	}

	pkg.Repo = repo

	subst := func(s string) string {
		if repoURL != "" {
			s = strings.ReplaceAll(s, "{URL}", repoURL)
		}
		s = strings.ReplaceAll(s, "{ARCH}", runtime.GOARCH)
		s = strings.ReplaceAll(s, "{REPO}", pkg.Repo)
		s = strings.ReplaceAll(s, "{NAME}", pkg.Name)
		s = strings.ReplaceAll(s, "{VER}", pkg.Version)
		return s
	}

	pkg.Name = subst(pkg.Name)
	pkg.Version = subst(pkg.Version)
	pkg.Desc = subst(pkg.Desc)
	pkg.URL = subst(pkg.URL)
	pkg.Type = subst(pkg.Type)
	pkg.After = subst(pkg.After)
	pkg.Remove = subst(pkg.Remove)
	pkg.Message = subst(pkg.Message)
	for i, dep := range pkg.Depends {
		pkg.Depends[i] = subst(dep)
	}
	for i, conflict := range pkg.Conflicts {
		pkg.Conflicts[i] = subst(conflict)
	}

	return pkg, nil
}

func GetPackages(destDir string) []Package {
	var found []Package

	reposPath := filepath.Join(destDir, paths.ReposDir)

	repoURLs := map[string]string{}
	for _, repo := range database.GetRepos(destDir) {
		repoURLs[repo.Name] = repo.URL
	}

	repos, err := os.ReadDir(reposPath)
	if err != nil {
		return found
	}

	for _, repo := range repos {
		repoPath := filepath.Join(
			reposPath,
			repo.Name(),
		)
		repoInfo, err := os.Stat(repoPath)
		if err != nil || !repoInfo.IsDir() {
			continue
		}

		packagesPath := filepath.Join(
			repoPath,
			"packages",
		)

		packages, err := os.ReadDir(packagesPath)
		if err != nil {
			continue
		}

		for _, pkgDir := range packages {
			pkgPath := filepath.Join(
				packagesPath,
				pkgDir.Name(),
			)
			pkgInfo, err := os.Stat(pkgPath)
			if err != nil || !pkgInfo.IsDir() {
				continue
			}

			pkg, err := readPackage(repo.Name(), pkgPath, repoURLs[repo.Name()])
			if err != nil {
				continue
			}

			if pkg.Name == "" {
				pkg.Name = pkgDir.Name()
			}

			found = append(found, *pkg)
		}
	}

	slices.SortFunc(found, func(a, b Package) int {
		if n := strings.Compare(a.Repo, b.Repo); n != 0 {
			return n
		}

		return strings.Compare(a.Name, b.Name)
	})

	return found
}

func IsBuilt(pkg *Package, destDir string) bool {
	builtDir := filepath.Join(
		destDir,
		paths.BuiltDir,
		runtime.GOARCH,
		pkg.Repo,
	)
	if _, err := os.Stat(
		filepath.Join(builtDir, pkg.Name+".tar.zst"),
	); err == nil {
		return true
	}
	if _, err := os.Stat(
		filepath.Join(builtDir, pkg.Name+".qpmp"),
	); err == nil {
		return true
	}
	return false
}

var versionRe = regexp.MustCompile(`[0-9]+|[a-zA-Z]+`)

func trimTrailingZeros(segs []string) []string {
	for len(segs) > 0 {
		last := segs[len(segs)-1]
		if n, err := strconv.Atoi(last); err == nil && n == 0 {
			segs = segs[:len(segs)-1]
		} else {
			break
		}
	}
	return segs
}

func versionCompare(a, b string) int {
	aa := trimTrailingZeros(versionRe.FindAllString(a, -1))
	bb := trimTrailingZeros(versionRe.FindAllString(b, -1))

	n := len(aa)
	if len(bb) < n {
		n = len(bb)
	}

	for i := 0; i < n; i++ {
		x, y := aa[i], bb[i]

		xn, xerr := strconv.Atoi(x)
		yn, yerr := strconv.Atoi(y)
		if xerr == nil && yerr == nil {
			if xn != yn {
				if xn > yn {
					return 1
				}
				return -1
			}
			continue
		}

		if x != y {
			if x > y {
				return 1
			}
			return -1
		}
	}

	if len(aa) < len(bb) {
		return -1
	}
	if len(aa) > len(bb) {
		return 1
	}
	return 0
}

func isStale(pkg *Package, destDir string) bool {
	builtDir := filepath.Join(
		destDir,
		paths.BuiltDir,
		runtime.GOARCH,
		pkg.Repo,
	)

	artifact := filepath.Join(builtDir, pkg.Name+".tar.zst")
	if _, err := os.Stat(artifact); err != nil {
		artifact = filepath.Join(builtDir, pkg.Name+".qpmp")
		if _, err := os.Stat(artifact); err != nil {
			return false
		}
	}

	artInfo, err := os.Stat(artifact)
	if err != nil {
		return false
	}

	for _, src := range []string{"manifest", "build.sh"} {
		info, err := os.Stat(filepath.Join(pkg.Path, src))
		if err == nil && info.ModTime().After(artInfo.ModTime()) {
			return true
		}
	}

	return false
}

func isOldInstalled(key string, destDir string) bool {
	pkg, err := getPackage(key, destDir)
	if err != nil {
		return false
	}
	ver := database.GetPackageVersion(key, destDir)
	return ver != "" && versionCompare(pkg.Version, ver) > 0
}

func buildable(p *Package) bool {
	return p.Type == "archive" || p.Type == "binary"
}

func Expand(cmd string, oths []string, destDir string) ([]string, error) {
	if cmd == "o" && len(oths) == 0 {
		oths = []string{"@old"}
	}

	var out []string
	seen := map[string]bool{}

	add := func(name string) {
		if !seen[name] {
			seen[name] = true
			out = append(out, name)
		}
	}

	for _, arg := range oths {
		lower := strings.ToLower(arg)

		var repo, kind string
		if strings.Contains(lower, "/") {
			parts := strings.SplitN(lower, "/", 2)
			repo = parts[0]
			kind = parts[1]
		} else {
			kind = lower
		}

		switch kind {
		case "@all", "@inst", "@notinst", "@old":
		default:
			add(arg)
			continue
		}

		packages := GetPackages(destDir)

		switch cmd {
		case "-":
			for key := range database.ReadInstalled(destDir) {
				ks, ok := key.(string)
				if !ok || (repo != "" && !strings.HasPrefix(ks, repo+"/")) {
					continue
				}

				switch kind {
				case "@all", "@inst":
					add(ks)
				case "@old":
					if isOldInstalled(ks, destDir) {
						add(ks)
					}
				}
			}

		case "b":
			for i := range packages {
				p := &packages[i]
				if !buildable(p) {
					continue
				}
				if repo != "" && strings.ToLower(p.Repo) != repo {
					continue
				}

				key := p.Repo + "/" + p.Name

				switch kind {
				case "@all":
					add(key)
				case "@inst":
					if IsBuilt(p, destDir) {
						add(key)
					}
				case "@notinst":
					if !IsBuilt(p, destDir) {
						add(key)
					}
				case "@old":
					if IsBuilt(p, destDir) && isStale(p, destDir) {
						add(key)
					}
				}
			}

		default: // "+" and "o"
			for i := range packages {
				p := &packages[i]
				if repo != "" && strings.ToLower(p.Repo) != repo {
					continue
				}

				key := p.Repo + "/" + p.Name
				installed := database.IsInstalled(key, destDir)

				switch kind {
				case "@all":
					add(key)
				case "@inst":
					if installed {
						add(key)
					}
				case "@notinst":
					if !installed {
						add(key)
					}
				case "@old":
					if installed {
						ver := database.GetPackageVersion(key, destDir)
						if versionCompare(p.Version, ver) > 0 {
							add(key)
						}
					}
				}
			}
		}
	}

	slices.Sort(out)
	return out, nil
}

func FindPackages(name string, destDir string) []Package {
	var found []Package

	name = strings.ToLower(name)

	var repo string
	if strings.Contains(name, "/") {
		parts := strings.SplitN(name, "/", 2)
		repo = parts[0]
		name = parts[1]
	}

	for _, pkg := range GetPackages(destDir) {
		if repo != "" && pkg.Repo != repo {
			continue
		}

		if strings.Contains(
			strings.ToLower(pkg.Name),
			name,
		) {
			found = append(found, pkg)
		}
	}

	return found
}

func getPackage(name string, destDir string) (*Package, error) {
	name = strings.ToLower(name)

	var repo, pkgName string
	if strings.Contains(name, "/") {
		parts := strings.SplitN(name, "/", 2)
		repo = parts[0]
		pkgName = parts[1]
	} else {
		pkgName = name
	}

	var exact []Package
	for _, p := range GetPackages(destDir) {
		if strings.ToLower(p.Name) != pkgName {
			continue
		}
		if repo != "" && strings.ToLower(p.Repo) != repo {
			continue
		}
		exact = append(exact, p)
	}

	if len(exact) == 0 {
		return nil, errors.New("no packages found with name " + name)
	}

	if len(exact) == 1 {
		return &exact[0], nil
	}

	fmt.Println("found multiple packages:")

	for _, pkg := range exact {
		fmt.Printf(
			"%s/%s - %s\n",
			pkg.Repo,
			pkg.Name,
			pkg.Version,
		)
	}

	return nil, errors.New(
		"multiple packages found, specify repository/name",
	)
}

func manifestString(p *Package) string {
	var b strings.Builder
	b.WriteString("repo: " + p.Repo + "\n")
	b.WriteString("name: " + p.Name + "\n")
	b.WriteString("ver: " + p.Version + "\n")
	if p.Desc != "" {
		b.WriteString("desc: " + p.Desc + "\n")
	}
	if len(p.Depends) > 0 {
		b.WriteString("deps: " + strings.Join(p.Depends, " ") + "\n")
	}
	if len(p.Conflicts) > 0 {
		b.WriteString("conflicts: " + strings.Join(p.Conflicts, " ") + "\n")
	}
	if p.Type != "" {
		b.WriteString("type: " + p.Type + "\n")
	}
	if p.URL != "" {
		b.WriteString("url: " + p.URL + "\n")
	}
	return b.String()
}

func tarZstd(payloadDir, destPath, manifest string) error {
	out, err := os.Create(destPath)
	if err != nil {
		return err
	}
	defer out.Close()

	zw, err := zstd.NewWriter(out)
	if err != nil {
		return err
	}

	tw := tar.NewWriter(zw)

	hdr := &tar.Header{
		Name: "manifest",
		Mode: 0644,
		Size: int64(len(manifest)),
	}
	if err := tw.WriteHeader(hdr); err != nil {
		return err
	}
	if _, err := tw.Write([]byte(manifest)); err != nil {
		return err
	}

	if err := writeTarTree(payloadDir, tw, "payload"); err != nil {
		return err
	}

	if err := tw.Close(); err != nil {
		return err
	}
	return zw.Close()
}

func tarPayload(payloadDir, destPath string) error {
	out, err := os.Create(destPath)
	if err != nil {
		return err
	}
	defer out.Close()

	zw, err := zstd.NewWriter(out)
	if err != nil {
		return err
	}

	tw := tar.NewWriter(zw)

	if err := writeTarTree(payloadDir, tw, ""); err != nil {
		return err
	}

	if err := tw.Close(); err != nil {
		return err
	}
	return zw.Close()
}

func writeTarTree(payloadDir string, tw *tar.Writer, prefix string) error {
	return filepath.Walk(payloadDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		rel, err := filepath.Rel(payloadDir, path)
		if err != nil {
			return err
		}

		name := filepath.Join(prefix, filepath.ToSlash(rel))

		switch {
		case info.Mode()&os.ModeSymlink != 0:
			link, err := os.Readlink(path)
			if err != nil {
				return err
			}
			return tw.WriteHeader(&tar.Header{
				Name:     name,
				Mode:     int64(info.Mode()),
				Typeflag: tar.TypeSymlink,
				Linkname: link,
			})

		case info.IsDir():
			return tw.WriteHeader(&tar.Header{
				Name:     name + "/",
				Mode:     int64(info.Mode()),
				Typeflag: tar.TypeDir,
			})

		default:
			hdr := &tar.Header{
				Name:    name,
				Mode:    int64(info.Mode()),
				Size:    info.Size(),
				ModTime: info.ModTime(),
			}
			if err := tw.WriteHeader(hdr); err != nil {
				return err
			}
			f, err := os.Open(path)
			if err != nil {
				return err
			}
			_, err = io.Copy(tw, f)
			f.Close()
			return err
		}
	})
}

func readBuildConfig(destDir string) map[string]string {
	cfg := map[string]string{}
	cfgPath := filepath.Join(destDir, paths.BuildConfigFile)
	data, err := os.ReadFile(cfgPath)
	if err != nil {
		return cfg
	}
	m := uzbekdb.Loads(string(data), "map")
	if m == nil {
		return cfg
	}
	mp, ok := m.(map[interface{}][]interface{})
	if !ok {
		return cfg
	}
	for k, vals := range mp {
		if len(vals) > 0 {
			cfg[fmt.Sprintf("%v", k)] = fmt.Sprintf("%v", vals[0])
		}
	}
	return cfg
}

func buildPackage(pkg *Package, destDir string) (string, error) {
	buildScript := filepath.Join(pkg.Path, "build.sh")
	if _, err := os.Stat(buildScript); err != nil {
		return "", errors.New(
			"package has no build.sh",
		)
	}

	payloadDir := filepath.Join(
		destDir,
		"tmp",
		"build_"+pkg.Repo+"_"+pkg.Name,
	)
	os.RemoveAll(payloadDir)
	os.MkdirAll(payloadDir, 0755)

	env := append([]string{}, os.Environ()...)
	for k, v := range readBuildConfig(destDir) {
		env = append(env, k+"="+v)
	}
	env = append(
		env,
		"DESTDIR="+payloadDir,
		"PKGURL="+pkg.URL,
		"ARCH="+runtime.GOARCH,
		"PKGREPO="+pkg.Repo,
		"PKGVER="+pkg.Version,
		"PKGNAME="+pkg.Name,
	)
	if v := os.Getenv("MAKEOPTS"); v != "" {
		env = append(env, "MAKEOPTS="+v)
	}

	cmd := exec.Command("sh", "build.sh")
	cmd.Dir = pkg.Path
	cmd.Env = env
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		return "", err
	}

	entries, err := os.ReadDir(payloadDir)
	if err != nil {
		return "", err
	}
	if len(entries) == 0 {
		return "", errors.New(
			"build.sh produced no files",
		)
	}

	return payloadDir, nil
}

func isSafePath(p string) bool {
	p = filepath.ToSlash(p)
	if p == "" || p == "/" {
		return false
	}
	for _, part := range strings.Split(p, "/") {
		if part == "." || part == ".." {
			return false
		}
	}
	return true
}

func conflictCheck(self string, conflicts []string, destDir string) (string, bool) {
	for _, c := range conflicts {
		if strings.Contains(c, "/") {
			if c == self {
				continue
			}
			if database.IsInstalled(c, destDir) || installing[c] {
				return c, true
			}
			continue
		}

		for key := range database.ReadInstalled(destDir) {
			ks := fmt.Sprintf("%v", key)
			if ks != self && strings.HasSuffix(ks, "/"+c) {
				return ks, true
			}
		}
		for k := range installing {
			if k != self && strings.HasSuffix(k, "/"+c) {
				return k, true
			}
		}
	}
	return "", false
}

func installPayload(payloadDir, destDir string, pkg *Package, record bool) error {
	err := helpers.CopyDir(payloadDir, destDir)
	if err != nil {
		return fmt.Errorf(
			"copy failed: %w",
			err,
		)
	}

	if !record {
		return nil
	}

	outputFilePath := filepath.Join(
		destDir,
		paths.FilesDir,
		pkg.Repo+"/"+pkg.Name+".files",
	)
	os.MkdirAll(filepath.Dir(outputFilePath), 0755)

	outputFile, err := os.Create(outputFilePath)
	if err != nil {
		return err
	}
	defer outputFile.Close()

	err = filepath.WalkDir(
		payloadDir,
		func(path string, d os.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if d.IsDir() {
				return nil
			}
			rel, err := filepath.Rel(payloadDir, path)
			if err != nil {
				return err
			}
			if !isSafePath(rel) {
				return errors.New(
					"unsafe path in payload: " + rel,
				)
			}
			_, err = outputFile.WriteString(
				"/"+filepath.ToSlash(rel)+"\n",
			)
			return err
		},
	)
	return err
}

func qpmpManifest(qpmpPath string) (string, error) {
	f, err := os.Open(qpmpPath)
	if err != nil {
		return "", err
	}
	defer f.Close()

	zr, err := zstd.NewReader(f)
	if err != nil {
		return "", err
	}
	defer zr.Close()

	tr := tar.NewReader(zr)
	for {
		hdr, err := tr.Next()
		if err != nil {
			return "", err
		}
		if hdr.Name == "manifest" {
			data, err := io.ReadAll(tr)
			if err != nil {
				return "", err
			}
			return string(data), nil
		}
	}
}

func qpmpVersion(qpmpPath string) (string, error) {
	data, err := qpmpManifest(qpmpPath)
	if err != nil {
		return "", err
	}
	for _, line := range strings.Split(data, "\n") {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "ver:") {
			return strings.TrimSpace(strings.TrimPrefix(line, "ver:")), nil
		}
	}
	return "", errors.New("no ver in qpmp manifest")
}

func Build(name string, args []string, destDir string) error {
	pkg, err := getPackage(name, destDir)
	if err != nil {
		return err
	}

	if pkg.Type != "archive" && pkg.Type != "binary" {
		return errors.New(
			"cannot build package of type " + pkg.Type,
		)
	}

	if _, err := os.Stat(filepath.Join(pkg.Path, "build.sh")); err != nil {
		return errors.New(
			pkg.Repo + "/" + pkg.Name + " has no build.sh",
		)
	}

	fmt.Println(
		":: running build.sh for",
		pkg.Repo+"/"+pkg.Name,
	)

	payloadDir, err := buildPackage(pkg, destDir)
	if err != nil {
		return err
	}
	defer os.RemoveAll(payloadDir)

	builtDir := filepath.Join(
		destDir,
		paths.BuiltDir,
		runtime.GOARCH,
		pkg.Repo,
	)
	os.MkdirAll(builtDir, 0755)

	if slices.Contains(args, ":qpmp") {
		qpmpPath := filepath.Join(builtDir, pkg.Name+".qpmp")

		err = tarZstd(
			payloadDir,
			qpmpPath,
			manifestString(pkg),
		)
		if err != nil {
			return err
		}

		fmt.Println(
			":: built",
			pkg.Repo+"/"+pkg.Name,
			"->",
			qpmpPath,
		)

		return nil
	}

	tarPath := filepath.Join(builtDir, pkg.Name+".tar.zst")

	err = tarPayload(payloadDir, tarPath)
	if err != nil {
		return err
	}

	fmt.Println(
		":: built",
		pkg.Repo+"/"+pkg.Name,
		"->",
		tarPath,
	)

	return nil
}

func installQpmp(qpmpPath string, args []string, destDir string) error {
	stagingDir := filepath.Join(
		destDir,
		"tmp",
		"qpmp_"+filepath.Base(qpmpPath),
	)
	os.RemoveAll(stagingDir)
	os.MkdirAll(stagingDir, 0755)
	defer os.RemoveAll(stagingDir)

	file, err := os.Open(qpmpPath)
	if err != nil {
		return err
	}
	defer file.Close()

	err = extract.Zstd(
		context.TODO(),
		file,
		stagingDir,
		nil,
	)
	if err != nil {
		return err
	}

	pkg, err := readPackage("", stagingDir, "")
	if err != nil {
		return err
	}
	if pkg.Name == "" {
		pkg.Name = strings.TrimSuffix(
			filepath.Base(qpmpPath),
			".qpmp",
		)
	}
	if pkg.Repo == "" {
		pkg.Repo = "local"
	}
	pkg.Path = stagingDir

	key := pkg.Repo + "/" + pkg.Name

	noLog := slices.Contains(args, ":nl")

	if slices.Contains(args, ":a") {
		if !helpers.Ask(
			"install " + key +
				" with version " +
				pkg.Version + "?",
		) {
			return errors.New("canceled by user")
		}
	}

	payloadDir := filepath.Join(stagingDir, "payload")
	if _, err := os.Stat(payloadDir); err != nil {
		return errors.New(
			"qpmp has no payload",
		)
	}

	fmt.Println(
		":: installing " +
			key,
	)

	err = helpers.CopyDir(payloadDir, destDir)
	if err != nil {
		return fmt.Errorf(
			"copy failed: %w",
			err,
		)
	}

	filesPath := filepath.Join(
		destDir,
		paths.FilesDir,
		key+".files",
	)
	if noLog {
		fmt.Println(
			":: " + key +
				" (" + pkg.Version + ") installed",
		)

		return nil
	}

	os.MkdirAll(filepath.Dir(filesPath), 0755)

	outputFile, err := os.Create(filesPath)
	if err != nil {
		return err
	}
	defer outputFile.Close()

	err = filepath.WalkDir(
		payloadDir,
		func(path string, d os.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if d.IsDir() {
				return nil
			}
			rel, err := filepath.Rel(payloadDir, path)
			if err != nil {
				return err
			}
			if !isSafePath(rel) {
				return errors.New(
					"unsafe path in qpmp payload: " + rel,
				)
			}
			_, err = outputFile.WriteString(
				"/"+filepath.ToSlash(rel)+"\n",
			)
			return err
		},
	)
	if err != nil {
		return err
	}
	outputFile.Close()

	err = database.RegPackage(
		key,
		pkg.Version,
		destDir,
	)
	if err != nil {
		return err
	}

	fmt.Println(
		":: " + key +
			" (" + pkg.Version + ") installed",
	)

	return nil
}

func Install(name string, args []string, destDir string) error {
	if strings.HasSuffix(name, ".qpmp") {
		if _, err := os.Stat(name); err == nil {
			return installQpmp(name, args, destDir)
		}
	}

	reinstall := slices.Contains(args, ":r")
	disableDeps := slices.Contains(args, ":nd")
	noLog := slices.Contains(args, ":nl")
	force := slices.Contains(args, ":f")

	pkg, err := getPackage(name, destDir)
	if err != nil {
		return err
	}

	key := pkg.Repo + "/" + pkg.Name

	if _, ok := installing[key]; ok {
		return nil
	}

	installing[key] = true
	defer delete(installing, key)

	if !force {
		if matched, ok := conflictCheck(key, pkg.Conflicts, destDir); ok {
			return errors.New(
				key + " conflicts with " + matched,
			)
		}
	}

	if !disableDeps {
		if len(pkg.Depends) > 0 {
			fmt.Println(
				helpers.ToString(len(pkg.Depends)) +
					" deps for " +
					key + ":",
			)

			for i, dep := range pkg.Depends {
				if dep == pkg.Name || dep == key {
					return errors.New(
						"broken deps in package " + dep,
					)
				}

				depPkgs := FindPackages(dep, destDir)

				version := "not found"
				if len(depPkgs) > 0 {
					version = depPkgs[0].Version
				}

				fmt.Printf(
					"%d. %s - %s\n",
					i+1,
					dep,
					version,
				)
			}
		}
	}

	if slices.Contains(args, ":a") {
		if !helpers.Ask(
			"install " +
				key +
				" with version " +
				pkg.Version,
		) {
			return errors.New("canceled by user")
		}
	}

	if !disableDeps {
		for _, dep := range pkg.Depends {
			depPkg, err := getPackage(dep, destDir)
			if err != nil {
				return err
			}

			fullName := depPkg.Repo + "/" + depPkg.Name

			if !database.IsInstalled(fullName, destDir) || reinstall {
				err := Install(fullName, args, destDir)
				if err != nil {
					return err
				}
			}
		}
	}

	fmt.Println(
		":: installing " +
			key,
	)

	builtQpmp := filepath.Join(
		destDir,
		paths.BuiltDir,
		runtime.GOARCH,
		pkg.Repo,
		pkg.Name+".qpmp",
	)
	if _, err := os.Stat(builtQpmp); err == nil {
		if ver, err := qpmpVersion(builtQpmp); err == nil && ver == pkg.Version {
			fmt.Println(
				":: found built",
				key,
				ver,
				"-> installing from .qpmp",
			)
			return installQpmp(builtQpmp, args, destDir)
		}
	}

	if pkg.Type == "archive" {
		destArchivePath := filepath.Join(
			destDir,
			"tmp",
			pkg.Name+"_"+pkg.Repo+".tar.zst",
		)

		fullDestPath := filepath.Join(
			pkg.Path,
			"extract",
		)

		os.MkdirAll(filepath.Dir(destArchivePath), 0755)
		defer os.Remove(destArchivePath)
		defer os.RemoveAll(fullDestPath)

		client := helpers.HTTPClient()

		download := func() error {
			resp, err := client.Get(pkg.URL)
			if err != nil {
				return err
			}
			defer resp.Body.Close()

			if resp.StatusCode != http.StatusOK {
				return fmt.Errorf(
					"download failed: HTTP %d",
					resp.StatusCode,
				)
			}

			f, err := os.Create(destArchivePath)
			if err != nil {
				return err
			}
			_, err = io.Copy(f, resp.Body)
			f.Close()
			return err
		}

		err := download()
		if err != nil {
			fmt.Println(
				"!! failed to download",
				pkg.URL,
				"-> building from source",
			)
			payloadDir, berr := buildPackage(pkg, destDir)
			if berr != nil {
				return fmt.Errorf(
					"download failed: %w; build fallback failed: %v",
					err,
					berr,
				)
			}
			defer os.RemoveAll(payloadDir)
			fullDestPath = payloadDir
		} else {
			fmt.Println(
				":: downloaded",
				pkg.URL,
			)

			os.RemoveAll(fullDestPath)

			file, err := os.Open(destArchivePath)
			if err != nil {
				return err
			}
			defer file.Close()

			err = extract.Zstd(
				context.TODO(),
				file,
				fullDestPath,
				nil,
			)
			if err != nil {
				return err
			}
		}

		fmt.Println(":: copying files to /")

		err = installPayload(fullDestPath, destDir, pkg, !noLog)
		if err != nil {
			return err
		}

	} else if pkg.Type == "script" {
		cmd := exec.Command("sh", "install.sh")

		cmd.Dir = pkg.Path
		cmd.Env = append(
			os.Environ(),
			"DESTDIR="+destDir,
		)

		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr

		err := cmd.Run()
		if err != nil {
			return err
		}

	} else if pkg.Type == "binary" {
		outPath := filepath.Join(
			destDir,
			"usr/bin",
			pkg.Name,
		)

		os.MkdirAll(filepath.Dir(outPath), 0755)

		client := helpers.HTTPClient()

		resp, err := client.Get(pkg.URL)
		downloadErr := error(nil)
		if err != nil {
			downloadErr = err
		} else if resp.StatusCode != http.StatusOK {
			downloadErr = fmt.Errorf(
				"download failed: HTTP %d",
				resp.StatusCode,
			)
		} else {
			f, cerr := os.Create(outPath)
			if cerr != nil {
				downloadErr = cerr
			} else {
				_, cerr = io.Copy(f, resp.Body)
				f.Close()
				if cerr != nil {
					downloadErr = cerr
				} else if cerr = os.Chmod(outPath, 0755); cerr != nil {
					downloadErr = cerr
				}
			}
		}
		if resp != nil {
			resp.Body.Close()
		}

		if downloadErr != nil {
			fmt.Println(
				"!! failed to download",
				pkg.URL,
				"-> building from source",
			)
			payloadDir, berr := buildPackage(pkg, destDir)
			if berr != nil {
				return fmt.Errorf(
					"download failed: %w; build fallback failed: %v",
					downloadErr,
					berr,
				)
			}
			defer os.RemoveAll(payloadDir)

			fmt.Println(":: copying files to /")

			if err := installPayload(payloadDir, destDir, pkg, !noLog); err != nil {
				return err
			}
		} else {
			if !noLog {
				outputFilePath := filepath.Join(
					destDir,
					paths.FilesDir,
					pkg.Repo+"/"+pkg.Name+".files",
				)
				os.MkdirAll(filepath.Dir(outputFilePath), 0755)

				outputFile, err := os.Create(outputFilePath)
				if err != nil {
					return err
				}
				defer outputFile.Close()

				_, err = outputFile.WriteString(
					"/usr/bin/" + pkg.Name + "\n",
				)

				if err != nil {
					return err
				}
			}
		}

	} else {
		return errors.New(
			"unknown package type: " + pkg.Type,
		)
	}


	if pkg.After != "" {
		fmt.Println(
			":: running after script",
			pkg.After,
		)

		cmd := exec.Command(
			"sh",
			pkg.After,
		)

		cmd.Dir = pkg.Path
		cmd.Env = append(
			os.Environ(),
			"DESTDIR="+destDir,
		)

		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr

		err := cmd.Run()
		if err != nil {
			return err
		}
	}


	if !noLog {
		err = database.RegPackage(
			pkg.Repo+"/"+pkg.Name,
			pkg.Version,
			destDir,
		)

		if err != nil {
			return errors.New(
				"failed to register package: " +
					err.Error(),
			)
		}
	}

	if pkg.Message != "" {
		dataMsg, err := os.ReadFile(
			filepath.Join(pkg.Path, pkg.Message),
		)
		if err != nil {
			return errors.New(
				"failed to read message from package: " +
					err.Error(),
			)
		}
		fmt.Println(
			":: message from "+pkg.Repo+"/"+pkg.Name+
				" "+pkg.Version+":\n"+
				string(dataMsg),
		)
	}

	fmt.Println(
		":: "+pkg.Repo+"/"+pkg.Name+
		" ("+pkg.Version+") installed",
	)

	return nil
}

func removeLocal(name string, args []string, destDir string) error {
	key := name
	if !strings.Contains(key, "/") {
		for k := range database.ReadInstalled(destDir) {
			if kStr, ok := k.(string); ok {
				if strings.HasSuffix(kStr, "/"+name) {
					key = kStr
					break
				}
			}
		}
	}

	if !database.IsInstalled(key, destDir) {
		return errors.New(
			"no packages found with name " + name,
		)
	}

	if slices.Contains(args, ":a") {
		if !helpers.Ask(
			"remove " + key + "?",
		) {
			return errors.New(
				"canceled by user",
			)
		}
	}

	filesPath := filepath.Join(
		destDir,
		paths.FilesDir,
		key+".files",
	)
	if _, err := os.Stat(filesPath); err != nil {
		return errors.New(
			"no files list for " + key,
		)
	}

	f, err := os.Open(filesPath)
	if err != nil {
		return err
	}
	defer f.Close()

	scanner := bufio.NewScanner(f)

	for scanner.Scan() {
		file := scanner.Text()

		if file == "" {
			continue
		}

		if !isSafePath(file) {
			fmt.Fprintf(
				os.Stderr,
				"w! skipping unsafe path: %s\n",
				file,
			)
			continue
		}

		path := filepath.Join(destDir, file)

		if _, err := os.Stat(path); err == nil {
			fmt.Println(
				"removing",
				path,
			)
			if err := os.RemoveAll(path); err != nil {
				fmt.Fprintf(
					os.Stderr,
					"failed removing %s: %v\n",
					path,
					err,
				)
			}
		}
	}

	if err := scanner.Err(); err != nil {
		return err
	}

	if !slices.Contains(args, ":nl") {
		err = database.UnRegPackage(
			key,
			destDir,
		)
		if err != nil {
			return err
		}
	}

	os.Remove(filesPath)

	fmt.Println(
		":: " + key + " removed",
	)

	return nil
}

func Remove(name string, args []string, destDir string) error {
	pkg, err := getPackage(name, destDir)
	if err != nil {
		if strings.HasPrefix(
			err.Error(),
			"no packages found",
		) {
			return removeLocal(name, args, destDir)
		}
		return err
	}

	key := pkg.Repo + "/" + pkg.Name

	if slices.Contains(args, ":a") {
		localVersion := database.GetPackageVersion(
			key,
			destDir,
		)

		if !helpers.Ask(
			"remove " + key +
				" repo version " +
				pkg.Version +
				" local version " +
				localVersion +
				"?",
		) {
			return errors.New(
				"canceled by user",
			)
		}
	}


	if pkg.Remove != "" {
		fmt.Println(
			":: running remove script " +
				pkg.Remove +
				" ...",
		)

		cmd := exec.Command(
			"sh",
			pkg.Remove,
		)

		cmd.Dir = pkg.Path
		cmd.Env = append(
			os.Environ(),
			"DESTDIR="+destDir,
		)

		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr

		err := cmd.Run()
		if err != nil {
			return err
		}
	}


	filesPath := filepath.Join(
		destDir,
		paths.FilesDir,
		key+".files",
	)
	persistentFiles := true
	if _, err := os.Stat(filesPath); err != nil {
		filesPath = filepath.Join(
			pkg.Path,
			"files",
		)
		persistentFiles = false
	}


	if _, err := os.Stat(filesPath); err == nil {

		f, err := os.Open(filesPath)
		if err != nil {
			return err
		}
		defer f.Close()


		scanner := bufio.NewScanner(f)

		for scanner.Scan() {
			file := scanner.Text()

			if file == "" {
				continue
			}

			if !isSafePath(file) {
				fmt.Fprintf(
					os.Stderr,
					"w! skipping unsafe path: %s\n",
					file,
				)
				continue
			}


			path := filepath.Join(
				destDir,
				file,
			)


			if _, err := os.Stat(path); err == nil {

				fmt.Println(
					"removing",
					path,
				)

				err := os.RemoveAll(path)
				if err != nil {
					fmt.Fprintf(
						os.Stderr,
						"failed removing %s: %v\n",
						path,
						err,
					)
				}
			}
		}


		if err := scanner.Err(); err != nil {
			return err
		}

	} else if pkg.Remove == "" {
		return errors.New(
			"package has no files list and no remove script",
		)
	}


	if !slices.Contains(args, ":nl") {
		err = database.UnRegPackage(
			key,
			destDir,
		)

		if err != nil {
			return err
		}
	}

	if persistentFiles {
		os.Remove(filesPath)
	}


	fmt.Println(
		":: "+key+
			" ("+pkg.Version+") removed",
	)

	return nil
}

func Upgrade(names []string, args []string, destDir string) error {
	upgraded := 0

	for _, n := range names {
		pkg, err := getPackage(n, destDir)
		if err != nil {
			fmt.Println("w! skipping", n, err)
			continue
		}

		key := pkg.Repo + "/" + pkg.Name

		ver := database.GetPackageVersion(key, destDir)
		if ver == "" {
			continue
		}

		fmt.Println(
			":: upgrading", key,
			ver, "->", pkg.Version,
		)
		if err := Install(key, args, destDir); err != nil {
			return err
		}
		upgraded++
	}

	if upgraded == 0 {
		fmt.Println(":: nothing to upgrade")
	} else {
		fmt.Println("::", upgraded, "package(s) upgraded")
	}

	return nil
}
