// Liney starter, Go: the four handlers (write, undo, and the two pushes to Liney), the pull-mode worker
// and the signature check, over a tiny in-memory CRM. Standard library only; Go 1.21 or later.
// The contract: https://www.liney.ai/developers/llms.txt
//
//	LINEY_URL=https://www.liney.ai LINEY_KEY=lk_test_… LINEY_SECRET=ls_… MODE=pull go run main.go
//	MODE=push PORT=8787 … go run main.go      (then give Liney https://<public host>/ as the endpoint)
package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"
)

var (
	lineyURL = strings.TrimRight(env("LINEY_URL", "https://www.liney.ai"), "/")
	lineyKey = env("LINEY_KEY", "")
	secrets  = splitSecrets(env("LINEY_SECRET", "")) // current, and the previous one during a rotation
	mode     = env("MODE", "pull")
	port     = env("PORT", "8787")
	api      = lineyURL + "/api/crm/custom/v1"
)

const windowS = 300

func env(k, d string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return d
}
func splitSecrets(s string) []string {
	var out []string
	for _, p := range strings.Split(s, ",") {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}

// ---- the CRM ---------------------------------------------------------------------------------------
type Fields = map[string]any
type Record struct {
	Owner     string   `json:"owner"`
	Fields    Fields   `json:"fields"`
	Contacts  []string `json:"contacts,omitempty"`
	Companies []string `json:"companies,omitempty"`
}
type Activity struct {
	Type   string `json:"type"`
	Target any    `json:"target"`
	Fields Fields `json:"fields"`
	Status string `json:"status"`
	Owner  string `json:"owner"`
}

var (
	mu         sync.Mutex
	deals      = map[string]*Record{}
	contacts   = map[string]*Record{}
	companies  = map[string]*Record{}
	activities = map[string]*Activity{}
	replies    = map[string]any{}   // Idempotency-Key → reply
	nonces     = map[string]int64{} // nonce → seen at (unix)
	counter    = 100
	runStamp   = strconv.FormatInt(time.Now().UnixMilli(), 36) // ids minted during a run carry a run stamp: a tombstoned id from an earlier run is never reused
)

var owners = []map[string]string{{"id": "u-sam", "name": "Sam Rivera", "email": "sam@northwind.example"}, {"id": "u-lee", "name": "Lee Park", "email": "lee@northwind.example"}}
var stages = []map[string]any{{"id": "new", "label": "New", "order": 0}, {"id": "quote", "label": "Quote sent", "order": 1}, {"id": "signed", "label": "Signed", "order": 2, "closed": "won"}, {"id": "dead", "label": "Dead", "order": 3, "closed": "lost"}}

const ruleField, ruleStage = "po_number", "signed" // our own rule: no PO number, no Signed

func tables() map[string]map[string]*Record {
	return map[string]map[string]*Record{"deals": deals, "contacts": contacts, "companies": companies}
}
func nextID(prefix string) string { counter++; return fmt.Sprintf("%s-%s-%d", prefix, runStamp, counter) }

func seed() {
	companies["CO-1"] = &Record{Owner: "u-sam", Fields: Fields{"name": "Acme Roofing", "website": "acme-roofing.example"}}
	contacts["C-1"] = &Record{Owner: "u-sam", Fields: Fields{"full_name": "Ada Lovelace", "email": "ada@acme-roofing.example", "title": "Ops"}, Companies: []string{"CO-1"}}
	deals["D-1"] = &Record{Owner: "u-sam", Fields: Fields{"title": "Kettle Works Boilers", "stage": "quote", "value": "1200", "close_date": nil, "next_step": "Send quote", "owner": "u-sam", "po_number": nil}, Contacts: []string{"C-1"}, Companies: []string{"CO-1"}}
	deals["D-2"] = &Record{Owner: "u-lee", Fields: Fields{"title": "Maple Dental X-Ray Suite", "stage": "new", "value": "31000", "close_date": nil, "next_step": nil, "owner": "u-lee", "po_number": nil}, Contacts: []string{}, Companies: []string{}}
}

func schemaPush() map[string]any {
	opts := []map[string]any{}
	for _, s := range stages {
		opts = append(opts, map[string]any{"value": s["id"], "label": s["label"]})
	}
	return map[string]any{
		"name": "Northwind CRM", "timezone": "America/Toronto", "currency": "CAD",
		"fields": map[string]any{
			"deals": []map[string]any{
				{"key": "title", "label": "Title", "type": "string", "role": "name"},
				{"key": "stage", "label": "Stage", "type": "enum", "role": "stage", "options": opts},
				{"key": "value", "label": "Value", "type": "number", "role": "amount"},
				{"key": "close_date", "label": "Close date", "type": "date", "role": "closeDate"},
				{"key": "next_step", "label": "Next step", "type": "string", "role": "nextStep"},
				{"key": "owner", "label": "Owner", "type": "string", "role": "owner"},
				{"key": "po_number", "label": "PO number", "type": "string"},
			},
			"contacts":  []map[string]any{{"key": "full_name", "label": "Name", "type": "string", "role": "name"}, {"key": "email", "label": "Email", "type": "string", "role": "email"}, {"key": "title", "label": "Job title", "type": "string", "role": "title"}},
			"companies": []map[string]any{{"key": "name", "label": "Name", "type": "string", "role": "name"}, {"key": "website", "label": "Website", "type": "string", "role": "domain"}},
		},
		"stages": stages, "owners": owners,
		"rules":        []map[string]any{{"field": ruleField, "requiredAt": []string{ruleStage}}},
		"capabilities": map[string]any{"activities": []string{"note", "call", "task", "meeting"}, "links": "create"},
	}
}

// The records push: the records named (nil = all), plus tombstones for the ids that are gone.
func recordsPush(touched, gone map[string][]string, mode string) map[string]any {
	out := map[string]any{"mode": mode}
	for name, table := range tables() {
		rows := []map[string]any{}
		ids := []string{}
		if touched == nil {
			for id := range table {
				ids = append(ids, id)
			}
		} else {
			ids = touched[name]
		}
		for _, id := range ids {
			if r, ok := table[id]; ok {
				row := map[string]any{"id": id, "owner": r.Owner, "fields": r.Fields}
				if r.Contacts != nil {
					row["contacts"] = r.Contacts
				}
				if r.Companies != nil {
					row["companies"] = r.Companies
				}
				rows = append(rows, row)
			}
		}
		if gone != nil {
			for _, id := range gone[name] {
				rows = append(rows, map[string]any{"id": id, "owner": owners[0]["id"], "deleted": true})
			}
		}
		out[name] = rows
	}
	return out
}

// ---- calls to Liney (with the key) ---------------------------------------------------------------
func liney(method, path string, body any, extra map[string]string) (int, map[string]any, error) {
	var buf io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		buf = bytes.NewReader(b)
	}
	req, _ := http.NewRequest(method, api+path, buf)
	req.Header.Set("authorization", "Bearer "+lineyKey)
	req.Header.Set("content-type", "application/json")
	req.Header.Set("accept", "application/json")
	for k, v := range extra {
		req.Header.Set(k, v)
	}
	res, err := (&http.Client{Timeout: 40 * time.Second}).Do(req)
	if err != nil {
		return 0, nil, err
	}
	defer res.Body.Close()
	text, _ := io.ReadAll(res.Body)
	var out map[string]any
	if len(text) > 0 {
		_ = json.Unmarshal(text, &out)
	}
	if res.StatusCode >= 400 {
		e, _ := out["error"].(map[string]any)
		return res.StatusCode, out, fmt.Errorf("%s %s -> %d %v: %v (fix: %v)", method, path, res.StatusCode, e["code"], e["message"], e["hint"])
	}
	return res.StatusCode, out, nil
}
func pushSchema() error { _, _, err := liney("POST", "/schema", schemaPush(), nil); return err }
func pushRecords(touched, gone map[string][]string, mode string) error {
	p := recordsPush(touched, gone, mode)
	if len(p["deals"].([]map[string]any))+len(p["contacts"].([]map[string]any))+len(p["companies"].([]map[string]any)) == 0 {
		return nil
	}
	_, _, err := liney("POST", "/records", p, nil)
	return err
}

// ---- the handlers ------------------------------------------------------------------------------------
func refuse(code, message, field string) map[string]any {
	e := map[string]any{"code": code, "message": message}
	if field != "" {
		e["field"] = field
	}
	return map[string]any{"ok": false, "error": e}
}
func str(v any) string {
	if v == nil {
		return ""
	}
	return fmt.Sprint(v)
}
func asMap(v any) map[string]any {
	m, _ := v.(map[string]any)
	return m
}
func asList(v any) []any {
	l, _ := v.([]any)
	return l
}
func write(body map[string]any, dryRun bool) map[string]any {
	previous := map[string]map[string]Fields{"deals": {}, "contacts": {}, "companies": {}}
	created := []map[string]any{}
	touched := map[string]map[string]bool{"deals": {}, "contacts": {}, "companies": {}}
	t := tables()
	// Validate everything first, then apply: a refused call must leave the record as it was.
	for _, obj := range []string{"deals", "contacts", "companies"} {
		for id, v := range asMap(body[obj]) {
			vals := asMap(v)
			row, ok := t[obj][id]
			if !ok {
				return refuse("not_found", fmt.Sprintf("%s %s is not in Northwind CRM", strings.TrimSuffix(obj, "s"), id), "")
			}
			if obj == "deals" && str(vals["stage"]) == ruleStage {
				po, given := vals[ruleField]
				if !given {
					po = row.Fields[ruleField]
				}
				if strings.TrimSpace(str(po)) == "" {
					return refuse("required_field", "PO number is needed to mark a deal Signed", ruleField)
				}
			}
			prev := Fields{}
			for k := range vals {
				prev[k] = row.Fields[k]
			}
			previous[obj][id] = prev
		}
	}
	if dryRun {
		return map[string]any{"ok": true, "previous": previous, "created": created, "dryRun": true}
	}
	for _, obj := range []string{"deals", "contacts", "companies"} {
		for id, v := range asMap(body[obj]) {
			row := t[obj][id]
			for k, val := range asMap(v) {
				if val == "" {
					row.Fields[k] = nil
				} else {
					row.Fields[k] = val
				}
			}
			if obj == "deals" {
				if o, ok := asMap(v)["owner"].(string); ok && o != "" {
					row.Owner = o
				}
			}
			touched[obj][id] = true
		}
	}
	for _, cv := range asList(body["create"]) {
		c := asMap(cv)
		switch c["type"] {
		case "deal":
			id := nextID("D")
			f := Fields{}
			for k, v := range asMap(c["fields"]) {
				f[k] = v
			}
			owner := str(f["owner"])
			if owner == "" {
				owner = str(body["rep"])
			}
			r := &Record{Owner: owner, Fields: f, Contacts: []string{}, Companies: []string{}}
			if cid := str(c["companyId"]); cid != "" {
				r.Companies = []string{cid}
			}
			deals[id] = r
			created = append(created, map[string]any{"type": "deal", "id": id})
			touched["deals"][id] = true
		case "contact":
			id := nextID("C")
			contacts[id] = &Record{Owner: str(body["rep"]), Fields: Fields{"full_name": str(c["name"]), "email": c["email"], "title": c["role"]}, Companies: []string{}}
			if d, ok := deals[str(c["dealId"])]; ok {
				d.Contacts = append(d.Contacts, id)
				touched["deals"][str(c["dealId"])] = true
			}
			created = append(created, map[string]any{"type": "contact", "id": id})
			touched["contacts"][id] = true
		case "company":
			id := nextID("CO")
			companies[id] = &Record{Owner: str(body["rep"]), Fields: Fields{"name": str(c["name"]), "website": c["domain"]}}
			if d, ok := deals[str(c["dealId"])]; ok {
				d.Companies = append(d.Companies, id)
				touched["deals"][str(c["dealId"])] = true
			}
			created = append(created, map[string]any{"type": "company", "id": id})
			touched["companies"][id] = true
		default: // note, call, task, meeting: an activity on the target record
			id := nextID("A")
			f := Fields{}
			for k, v := range c {
				if k != "type" && k != "target" && k != "also" && k != "ownerId" {
					f[k] = v
				}
			}
			owner := str(c["ownerId"])
			if owner == "" {
				owner = str(body["rep"])
			}
			activities[id] = &Activity{Type: str(c["type"]), Target: c["target"], Fields: f, Status: "open", Owner: owner}
			created = append(created, map[string]any{"type": str(c["type"]), "id": id})
		}
	}
	for _, lv := range asList(body["links"]) {
		l := asMap(lv)
		from, to := asMap(l["from"]), asMap(l["to"])
		if str(from["objectType"]) != "deals" {
			continue
		}
		d, ok := deals[str(from["id"])]
		if !ok {
			continue
		}
		switch str(to["objectType"]) {
		case "contacts":
			d.Contacts = appendOnce(d.Contacts, str(to["id"]))
		case "companies":
			d.Companies = appendOnce(d.Companies, str(to["id"]))
		}
		touched["deals"][str(from["id"])] = true
	}
	for _, id := range asList(asMap(body["tasks"])["complete"]) {
		if a, ok := activities[str(id)]; ok {
			a.Status = "done"
		}
	}
	if err := pushRecords(keys(touched), nil, "upsert"); err != nil {
		log.Println("push after write:", err) // the write stands; Liney's copy catches up on the next push
	}
	return map[string]any{"ok": true, "previous": previous, "created": created}
}
func appendOnce(list []string, v string) []string {
	for _, x := range list {
		if x == v {
			return list
		}
	}
	return append(list, v)
}
func keys(m map[string]map[string]bool) map[string][]string {
	out := map[string][]string{}
	for name, set := range m {
		for id := range set {
			out[name] = append(out[name], id)
		}
	}
	return out
}

func undo(body map[string]any, dryRun bool) map[string]any {
	expect, restore := asMap(body["expect"]), asMap(body["restore"])
	t := tables()
	// Compare first, everything, before writing anything.
	for _, obj := range []string{"deals", "contacts", "companies"} {
		for id := range asMap(restore[obj]) {
			row, ok := t[obj][id]
			if !ok {
				return refuse("not_found", fmt.Sprintf("%s %s is not in Northwind CRM", strings.TrimSuffix(obj, "s"), id), "")
			}
			for k, want := range asMap(asMap(expect[obj])[id]) {
				if str(row.Fields[k]) != str(want) {
					return refuse("conflict", k+" changed since Liney wrote it", k)
				}
			}
		}
	}
	if dryRun {
		return map[string]any{"ok": true, "dryRun": true}
	}
	touched := map[string]map[string]bool{"deals": {}, "contacts": {}, "companies": {}}
	gone := map[string][]string{}
	for _, obj := range []string{"deals", "contacts", "companies"} {
		for id, v := range asMap(restore[obj]) {
			row := t[obj][id]
			for k, val := range asMap(v) {
				if val == "" {
					row.Fields[k] = nil
				} else {
					row.Fields[k] = val
				}
			}
			touched[obj][id] = true
		}
	}
	for _, rv := range asList(body["remove"]) {
		r := asMap(rv)
		id := str(r["id"])
		switch str(r["type"]) {
		case "deal":
			delete(deals, id)
			gone["deals"] = append(gone["deals"], id)
		case "contact":
			delete(contacts, id)
			gone["contacts"] = append(gone["contacts"], id)
			for did, d := range deals {
				kept := []string{}
				for _, c := range d.Contacts {
					if c != id {
						kept = append(kept, c)
					}
				}
				if len(kept) != len(d.Contacts) {
					d.Contacts = kept
					touched["deals"][did] = true
				}
			}
		case "company":
			delete(companies, id)
			gone["companies"] = append(gone["companies"], id)
		case "task_done":
			if a, ok := activities[id]; ok {
				a.Status = "open"
			}
		default:
			delete(activities, id) // a second remove of something already gone is not an error
		}
	}
	if err := pushRecords(keys(touched), gone, "upsert"); err != nil {
		log.Println("push after undo:", err)
	}
	return map[string]any{"ok": true}
}

// One call, whichever way it arrived: the idempotency key replays the first reply.
func handle(kind string, body map[string]any, key string, dryRun bool) any {
	mu.Lock()
	defer mu.Unlock()
	if !dryRun && key != "" {
		if r, ok := replies[key]; ok {
			return r
		}
	}
	var reply map[string]any
	if kind == "write" {
		reply = write(body, dryRun)
	} else {
		reply = undo(body, dryRun)
	}
	if !dryRun && key != "" {
		replies[key] = reply
	}
	return reply
}

// ---- push mode: the signature -----------------------------------------------------------------------
func verify(h http.Header, method, path string, raw []byte) string {
	ts, nonce, sig := h.Get("X-Liney-Timestamp"), h.Get("X-Liney-Nonce"), h.Get("X-Liney-Signature")
	if ts == "" || nonce == "" || sig == "" {
		return "missing"
	}
	t, err := strconv.ParseFloat(ts, 64)
	if err != nil || math.Abs(float64(time.Now().Unix())-t) > windowS {
		return "stale"
	}
	sum := sha256.Sum256(raw)
	canonical := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", ts, nonce, strings.ToUpper(method), path, hex.EncodeToString(sum[:]))
	good := false
	for _, secret := range secrets {
		m := hmac.New(sha256.New, []byte(secret))
		m.Write([]byte(canonical))
		expected := hex.EncodeToString(m.Sum(nil))
		for _, p := range strings.Split(sig, ",") {
			p = strings.TrimSpace(p)
			if strings.HasPrefix(p, "v1=") && subtle.ConstantTimeCompare([]byte(p[3:]), []byte(expected)) == 1 {
				good = true
			}
		}
	}
	if !good {
		return "bad_signature"
	}
	mu.Lock()
	defer mu.Unlock()
	now := time.Now().Unix()
	for k, at := range nonces {
		if now-at > windowS*2 {
			delete(nonces, k)
		}
	}
	if _, seen := nonces[nonce]; seen {
		return "replay"
	}
	nonces[nonce] = now
	return ""
}

func send(w http.ResponseWriter, status int, body any) {
	w.Header().Set("content-type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

func handler(w http.ResponseWriter, r *http.Request) {
	path := r.URL.Path
	kind := ""
	if strings.HasSuffix(path, "/liney/write") {
		kind = "write"
	} else if strings.HasSuffix(path, "/liney/undo") {
		kind = "undo"
	}
	raw, _ := io.ReadAll(r.Body)
	if kind == "" || r.Method != http.MethodPost {
		send(w, 404, map[string]any{"ok": false, "error": map[string]any{"code": "not_found", "message": "no such handler"}})
		return
	}
	if why := verify(r.Header, r.Method, path, raw); why != "" {
		code := "bad_signature"
		if why == "replay" {
			code = "replay"
		}
		send(w, 401, map[string]any{"ok": false, "error": map[string]any{"code": code, "message": why}})
		return
	}
	body := map[string]any{}
	if err := json.Unmarshal(raw, &body); err != nil && len(raw) > 0 {
		send(w, 200, refuse("invalid_value", "the body is not JSON", ""))
		return
	}
	send(w, 200, handle(kind, body, r.Header.Get("Idempotency-Key"), r.Header.Get("X-Liney-Dry-Run") == "1"))
}

// ---- pull mode: the worker --------------------------------------------------------------------------
func poll() {
	for {
		status, item, err := liney("GET", "/outbox", nil, nil)
		if err != nil {
			log.Println("poll:", err)
			time.Sleep(2 * time.Second)
			continue
		}
		if status == 204 || item == nil {
			continue
		}
		reply := handle(str(item["kind"]), asMap(item["body"]), str(item["idempotencyKey"]), item["dryRun"] == true)
		if _, _, err := liney("POST", "/outbox/"+str(item["id"]), reply, map[string]string{"x-liney-lease": str(item["lease"])}); err != nil {
			log.Println("answer:", err)
		}
	}
}

func main() {
	seed()
	if lineyKey == "" {
		log.Println("LINEY_KEY is not set: nothing will be pushed to Liney and pull mode cannot poll.")
	}
	if mode == "push" && len(secrets) == 0 {
		log.Println("LINEY_SECRET is not set: every call from Liney will be refused.")
	}
	if lineyKey != "" {
		if err := pushSchema(); err != nil {
			log.Println("push:", err)
		} else if err := pushRecords(nil, nil, "replace"); err != nil { // on start: Liney's copy becomes exactly this CRM's records
			log.Println("push:", err)
		} else {
			log.Println("schema and records pushed to Liney")
		}
		if mode == "pull" {
			go poll()
		}
	}
	http.HandleFunc("/", handler)
	log.Printf("Northwind CRM on http://localhost:%s (%s mode)", port, mode)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}
