package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"time"
)

// How an install is made atomic
// =============================
//
// For every program:
//
//  1. the bytes are streamed to <root>/bin/<slug>.part, hashing as they go
//  2. the hash of what actually landed on disk is compared with the hash the
//     source or the bundle stated
//  3. only on a match is the .part renamed onto the final name
//
// rename(2) within one directory is atomic on every filesystem this program
// can be running on, so at no point does a half-written or wrong-hash file
// exist under the name the customer will run.
//
// If anything fails, the .part file -- which this program created moments
// ago, in its own install root, and which no customer has ever seen -- is
// removed, and the final name is left exactly as it was. A failed install of
// a new program leaves nothing; a failed update leaves the old version still
// installed and still working.

// hashCheck compares an expected hash with what was actually written.
//
// It is a named function on purpose: it is the single point where a corrupt
// download is caught, and a test mutates exactly this to prove the tests are
// not vacuous.
func hashCheck(expected, got string) error {
	if !strings.EqualFold(expected, got) {
		return fmt.Errorf("hash mismatch: expected %s, got %s", expected, got)
	}
	return nil
}

// testHookAfterWrite is nil in every shipped build. The atomicity test sets
// it to inject a failure at the one moment that matters: after the bytes are
// on disk under the .part name, before the rename.
var testHookAfterWrite func(slug, partPath string) error

// InstallResult is what happened to one program.
type InstallResult struct {
	Slug    string `json:"slug"`
	Name    string `json:"name"`
	Action  string `json:"action"` // installed, updated, unchanged, failed, skipped
	Version string `json:"version,omitempty"`
	From    string `json:"from_version,omitempty"`
	SHA256  string `json:"sha256,omitempty"`
	Size    int64  `json:"size,omitempty"`
	Path    string `json:"path,omitempty"`
	Error   string `json:"error,omitempty"`
}

// payload is one thing to install: a reader, a length, an expected hash and
// enough catalogue detail to record it. It is what both a source folder and a
// bundle reduce to, so installFrom does not care which one it came from.
type payload struct {
	Slug    string
	Name    string
	Group   string
	Version string
	Size    int64
	SHA256  string
	Source  string
	Open    func() (io.ReadCloser, error)
}

func payloadFromSource(sf SourceFile) payload {
	return payload{
		Slug: sf.Slug, Name: sf.Name, Group: sf.Group, Version: sf.Version,
		Size: sf.Size, SHA256: sf.SHA256, Source: sf.Path,
		Open: func() (io.ReadCloser, error) { return os.Open(sf.Path) },
	}
}

func payloadFromBundle(b *Bundle, e BundleEntry, bundlePath string) payload {
	return payload{
		Slug: e.Slug, Name: e.Name, Group: e.Group, Version: e.Version,
		Size: e.Size, SHA256: e.SHA256, Source: bundlePath + "!" + e.Filename,
		Open: func() (io.ReadCloser, error) {
			return io.NopCloser(b.reader(e)), nil
		},
	}
}

// installOne puts exactly one program in place, atomically, and records it.
// It returns the result rather than an error so a batch install can carry on
// past one bad file and report the whole picture at the end.
func (r *Root) installOne(db *InstalledDB, p payload, force bool) InstallResult {
	res := InstallResult{Slug: p.Slug, Name: p.Name, Version: p.Version}

	prev, wasInstalled := db.Programs[p.Slug]
	if wasInstalled {
		res.From = prev.Version
	}

	final := r.programPath(p.Slug)
	if !r.contains(final) {
		res.Action = "failed"
		res.Error = "refusing to write outside the install root"
		return res
	}

	// Re-installing exactly what is already there is a no-op, not a rewrite.
	// This is what makes `install` idempotent: run it twice, the second run
	// changes nothing and says so.
	if wasInstalled && !force && prev.SHA256 == p.SHA256 && prev.Version == p.Version {
		if _, err := os.Stat(final); err == nil {
			res.Action = "unchanged"
			res.SHA256 = prev.SHA256
			res.Size = prev.Size
			res.Path = final
			return res
		}
	}

	if err := os.MkdirAll(r.bin(), 0o755); err != nil {
		res.Action = "failed"
		res.Error = err.Error()
		return res
	}

	part := final + partSuffix
	written, sum, err := writeAndHash(part, p)
	if err != nil {
		os.Remove(part) // our own temporary file, nothing of the customer's
		res.Action = "failed"
		res.Error = err.Error()
		r.logFailure(p, res.Error)
		return res
	}

	if written != p.Size {
		os.Remove(part)
		res.Action = "failed"
		res.Error = fmt.Sprintf("short read: %d bytes of an expected %d", written, p.Size)
		r.logFailure(p, res.Error)
		return res
	}

	// THE check. The bytes on disk, not the bytes we thought we wrote.
	if err := hashCheck(p.SHA256, sum); err != nil {
		os.Remove(part)
		res.Action = "failed"
		res.Error = err.Error()
		r.logFailure(p, res.Error)
		return res
	}

	if testHookAfterWrite != nil {
		if err := testHookAfterWrite(p.Slug, part); err != nil {
			os.Remove(part)
			res.Action = "failed"
			res.Error = err.Error()
			r.logFailure(p, res.Error)
			return res
		}
	}

	if runtime.GOOS != "windows" {
		if err := os.Chmod(part, 0o755); err != nil {
			os.Remove(part)
			res.Action = "failed"
			res.Error = err.Error()
			r.logFailure(p, res.Error)
			return res
		}
	}

	if err := os.Rename(part, final); err != nil {
		os.Remove(part)
		res.Action = "failed"
		res.Error = err.Error()
		r.logFailure(p, res.Error)
		return res
	}

	entry := InstalledEntry{
		Slug: p.Slug, Name: p.Name, Group: p.Group, Version: p.Version,
		SHA256: sum, Size: written,
		InstalledAt: time.Now().UTC().Format(time.RFC3339Nano),
		Source:      p.Source, File: filepath.Base(final),
	}
	db.Programs[p.Slug] = entry

	res.Action = "installed"
	if wasInstalled {
		res.Action = "updated"
	}
	res.SHA256 = sum
	res.Size = written
	res.Path = final

	_ = r.appendLedger(LedgerRecord{
		Action: res.Action, Slug: p.Slug, Name: p.Name, Version: p.Version,
		From: res.From, SHA256: sum, Size: written, Path: final,
		Source: p.Source, Result: "ok",
	})
	return res
}

func (r *Root) logFailure(p payload, detail string) {
	_ = r.appendLedger(LedgerRecord{
		Action: "install", Slug: p.Slug, Name: p.Name, Version: p.Version,
		Source: p.Source, Result: "failed", Detail: detail,
	})
}

// writeAndHash streams a payload into path, hashing as it goes, and returns
// the byte count and the hex hash of what was actually written.
func writeAndHash(path string, p payload) (n int64, sum string, err error) {
	rc, err := p.Open()
	if err != nil {
		return 0, "", err
	}
	defer rc.Close()

	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
	if err != nil {
		return 0, "", err
	}
	h := sha256.New()
	n, err = io.Copy(io.MultiWriter(f, h), rc)
	if err != nil {
		f.Close()
		return n, "", err
	}
	// Get it on the platter before we claim it is there.
	if err := f.Sync(); err != nil {
		f.Close()
		return n, "", err
	}
	if err := f.Close(); err != nil {
		return n, "", err
	}
	return n, hex.EncodeToString(h.Sum(nil)), nil
}

// RemoveResult is what happened to one removal.
type RemoveResult struct {
	Slug   string `json:"slug"`
	Name   string `json:"name"`
	Action string `json:"action"` // moved-to-trash, not-installed, failed
	Trash  string `json:"trash,omitempty"`
	Size   int64  `json:"size,omitempty"`
	Error  string `json:"error,omitempty"`
}

// removeOne moves a program's file into the trash. It does not delete it.
//
// The trash path carries a timestamp so removing, re-installing and removing
// again keeps both copies instead of one quietly overwriting the other.
func (r *Root) removeOne(db *InstalledDB, slug string) RemoveResult {
	res := RemoveResult{Slug: slug}
	entry, ok := db.Programs[slug]
	if !ok {
		res.Action = "not-installed"
		if p, found := lookupProgram(slug); found {
			res.Name = p.Name
		}
		return res
	}
	res.Name = entry.Name

	final := r.programPath(slug)
	if !r.contains(final) {
		res.Action = "failed"
		res.Error = "refusing to touch a path outside the install root"
		return res
	}

	stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
	dir := filepath.Join(r.trash(), stamp+"-"+slug)
	if !r.contains(dir) {
		res.Action = "failed"
		res.Error = "refusing to write outside the install root"
		return res
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		res.Action = "failed"
		res.Error = err.Error()
		return res
	}
	dest := filepath.Join(dir, filepath.Base(final))

	info, statErr := os.Stat(final)
	switch {
	case statErr == nil:
		if err := moveFile(final, dest); err != nil {
			res.Action = "failed"
			res.Error = err.Error()
			_ = r.appendLedger(LedgerRecord{Action: "remove", Slug: slug, Name: entry.Name,
				Version: entry.Version, Result: "failed", Detail: err.Error()})
			return res
		}
		res.Size = info.Size()
	case os.IsNotExist(statErr):
		// Recorded as installed but the file is gone -- somebody moved it by
		// hand. Say so, drop the record, delete nothing.
		res.Action = "record-only"
		delete(db.Programs, slug)
		_ = r.appendLedger(LedgerRecord{Action: "remove", Slug: slug, Name: entry.Name,
			Version: entry.Version, Result: "ok", Detail: "file was already gone; only the record was cleared"})
		return res
	default:
		res.Action = "failed"
		res.Error = statErr.Error()
		return res
	}

	// Leave a note next to the file saying what it was, so the trash is not a
	// pile of anonymous binaries six months from now.
	note := fmt.Sprintf("%s (%s)\nversion %s\nsha256 %s\nremoved %s\nwas at %s\n",
		entry.Name, entry.Slug, entry.Version, entry.SHA256,
		time.Now().UTC().Format(time.RFC3339), final)
	_ = os.WriteFile(filepath.Join(dir, "REMOVED.txt"), []byte(note), 0o644)

	delete(db.Programs, slug)
	res.Action = "moved-to-trash"
	res.Trash = dest

	_ = r.appendLedger(LedgerRecord{
		Action: "removed", Slug: slug, Name: entry.Name, Version: entry.Version,
		SHA256: entry.SHA256, Size: res.Size, Path: final, Trash: dest, Result: "ok",
	})
	return res
}

// moveFile renames, falling back to copy-then-remove when the trash is on a
// different filesystem from bin/. The copy is verified before the original is
// touched, so a failed move never loses the file.
func moveFile(src, dst string) error {
	if err := os.Rename(src, dst); err == nil {
		return nil
	}
	before, err := hashFile(src)
	if err != nil {
		return err
	}
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
	if err != nil {
		in.Close()
		return err
	}
	_, cerr := io.Copy(out, in)
	in.Close()
	if cerr != nil {
		out.Close()
		os.Remove(dst)
		return cerr
	}
	if err := out.Close(); err != nil {
		os.Remove(dst)
		return err
	}
	after, err := hashFile(dst)
	if err != nil {
		return err
	}
	if err := hashCheck(before, after); err != nil {
		os.Remove(dst)
		return fmt.Errorf("copy into trash did not match the original, leaving the original alone: %w", err)
	}
	return os.Remove(src)
}

// TrashItem is one thing sitting in the trash.
type TrashItem struct {
	Dir   string `json:"dir"`
	Files int    `json:"files"`
	Bytes int64  `json:"bytes"`
	When  string `json:"when"`
}

func (r *Root) listTrash() ([]TrashItem, error) {
	entries, err := os.ReadDir(r.trash())
	if os.IsNotExist(err) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	var out []TrashItem
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		item := TrashItem{Dir: e.Name()}
		sub := filepath.Join(r.trash(), e.Name())
		_ = filepath.Walk(sub, func(p string, info os.FileInfo, err error) error {
			if err != nil || info.IsDir() {
				return nil
			}
			item.Files++
			item.Bytes += info.Size()
			return nil
		})
		if info, err := e.Info(); err == nil {
			item.When = info.ModTime().UTC().Format(time.RFC3339)
		}
		out = append(out, item)
	}
	return out, nil
}

// purgeTrash empties the trash folder, and nothing else. It re-checks that
// every path it is about to touch is genuinely inside <root>/trash before
// touching it -- this is the only code in the program that deletes anything a
// customer might care about, so it verifies rather than trusts.
func (r *Root) purgeTrash() (files int, bytes int64, err error) {
	trash := r.trash()
	entries, err := os.ReadDir(trash)
	if os.IsNotExist(err) {
		return 0, 0, nil
	}
	if err != nil {
		return 0, 0, err
	}
	for _, e := range entries {
		target := filepath.Join(trash, e.Name())
		rel, rerr := filepath.Rel(trash, target)
		if rerr != nil || rel == "." || strings.HasPrefix(rel, "..") {
			return files, bytes, fmt.Errorf("refusing to purge %s: it is not inside %s", target, trash)
		}
		_ = filepath.Walk(target, func(p string, info os.FileInfo, werr error) error {
			if werr != nil || info.IsDir() {
				return nil
			}
			files++
			bytes += info.Size()
			return nil
		})
		if err := os.RemoveAll(target); err != nil {
			return files, bytes, err
		}
	}
	_ = r.appendLedger(LedgerRecord{Action: "purged-trash", Size: bytes, Result: "ok",
		Detail: strconv.Itoa(files) + " files"})
	return files, bytes, nil
}

// UpdateInfo is what `update --check` found for one installed program.
type UpdateInfo struct {
	Slug        string `json:"slug"`
	Name        string `json:"name"`
	Installed   string `json:"installed_version"`
	Available   string `json:"available_version"`
	Reason      string `json:"reason"` // newer-version, rebuilt, up-to-date, no-source
	SourceSHA   string `json:"source_sha256,omitempty"`
	InstalledSH string `json:"installed_sha256,omitempty"`
	Size        int64  `json:"size,omitempty"`
}

// needsUpdate decides whether an installed program has something newer
// waiting in the source folder.
//
// Two independent signals, in this order:
//
//	newer-version  the source states a version greater than the installed one
//	rebuilt        same version, different bytes -- a rebuild of the same
//	               release, which is worth offering but is not a new version
//
// A source version OLDER than what is installed is never offered. Downgrading
// somebody silently would be worse than doing nothing.
func needsUpdate(inst InstalledEntry, src SourceFile) UpdateInfo {
	u := UpdateInfo{
		Slug: inst.Slug, Name: inst.Name,
		Installed: inst.Version, Available: src.Version,
		SourceSHA: src.SHA256, InstalledSH: inst.SHA256, Size: src.Size,
	}
	switch cmp := compareVersions(src.Version, inst.Version); {
	case cmp > 0:
		u.Reason = "newer-version"
	case cmp == 0 && !strings.EqualFold(src.SHA256, inst.SHA256):
		u.Reason = "rebuilt"
	default:
		u.Reason = "up-to-date"
	}
	return u
}

// compareVersions compares dotted numeric versions: -1, 0 or 1.
//
// Segments are compared numerically where both sides are numbers and
// lexically otherwise, and a missing segment counts as zero, so 1.2 == 1.2.0
// and 1.10 > 1.9. Anything it genuinely cannot order compares as equal, which
// falls back to the hash check rather than guessing.
func compareVersions(a, b string) int {
	as := strings.Split(strings.TrimPrefix(strings.TrimSpace(a), "v"), ".")
	bs := strings.Split(strings.TrimPrefix(strings.TrimSpace(b), "v"), ".")
	n := len(as)
	if len(bs) > n {
		n = len(bs)
	}
	for i := 0; i < n; i++ {
		x, y := "0", "0"
		if i < len(as) {
			x = as[i]
		}
		if i < len(bs) {
			y = bs[i]
		}
		xi, xerr := strconv.Atoi(x)
		yi, yerr := strconv.Atoi(y)
		if xerr == nil && yerr == nil {
			if xi != yi {
				if xi > yi {
					return 1
				}
				return -1
			}
			continue
		}
		if x != y {
			if x > y {
				return 1
			}
			return -1
		}
	}
	return 0
}
