package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"time"
)

// The install root is the ONE directory this program is allowed to write to.
// Everything below is derived from it:
//
//	<root>/.techlosoft-root      confirmation marker, written once
//	<root>/bin/                  the installed programs themselves
//	<root>/state/installed.json  what is installed, at what version and hash
//	<root>/trash/                where removed programs go. Nothing is deleted.
//	<root>/ledger.jsonl          append-only record of every change
const (
	markerName    = ".techlosoft-root"
	binDir        = "bin"
	stateDir      = "state"
	trashDir      = "trash"
	ledgerName    = "ledger.jsonl"
	installedName = "installed.json"
	partSuffix    = ".part"
)

// Root is a validated, confirmed install root.
type Root struct {
	Path string
}

// systemRoots are absolute paths this program refuses to treat as an install
// root, along with every one of their ancestors. Installing into any of these
// would mean scattering binaries through directories the operating system and
// the package manager own.
//
// The check is deliberately a refusal list of exact paths rather than a
// prefix test: a customer's own folder that merely happens to live under
// /usr/local/share/me is their business, but /usr/local itself is not ours.
var systemRoots = []string{
	"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64",
	"/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr",
	"/usr/bin", "/usr/lib", "/usr/local", "/usr/local/bin", "/usr/local/lib",
	"/usr/sbin", "/usr/share", "/var", "/var/lib", "/var/log", "/var/tmp",
	"/Applications", "/Library", "/System", "/Users", "/Volumes",
	"/private", "/private/etc", "/private/tmp", "/private/var",
	`C:\`, `C:\Windows`, `C:\Windows\System32`, `C:\Program Files`,
	`C:\Program Files (x86)`, `C:\ProgramData`, `C:\Users`,
}

// ErrUnconfirmedRoot is returned when the root looks fine but has never been
// confirmed by the customer. It is a question, not a failure.
var ErrUnconfirmedRoot = errors.New("install root not confirmed")

// normalizeRoot turns whatever the customer typed into an absolute, cleaned
// path, and expands a leading ~.
func normalizeRoot(p string) (string, error) {
	p = strings.TrimSpace(p)
	p = strings.Trim(p, `"`)
	if p == "" {
		return "", errors.New("empty install root")
	}
	if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("cannot expand ~: %w", err)
		}
		p = filepath.Join(home, strings.TrimLeft(p[1:], `/\`))
	}
	abs, err := filepath.Abs(p)
	if err != nil {
		return "", err
	}
	return filepath.Clean(abs), nil
}

// checkRootSafety refuses obvious system directories. This runs before
// anything is created, so a mistyped root is caught before it does damage.
func checkRootSafety(abs string) error {
	cmp := abs
	if runtime.GOOS == "windows" {
		cmp = strings.ToUpper(abs)
	}
	for _, sys := range systemRoots {
		s := filepath.Clean(sys)
		if runtime.GOOS == "windows" {
			s = strings.ToUpper(s)
		}
		if cmp == s {
			return fmt.Errorf("refusing to use %s as an install root: that is a system directory", abs)
		}
	}
	// A bare drive letter or filesystem root on any platform.
	if abs == filepath.VolumeName(abs)+string(filepath.Separator) {
		return fmt.Errorf("refusing to use %s as an install root: that is the root of a drive", abs)
	}
	if home, err := os.UserHomeDir(); err == nil && filepath.Clean(home) == abs {
		return fmt.Errorf("refusing to use %s as an install root: that is your home folder itself; use a folder inside it", abs)
	}
	return nil
}

// openRoot validates a root and returns it ready for use. If the root has
// never been confirmed it returns ErrUnconfirmedRoot and creates nothing; the
// caller decides whether to ask (wizard) or to require --yes (command line).
func openRoot(p string) (*Root, error) {
	abs, err := normalizeRoot(p)
	if err != nil {
		return nil, err
	}
	if err := checkRootSafety(abs); err != nil {
		return nil, err
	}
	if _, err := os.Stat(filepath.Join(abs, markerName)); err != nil {
		return nil, ErrUnconfirmedRoot
	}
	return &Root{Path: abs}, nil
}

// confirmRoot creates and marks an install root. This is the only place the
// marker is written, and it happens only after an explicit yes.
func confirmRoot(p string) (*Root, error) {
	abs, err := normalizeRoot(p)
	if err != nil {
		return nil, err
	}
	if err := checkRootSafety(abs); err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Join(abs, binDir), 0o755); err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Join(abs, stateDir), 0o755); err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Join(abs, trashDir), 0o755); err != nil {
		return nil, err
	}
	marker := filepath.Join(abs, markerName)
	if _, err := os.Stat(marker); err != nil {
		body := "This folder is a Techlosoft Suite install root.\n" +
			"Created " + time.Now().UTC().Format(time.RFC3339) + "\n" +
			"techlosoft-suite writes here and nowhere else.\n"
		if err := os.WriteFile(marker, []byte(body), 0o644); err != nil {
			return nil, err
		}
	}
	return &Root{Path: abs}, nil
}

func (r *Root) bin() string        { return filepath.Join(r.Path, binDir) }
func (r *Root) state() string      { return filepath.Join(r.Path, stateDir) }
func (r *Root) trash() string      { return filepath.Join(r.Path, trashDir) }
func (r *Root) ledgerPath() string { return filepath.Join(r.Path, ledgerName) }
func (r *Root) installedPath() string {
	return filepath.Join(r.Path, stateDir, installedName)
}

// programPath is where a given program's binary lives once installed.
func (r *Root) programPath(slug string) string {
	name := slug
	if runtime.GOOS == "windows" {
		name += ".exe"
	}
	return filepath.Join(r.bin(), name)
}

// contains reports whether p is inside the root. Every write and every move
// is checked through this, so a slug carrying path separators or ".." cannot
// escape.
func (r *Root) contains(p string) bool {
	rel, err := filepath.Rel(r.Path, filepath.Clean(p))
	if err != nil {
		return false
	}
	if rel == "." {
		return true
	}
	return !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
}

// InstalledEntry is one record in state/installed.json.
type InstalledEntry struct {
	Slug        string `json:"slug"`
	Name        string `json:"name"`
	Group       string `json:"group"`
	Version     string `json:"version"`
	SHA256      string `json:"sha256"`
	Size        int64  `json:"size"`
	InstalledAt string `json:"installed_at"`
	Source      string `json:"source"`
	File        string `json:"file"`
}

// InstalledDB is the whole of state/installed.json.
type InstalledDB struct {
	Programs map[string]InstalledEntry `json:"programs"`
	LastRun  string                    `json:"last_run"`
}

func (r *Root) loadInstalled() (*InstalledDB, error) {
	db := &InstalledDB{Programs: map[string]InstalledEntry{}}
	b, err := os.ReadFile(r.installedPath())
	if errors.Is(err, os.ErrNotExist) {
		return db, nil
	}
	if err != nil {
		return nil, err
	}
	if err := json.Unmarshal(b, db); err != nil {
		return nil, fmt.Errorf("%s is corrupt: %w", r.installedPath(), err)
	}
	if db.Programs == nil {
		db.Programs = map[string]InstalledEntry{}
	}
	return db, nil
}

// saveInstalled rewrites the manifest atomically. Unlike the ledger this file
// IS rewritten -- it is a current-state snapshot, not a history. The history
// is the ledger, and that is append-only.
func (r *Root) saveInstalled(db *InstalledDB) error {
	db.LastRun = time.Now().UTC().Format(time.RFC3339Nano)
	b, err := json.MarshalIndent(db, "", "  ")
	if err != nil {
		return err
	}
	b = append(b, '\n')
	if err := os.MkdirAll(r.state(), 0o755); err != nil {
		return err
	}
	tmp := r.installedPath() + partSuffix
	if err := os.WriteFile(tmp, b, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, r.installedPath())
}

// installedSlugs returns the installed slugs in catalogue order.
func (db *InstalledDB) installedSlugs() []string {
	var out []string
	for s := range db.Programs {
		out = append(out, s)
	}
	sort.Slice(out, func(i, j int) bool {
		return catalogRank(out[i]) < catalogRank(out[j])
	})
	return out
}

// diskUsed adds up the bytes the install root is actually occupying.
func (r *Root) diskUsed() (int64, error) {
	var total int64
	err := filepath.Walk(r.Path, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}
		if info.Mode().IsRegular() {
			total += info.Size()
		}
		return nil
	})
	return total, err
}
