package main

import (
	"crypto/ed25519"
	"encoding/base32"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"
	"unicode/utf8"
)

// Licensing
// =========
//
// A licence key is a signed statement, checked entirely on this machine.
// There is no activation server and no network call -- this program contains
// no network code at all, and the licensing was built to keep that true
// rather than become the excuse for breaking it.
//
// The key carries: which plan, how many of your own computers, who it is for,
// when it was issued, and the date updates run to. The signature means this
// program can tell a key we issued from any other string of letters, and can
// tell if one character of a real key has been altered -- but it cannot mint
// one, because making keys needs a private key that only Techlosoft holds and
// that is not inside this binary.
//
// What activation does: verifies the key and writes one small file,
// <root>/state/license.json. What it does not do: switch anything on or off.
// Every program works the same either way; while Techlosoft is in preview
// nothing needs a licence at all.
//
// Wire format, shared with the (private, unshipped) issuing tool:
//
//	payload := ver(1)=1 | plan(1) | seats(1) | issuedDays(2,BE) |
//	           updatesDays(2,BE; 0 = no end) | nameLen(1) | name(<=80)
//	signed  := "techlosoft licence 1\n" + payload
//	key     := "TL1-" + base32(payload || ed25519 signature), grouped in fives
//
// license_test.go holds keys issued by the real tool, so the two halves of
// the format cannot drift apart without a test failing.
const (
	licenceFormatVersion = 1
	licenceKeyPrefix     = "TL1"
	licenceContext       = "techlosoft licence 1\n"
	licenceFileName      = "license.json"

	// The public half of the Techlosoft signing key. Replacing this without
	// holding the matching private key gets you a binary that rejects every
	// real key and accepts your own -- which you could also achieve by
	// deleting the check, so it defends against mistakes, not against you.
	licencePublicKeyHex = "67e372f12b281c6efa445756b813ff09e575daaaaebca0edf8e78a325ab0bf7a"
)

var licencePlanNames = map[byte]string{1: "yearly", 2: "one-time", 3: "lifetime"}

// Licence is what a verified key says.
type Licence struct {
	Plan         string    `json:"plan"`
	Seats        int       `json:"seats"`
	Name         string    `json:"licensed_to,omitempty"`
	Issued       time.Time `json:"issued"`
	UpdatesUntil time.Time `json:"updates_until,omitempty"` // zero = no end date
}

// StoredLicence is the on-disk record: the key itself plus what it decodes
// to, so `licence` can answer without re-deriving anything, and the key is
// recoverable from the machine it was typed into.
type StoredLicence struct {
	Key         string    `json:"key"`
	ActivatedAt time.Time `json:"activated_at"`
	Licence
}

// verifyLicenceKey checks a key against a given public key. The embedded-key
// wrapper below is what commands call; taking the key as a parameter keeps
// the whole path testable with a throwaway pair.
func verifyLicenceKey(pub ed25519.PublicKey, key string) (Licence, error) {
	var lic Licence
	s := strings.ToUpper(strings.Map(func(r rune) rune {
		if r == '-' || r == ' ' || r == '\n' || r == '\t' || r == '\r' {
			return -1
		}
		return r
	}, key))
	if !strings.HasPrefix(s, licenceKeyPrefix) {
		return lic, errors.New("that does not look like a Techlosoft licence key: they start with TL1-")
	}
	blob, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s[len(licenceKeyPrefix):])
	if err != nil {
		return lic, errors.New("the key has a typo somewhere: check it against your receipt")
	}
	if len(blob) < 8+ed25519.SignatureSize {
		return lic, errors.New("part of the key is missing: check you copied all of it")
	}
	payload := blob[:len(blob)-ed25519.SignatureSize]
	sig := blob[len(blob)-ed25519.SignatureSize:]
	if !ed25519.Verify(pub, append([]byte(licenceContext), payload...), sig) {
		return lic, errors.New("this is not a key we issued, or one character of it has changed: check it against your receipt")
	}
	// Everything below this line is signed, so a failure here means a key WE
	// issued is malformed -- worth wording differently, because the customer
	// did nothing wrong.
	if payload[0] != licenceFormatVersion {
		return lic, fmt.Errorf("this key is a newer kind (%d) than this build understands: update the Suite", payload[0])
	}
	plan, ok := licencePlanNames[payload[1]]
	if !ok {
		return lic, errors.New("this key names a plan this build does not know: update the Suite")
	}
	if int(payload[7]) != len(payload)-8 {
		return lic, errors.New("this key is damaged in a way that should not happen: write to us")
	}
	name := string(payload[8:])
	if !utf8.ValidString(name) {
		return lic, errors.New("this key is damaged in a way that should not happen: write to us")
	}
	lic.Plan = plan
	lic.Seats = int(payload[2])
	lic.Name = name
	lic.Issued = time.Unix(int64(binary.BigEndian.Uint16(payload[3:5]))*86400, 0).UTC()
	if u := binary.BigEndian.Uint16(payload[5:7]); u != 0 {
		lic.UpdatesUntil = time.Unix(int64(u)*86400, 0).UTC()
	}
	return lic, nil
}

func verifyTechlosoftKey(key string) (Licence, error) {
	pub, err := hex.DecodeString(licencePublicKeyHex)
	if err != nil || len(pub) != ed25519.PublicKeySize {
		// Unreachable in a correct build; better a plain sentence than a panic.
		return Licence{}, errors.New("this build was made wrongly and cannot check keys: download it again")
	}
	return verifyLicenceKey(pub, key)
}

func (r *Root) licencePath() string { return filepath.Join(r.state(), licenceFileName) }

// loadLicence returns the stored licence, or nil if this machine has none.
// A corrupt file is reported as an error rather than treated as absence,
// because "you have no licence" and "your licence file is damaged" call for
// different next steps.
func (r *Root) loadLicence() (*StoredLicence, error) {
	raw, err := os.ReadFile(r.licencePath())
	if os.IsNotExist(err) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	var s StoredLicence
	if err := json.Unmarshal(raw, &s); err != nil {
		return nil, fmt.Errorf("the licence file at %s is damaged: activate again with the key from your receipt", r.licencePath())
	}
	return &s, nil
}

// ------------------------------------------------------------ activate ----

func cmdActivate(args []string) {
	fs := flag.NewFlagSet("activate", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	root := fs.String("root", defaultRoot(), "install root")
	yes := fs.Bool("yes", false, "confirm the install root without being asked")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"root": true})); err != nil {
		os.Exit(1)
	}
	if fs.NArg() == 0 {
		die("activate needs the licence key from your receipt:\n\n" +
			"  techlosoft-suite activate TL1-XXXXX-XXXXX-...\n\n" +
			"(Paste the whole thing; spaces and line breaks from the email do not matter.)")
	}
	// The key is long and mail clients wrap it, so accept it in pieces.
	key := strings.Join(fs.Args(), "")

	lic, err := verifyTechlosoftKey(key)
	if err != nil {
		die("That key did not check out.\n\n  %v\n\nNothing has been changed.", err)
	}

	r := mustRoot(*root, *yes)
	previous, err := r.loadLicence()
	if err != nil {
		// Damaged file: activating over it is exactly the fix, so say so and
		// carry on rather than stopping.
		fmt.Fprintf(os.Stderr, "note: %v\n", err)
	}

	stored := StoredLicence{Key: key, ActivatedAt: time.Now().UTC(), Licence: lic}
	raw, _ := json.MarshalIndent(stored, "", "  ")
	if err := os.MkdirAll(r.state(), 0o755); err != nil {
		die("error: %v", err)
	}
	if err := os.WriteFile(r.licencePath(), append(raw, '\n'), 0o644); err != nil {
		die("error: %v", err)
	}
	detail := lic.Plan
	if lic.Name != "" {
		detail += " for " + lic.Name
	}
	if previous != nil && previous.Key != key {
		detail += " (replacing an earlier licence)"
	}
	if err := r.appendLedger(LedgerRecord{
		TS: time.Now().UTC().Format(time.RFC3339), Action: "activate",
		Path: r.licencePath(), Result: "activated", Detail: detail,
	}); err != nil {
		fmt.Fprintf(os.Stderr, "warning: could not write the ledger: %v\n", err)
	}

	if *jsonOut {
		emitJSON(map[string]any{"activated": true, "path": r.licencePath(), "licence": stored})
		return
	}
	fmt.Println("Done. This machine now carries your licence.")
	printLicence(&stored)
	if previous != nil && previous.Key != key {
		fmt.Println("\nThe earlier licence on this machine has been replaced.")
	}
}

// ------------------------------------------------------------- licence ----

func cmdLicence(args []string) {
	fs := flag.NewFlagSet("licence", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	root := fs.String("root", defaultRoot(), "install root")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"root": true})); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		die("licence takes no positional arguments (got %q)", fs.Arg(0))
	}
	noLicence := func() {
		if *jsonOut {
			emitJSON(map[string]any{"licensed": false})
			return
		}
		fmt.Println("No licence on this machine -- and none is needed yet: everything")
		fmt.Println("is free while Techlosoft is in preview.")
		fmt.Println("\nWhen you have a key:  techlosoft-suite activate TL1-...")
	}
	r, err := openRoot(*root)
	if err == ErrUnconfirmedRoot {
		noLicence()
		return
	}
	if err != nil {
		die("error: %v", err)
	}
	s, err := r.loadLicence()
	if err != nil {
		die("error: %v", err)
	}
	if s == nil {
		noLicence()
		return
	}
	if *jsonOut {
		emitJSON(map[string]any{"licensed": true, "path": r.licencePath(), "licence": s})
		return
	}
	printLicence(s)
}

func printLicence(s *StoredLicence) {
	fmt.Printf("  plan          : %s\n", s.Plan)
	if s.Name != "" {
		fmt.Printf("  licensed to   : %s\n", s.Name)
	}
	fmt.Printf("  your machines : up to %d\n", s.Seats)
	if s.UpdatesUntil.IsZero() {
		fmt.Println("  updates until : no end date")
		return
	}
	fmt.Printf("  updates until : %s\n", s.UpdatesUntil.Format("2 Jan 2006"))
	if time.Now().After(s.UpdatesUntil) {
		fmt.Println("\nThat date has passed. Everything you have keeps working exactly as")
		fmt.Println("it is; builds newer than that date are outside this licence.")
	}
}
