// sellkeys Auth – Go SDK (Go 1.21+, standard library only). Copy this file into your module as package sellkeys.
//
//	auth, err := sellkeys.New("APP_ID", "PUBLIC_KEY", "1.0")
//	if _, err = auth.Init(""); err == nil {
//		var lic *sellkeys.License
//		if lic, err = auth.License("XXXXX-XXXXX-XXXXX-XXXXX"); err == nil {
//			fmt.Println("Welcome! Level", lic.Level)
//		}
//	}
//	var e *sellkeys.Error
//	if errors.As(err, &e) {
//		fmt.Println("Login failed:", e.Code, e.Message)
//	}
//
// Every answer is signed with your app's Ed25519 key; the SDK rejects wrong signatures and replayed nonces, so a fake
// server cannot unlock your program. (It does not protect against patching your program.)
package sellkeys

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"regexp"
	"runtime"
	"strings"
	"time"
)

const DefaultAPIURL = "https://auth.sellkeys.cc/v1"

// Error is returned for every failed call. Data holds the full server answer (e.g. Data["update_url"] for "update_required").
type Error struct {
	Code    string
	Message string
	Data    map[string]any
}

func (e *Error) Error() string { return e.Code + ": " + e.Message }

type AppInfo struct {
	Name      string  `json:"name"`
	Version   string  `json:"version"`
	UpdateURL *string `json:"update_url"`
}

type License struct {
	Level       int     `json:"level"`
	ExpiresAt   *string `json:"expires_at"` // ISO date, nil = lifetime
	Devices     int     `json:"devices"`
	DeviceLimit int     `json:"device_limit"`
}

type User struct {
	Username    string  `json:"username"`
	Level       *int    `json:"level"`
	ExpiresAt   *string `json:"expires_at"`
	Licenses    int     `json:"licenses"`
	Devices     int     `json:"devices"`
	DeviceLimit int     `json:"device_limit"`
}

type Auth struct {
	AppID, Version, APIURL, Hwid string
	Client                       *http.Client
	Session                      string
	App                          *AppInfo
	LicenseInfo                  *License
	User                         *User
	pub                          ed25519.PublicKey
}

// New creates a client for https://auth.sellkeys.cc/v1 with the default hardware id; change APIURL / Hwid afterwards if needed.
func New(appID, publicKey, version string) (*Auth, error) {
	pub, err := base64.StdEncoding.DecodeString(publicKey)
	if err != nil || len(pub) != ed25519.PublicKeySize {
		return nil, &Error{Code: "bad_public_key", Message: "The public key must be 32 bytes, base64."}
	}
	return &Auth{AppID: appID, Version: version, APIURL: DefaultAPIURL, Hwid: DefaultHwid(),
		Client: &http.Client{Timeout: 15 * time.Second}, pub: pub}, nil
}

func sha256hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }

// DefaultHwid returns a stable per-machine id (hashed, the raw value never leaves the machine).
func DefaultHwid() string {
	raw := ""
	switch runtime.GOOS {
	case "windows":
		out, _ := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid", "/reg:64").Output()
		if m := regexp.MustCompile(`MachineGuid\s+REG_SZ\s+(\S+)`).FindSubmatch(out); m != nil {
			raw = string(m[1])
		}
	case "darwin":
		out, _ := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output()
		if m := regexp.MustCompile(`"IOPlatformUUID" = "([^"]+)"`).FindSubmatch(out); m != nil {
			raw = string(m[1])
		}
	default:
		for _, p := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
			if b, err := os.ReadFile(p); err == nil && strings.TrimSpace(string(b)) != "" {
				raw = strings.TrimSpace(string(b))
				break
			}
		}
	}
	if raw == "" {
		host, _ := os.Hostname()
		ifs, _ := net.Interfaces()
		for _, i := range ifs {
			if i.Flags&net.FlagLoopback == 0 && len(i.HardwareAddr) > 0 {
				raw = i.HardwareAddr.String()
				break
			}
		}
		raw += "-" + host
	}
	return sha256hex([]byte(raw))
}

// FileHash returns the SHA-256 of your program file (for the app's hash check). Empty path = the running executable.
func FileHash(path string) (string, error) {
	if path == "" {
		var err error
		if path, err = os.Executable(); err != nil {
			return "", err
		}
	}
	b, err := os.ReadFile(path)
	if err != nil {
		return "", err
	}
	return sha256hex(b), nil
}

func (a *Auth) call(typ string, fields map[string]string) (map[string]json.RawMessage, error) {
	nb := make([]byte, 16)
	if _, err := rand.Read(nb); err != nil {
		return nil, err
	}
	nonce := hex.EncodeToString(nb)
	form := url.Values{"type": {typ}, "app": {a.AppID}, "nonce": {nonce}}
	for k, v := range fields {
		if v != "" {
			form.Set(k, v)
		}
	}
	r, err := a.Client.PostForm(a.APIURL, form)
	if err != nil {
		return nil, &Error{Code: "network_error", Message: "Could not reach the license server: " + err.Error()}
	}
	defer r.Body.Close()
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		return nil, &Error{Code: "network_error", Message: "Could not reach the license server: " + err.Error()}
	}
	sig := r.Header.Get("X-Sellkeys-Signature")
	if sig == "" { // only errors before the app is known (rate limit, unknown app) are unsigned
		var b struct{ Code, Message string }
		_ = json.Unmarshal(raw, &b)
		if b.Code == "" {
			b.Code = fmt.Sprintf("http_%d", r.StatusCode)
		}
		if b.Message == "" {
			b.Message = "Unexpected server response."
		}
		return nil, &Error{Code: b.Code, Message: b.Message}
	}
	sb, err := base64.StdEncoding.DecodeString(sig)
	if err != nil || !ed25519.Verify(a.pub, raw, sb) {
		return nil, &Error{Code: "bad_signature", Message: "The server response is not signed by sellkeys."}
	}
	var body map[string]json.RawMessage
	var head struct {
		Success       bool
		Code, Message string
		Nonce         string
	}
	if json.Unmarshal(raw, &body) != nil || json.Unmarshal(raw, &head) != nil {
		return nil, &Error{Code: "bad_response", Message: "The server response is not valid JSON."}
	}
	if head.Nonce != nonce {
		return nil, &Error{Code: "bad_nonce", Message: "The server response does not belong to this request."}
	}
	if !head.Success {
		var data map[string]any
		_ = json.Unmarshal(raw, &data)
		if head.Code == "" {
			head.Code = "error"
		}
		return nil, &Error{Code: head.Code, Message: head.Message, Data: data}
	}
	return body, nil
}

func field[T any](body map[string]json.RawMessage, key string) (T, error) {
	var v T
	if err := json.Unmarshal(body[key], &v); err != nil {
		return v, &Error{Code: "bad_response", Message: "Missing field " + key + " in the server response."}
	}
	return v, nil
}

func (a *Auth) ensureSession() error {
	if a.Session == "" {
		_, err := a.Init("")
		return err
	}
	return nil
}

// Init starts a session. hash is optional (see FileHash). Fails with code "update_required" (see Data["update_url"]) if your version is too old.
func (a *Auth) Init(hash string) (*AppInfo, error) {
	b, err := a.call("init", map[string]string{"version": a.Version, "hash": hash})
	if err != nil {
		return nil, err
	}
	if a.Session, err = field[string](b, "session"); err != nil {
		return nil, err
	}
	a.App, err = field[*AppInfo](b, "app")
	return a.App, err
}

func (a *Auth) License(key string) (*License, error) {
	if err := a.ensureSession(); err != nil {
		return nil, err
	}
	b, err := a.call("license", map[string]string{"session": a.Session, "key": strings.TrimSpace(key), "hwid": a.Hwid})
	if err != nil {
		return nil, err
	}
	a.LicenseInfo, err = field[*License](b, "license")
	return a.LicenseInfo, err
}

// Check re-checks the session's license or account; call it periodically. Exactly one of the results is set.
func (a *Auth) Check() (*License, *User, error) {
	b, err := a.call("check", map[string]string{"session": a.Session})
	if err != nil {
		return nil, nil, err
	}
	if _, ok := b["user"]; ok {
		a.User, err = field[*User](b, "user")
		return nil, a.User, err
	}
	a.LicenseInfo, err = field[*License](b, "license")
	return a.LicenseInfo, nil, err
}

// ---- accounts (username + password; the key is redeemed to the account) ----

func (a *Auth) userCall(typ string, fields map[string]string) (*User, error) {
	if err := a.ensureSession(); err != nil {
		return nil, err
	}
	fields["session"] = a.Session
	b, err := a.call(typ, fields)
	if err != nil {
		return nil, err
	}
	return field[*User](b, "user")
}

func (a *Auth) Register(username, password, key string) (u *User, err error) {
	if u, err = a.userCall("register", map[string]string{"username": username, "password": password, "key": strings.TrimSpace(key), "hwid": a.Hwid}); err == nil {
		a.User = u
	}
	return
}

func (a *Auth) Login(username, password string) (u *User, err error) {
	if u, err = a.userCall("login", map[string]string{"username": username, "password": password, "hwid": a.Hwid}); err == nil {
		a.User = u
	}
	return
}

// Upgrade adds another license (renewal, higher level) to an existing account.
func (a *Auth) Upgrade(username, key string) (*User, error) {
	return a.userCall("upgrade", map[string]string{"username": username, "key": strings.TrimSpace(key)})
}

// ---- content ----

// Var returns a global app variable (set in the dashboard).
func (a *Auth) Var(name string) (string, error) {
	b, err := a.call("var", map[string]string{"session": a.Session, "name": name})
	if err != nil {
		return "", err
	}
	return field[string](b, "value")
}

func (a *Auth) GetUserVar(name string) (string, error) {
	b, err := a.call("getvar", map[string]string{"session": a.Session, "name": name})
	if err != nil {
		return "", err
	}
	return field[string](b, "value")
}

func (a *Auth) SetUserVar(name, value string) error {
	_, err := a.call("setvar", map[string]string{"session": a.Session, "name": name, "value": value})
	return err
}

// Download fetches an app file and checks it against the signed SHA-256.
func (a *Auth) Download(fileID string) ([]byte, error) {
	b, err := a.call("file", map[string]string{"session": a.Session, "file": fileID})
	if err != nil {
		return nil, err
	}
	u, err := field[string](b, "url")
	if err != nil {
		return nil, err
	}
	want, err := field[string](b, "sha256")
	if err != nil {
		return nil, err
	}
	c := *a.Client
	if c.Timeout < 120*time.Second {
		c.Timeout = 120 * time.Second
	}
	r, err := c.Get(u)
	if err != nil {
		return nil, &Error{Code: "network_error", Message: "Download failed: " + err.Error()}
	}
	defer r.Body.Close()
	data, err := io.ReadAll(r.Body)
	if err != nil || r.StatusCode != 200 {
		return nil, &Error{Code: "network_error", Message: fmt.Sprintf("Download failed: HTTP %d", r.StatusCode)}
	}
	if subtle.ConstantTimeCompare([]byte(sha256hex(data)), []byte(want)) != 1 {
		return nil, &Error{Code: "bad_file_hash", Message: "The downloaded file does not match its signed checksum."}
	}
	return data, nil
}

// Webhook calls a webhook set in the dashboard through sellkeys (its URL and keys stay on the server).
// params, body and contentType are optional (""). Returns the HTTP status and response text.
func (a *Auth) Webhook(name, params, body, contentType string) (int, string, error) {
	b, err := a.call("webhook", map[string]string{"session": a.Session, "webid": name, "params": params, "body": body, "content_type": contentType})
	if err != nil {
		return 0, "", err
	}
	status, err := field[int](b, "status")
	if err != nil {
		return 0, "", err
	}
	resp, err := field[string](b, "response")
	return status, resp, err
}

// Log writes a line to the app's logs in the sellkeys dashboard.
func (a *Auth) Log(message string) error {
	if r := []rune(message); len(r) > 500 {
		message = string(r[:500])
	}
	_, err := a.call("log", map[string]string{"session": a.Session, "message": message})
	return err
}

func (a *Auth) Logout() error {
	if a.Session == "" {
		return nil
	}
	_, err := a.call("logout", map[string]string{"session": a.Session})
	a.Session = ""
	return err
}
