package main

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

// Where programs come from
// ========================
//
// The Suite never downloads anything. It takes programs from a folder the
// customer already has -- whatever they downloaded, copied off a USB stick,
// or were handed by somebody who had -- or from a .tsb bundle made from such
// a folder.
//
// A source folder can be laid out either way round, because both turn up in
// practice:
//
//	<from>/<slug>/<slug>-<os>-<arch>[.exe]     the download layout
//	<from>/<slug>/downloads/<slug>-<os>-<arch>[.exe]
//	<from>/<slug>-<os>-<arch>[.exe]            everything in one folder
//	<from>/<slug>/<slug>[.exe]                 already unpacked
//	<from>/<slug>[.exe]                        one file per program
//
// Versions come from, in order of preference:
//
//	<from>/versions.txt        lines of "<slug> <version>"
//	<from>/<slug>/VERSION      one line
//	the compiled-in catalogue  (currently 1.0.0 for everything)
//
// This is how `update` can ever report anything: a newer drop of binaries in
// a source folder states its version, and the Suite compares it against what
// is on the machine. There is no version number inside the binaries to read,
// and inventing one would be a lie.

// SourceFile is one program located in a source directory, hashed and ready
// either to install or to put into a bundle.
type SourceFile struct {
	Slug     string
	Name     string
	Group    string
	Version  string
	Path     string
	Filename string
	Size     int64
	SHA256   string
}

// Source is a source directory with its version table already read.
type Source struct {
	Dir      string
	versions map[string]string
}

// errNotInSource means the source folder simply does not have that program.
var errNotInSource = errors.New("not in the source folder")

func openSource(dir string) (*Source, error) {
	dir = strings.Trim(strings.TrimSpace(dir), `"`)
	if dir == "" {
		return nil, errors.New("no source folder given (use --from <dir>)")
	}
	abs, err := filepath.Abs(dir)
	if err != nil {
		return nil, err
	}
	info, err := os.Stat(abs)
	if err != nil {
		return nil, fmt.Errorf("source folder %s: %w", abs, err)
	}
	if !info.IsDir() {
		return nil, fmt.Errorf("source folder %s is a file, not a folder", abs)
	}
	s := &Source{Dir: abs, versions: map[string]string{}}
	s.readVersions()
	return s, nil
}

// readVersions loads <from>/versions.txt if it is there. Unreadable or
// malformed lines are skipped rather than fatal: a missing version table just
// means everything falls back to the catalogue version.
func (s *Source) readVersions() {
	f, err := os.Open(filepath.Join(s.Dir, "versions.txt"))
	if err != nil {
		return
	}
	defer f.Close()
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		fields := strings.Fields(line)
		if len(fields) != 2 {
			continue
		}
		s.versions[strings.ToLower(fields[0])] = fields[1]
	}
}

// version reports the version the source folder claims for a program.
func (s *Source) version(slug string) string {
	if v, ok := s.versions[slug]; ok {
		return v
	}
	b, err := os.ReadFile(filepath.Join(s.Dir, slug, "VERSION"))
	if err == nil {
		if v := strings.TrimSpace(string(b)); v != "" {
			return v
		}
	}
	return catalogVersion
}

// candidatePaths lists, in preference order, where a program's binary might
// be inside the source folder for this platform.
func candidatePaths(dir, slug string) []string {
	ext := ""
	if runtime.GOOS == "windows" {
		ext = ".exe"
	}
	platform := fmt.Sprintf("%s-%s-%s%s", slug, runtime.GOOS, runtime.GOARCH, ext)
	return []string{
		filepath.Join(dir, slug, "downloads", platform),
		filepath.Join(dir, slug, platform),
		filepath.Join(dir, platform),
		filepath.Join(dir, slug, "downloads", slug+ext),
		filepath.Join(dir, slug, slug+ext),
		filepath.Join(dir, slug+ext),
	}
}

// find locates and hashes one program in the source folder.
func (s *Source) find(slug string) (SourceFile, error) {
	p, ok := lookupProgram(slug)
	if !ok {
		return SourceFile{}, fmt.Errorf("%q is not a Techlosoft program", slug)
	}
	for _, cand := range candidatePaths(s.Dir, slug) {
		info, err := os.Stat(cand)
		if err != nil || !info.Mode().IsRegular() {
			continue
		}
		sum, err := hashFile(cand)
		if err != nil {
			return SourceFile{}, err
		}
		name := slug
		if runtime.GOOS == "windows" {
			name += ".exe"
		}
		return SourceFile{
			Slug:     slug,
			Name:     p.Name,
			Group:    p.Group,
			Version:  s.version(slug),
			Path:     cand,
			Filename: name,
			Size:     info.Size(),
			SHA256:   sum,
		}, nil
	}
	return SourceFile{}, fmt.Errorf("%s: %w (looked in %s)", slug, errNotInSource, s.Dir)
}

// available reports which catalogue programs this source folder can supply.
// It stats only -- no hashing -- so it stays fast enough to call before every
// listing.
func (s *Source) available() map[string]bool {
	out := map[string]bool{}
	for _, p := range catalog {
		for _, cand := range candidatePaths(s.Dir, p.Slug) {
			if info, err := os.Stat(cand); err == nil && info.Mode().IsRegular() {
				out[p.Slug] = true
				break
			}
		}
	}
	return out
}

// sizeOf reports the on-disk size of a program in the source folder without
// hashing it, for the running total the wizard shows while you pick.
func (s *Source) sizeOf(slug string) int64 {
	for _, cand := range candidatePaths(s.Dir, slug) {
		if info, err := os.Stat(cand); err == nil && info.Mode().IsRegular() {
			return info.Size()
		}
	}
	return 0
}

// hashFile returns the lowercase hex SHA-256 of a file's contents.
func hashFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}
