// kuma-glpi-bridge: Uptime Kuma webhook -> GLPI incident tickets.
// Receives Kuma webhook POSTs, opens a GLPI Ticket (type 1) per monitor
// down-event, dedupes while open, and posts a recovery followup on up.
// Ticket: https://projects.knownelement.com/issues/824
package main

import (
	"crypto/tls"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"
)

type kumaWebhook struct {
	Monitor struct {
		Name string `json:"name"`
		URL  string `json:"url"`
		Type string `json:"type"`
	} `json:"monitor"`
	Heartbeat struct {
		Status int    `json:"status"`
		Msg    string `json:"msg"`
		Time   string `json:"time"`
	} `json:"heartbeat"`
	Msg string `json:"msg"`
}

type glpiClient struct {
	baseURL    string
	appToken   string
	userToken  string
	httpClient *http.Client
	mu         sync.Mutex
	session    string
	expires    time.Time
}

func newGLPI() *glpiClient {
	return &glpiClient{
		baseURL:   os.Getenv("GLPI_URL"),
		appToken:  os.Getenv("GLPI_APP_TOKEN"),
		userToken: os.Getenv("GLPI_USER_TOKEN"),
		httpClient: &http.Client{
			Timeout:   20 * time.Second,
			Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: os.Getenv("GLPI_INSECURE") == "1"}}, //nolint:gosec // self-signed tailnet endpoint
		},
	}
}

// session returns a cached GLPI session token, re-initing when stale.
func (g *glpiClient) ensureSession() (string, error) {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.session != "" && time.Now().Before(g.expires) {
		return g.session, nil
	}
	req, err := http.NewRequest("GET", g.baseURL+"/initSession", nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("App-Token", g.appToken)
	req.Header.Set("Authorization", "user_token "+g.userToken)
	resp, err := g.httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	var out struct {
		SessionToken string `json:"session_token"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.SessionToken == "" {
		return "", fmt.Errorf("initSession failed: HTTP %d", resp.StatusCode)
	}
	g.session = out.SessionToken
	g.expires = time.Now().Add(50 * time.Minute)
	return g.session, nil
}

func (g *glpiClient) do(method, path string, body any, out any) error {
	sess, err := g.ensureSession()
	if err != nil {
		return err
	}
	var rd *strings.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rd = strings.NewReader(string(b))
	} else {
		rd = strings.NewReader("")
	}
	req, err := http.NewRequest(method, g.baseURL+path, rd)
	if err != nil {
		return err
	}
	req.Header.Set("App-Token", g.appToken)
	req.Header.Set("Session-Token", sess)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := g.httpClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		return fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode)
	}
	if out != nil {
		return json.NewDecoder(resp.Body).Decode(out)
	}
	return nil
}

// openTicketFor returns the id of an existing unsolved ticket for this monitor, if any.
func (g *glpiClient) openTicketFor(monitor string) (int, error) {
	q := url.Values{}
	q.Set("is_deleted", "0")
	q.Set("criteria[0][field]", "1") // name
	q.Set("criteria[0][searchtype]", "contains")
	q.Set("criteria[0][value]", "[kuma] "+monitor)
	q.Set("criteria[1][link]", "AND")
	q.Set("criteria[1][field]", "12") // status
	q.Set("criteria[1][searchtype]", "equals")
	q.Set("criteria[1][value]", "notold")
	var out struct {
		Data []map[string]any `json:"data"`
	}
	if err := g.do("GET", "/search/Ticket?"+q.Encode(), nil, &out); err != nil {
		return 0, err
	}
	for _, row := range out.Data {
		if v, ok := row["2"]; ok {
			var tid int
			fmt.Sscanf(fmt.Sprint(v), "%d", &tid)
			return tid, nil
		}
	}
	return 0, nil
}

func (g *glpiClient) createTicket(name, content string) (int, error) {
	var out struct {
		ID int `json:"id"`
	}
	err := g.do("POST", "/Ticket", map[string]any{
		"input": map[string]any{"name": name, "content": content, "type": 1},
	}, &out)
	return out.ID, err
}

func (g *glpiClient) followup(ticket int, content string) error {
	// GLPI 11: followups live under the item endpoint; fall back to the
	// legacy path when the item endpoint rejects the payload.
	err := g.do("POST", fmt.Sprintf("/Ticket/%d/ITILFollowup", ticket), map[string]any{
		"input": map[string]any{"content": content},
	}, nil)
	if err != nil {
		return g.do("POST", "/followup", map[string]any{
			"input": map[string]any{"itemtype": "Ticket", "items_id": ticket, "content": content},
		}, nil)
	}
	return nil
}

type bridge struct {
	glpi *glpiClient
	mu   sync.Mutex
	open map[string]int // monitor -> ticket id
}

func (b *bridge) handle(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "POST only", http.StatusMethodNotAllowed)
		return
	}
	var k kumaWebhook
	if err := json.NewDecoder(r.Body).Decode(&k); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if k.Monitor.Name == "" {
		w.WriteHeader(http.StatusAccepted)
		return
	}
	status := k.Heartbeat.Status
	name := "[kuma] " + k.Monitor.Name
	content := fmt.Sprintf("Automated incident from Uptime Kuma bridge (#824).\nMonitor: %s (type %s)\nURL: %s\nStatus code: %d\nMessage: %s\nAt: %s\n\nSource: https://status.knownelement.com — work this incident per the incidents-first mandate; see Redmine project \"Incident Response\".",
		k.Monitor.Name, k.Monitor.Type, k.Monitor.URL, status, k.Msg, k.Heartbeat.Time)

	b.mu.Lock()
	defer b.mu.Unlock()
	switch status {
	case 0: // Kuma heartbeat status: 0=down, 1=up, 2=pending, 3=maintenance
		if _, exists := b.open[k.Monitor.Name]; !exists {
			tid, err := b.glpi.openTicketFor(k.Monitor.Name)
			if err == nil && tid == 0 {
				tid, err = b.glpi.createTicket(name, content)
			}
			if err != nil {
				log.Printf("ticket create failed: %v", err)
				http.Error(w, "glpi error", http.StatusBadGateway)
				return
			}
			b.open[k.Monitor.Name] = tid
			log.Printf("incident ticket %d for %s", tid, k.Monitor.Name)
		}
	case 1: // UP
		tid, exists := b.open[k.Monitor.Name]
		if !exists {
			// restarts wipe the in-memory map; search GLPI for an open ticket
			tid, _ = b.glpi.openTicketFor(k.Monitor.Name)
			if tid == 0 {
				return
			}
		}
		delete(b.open, k.Monitor.Name)
		go func() {
			fu := fmt.Sprintf("RECOVERY: monitor %s back UP at %s. Ticket auto-closing as solved with recovery evidence.", k.Monitor.Name, k.Heartbeat.Time)
			if err := b.glpi.followup(tid, fu); err != nil {
				log.Printf("followup failed (continuing to solve): %v", err)
			}
			if err := b.glpi.do("PUT", fmt.Sprintf("/Ticket/%d", tid), map[string]any{"input": map[string]any{"status": 5}}, nil); err != nil { // ticket status 5 = solved
				log.Printf("solve failed: %v", err)
			}
		}()
	}
	w.WriteHeader(http.StatusOK)
}

func main() {
	listen := os.Getenv("BRIDGE_LISTEN")
	if listen == "" {
		listen = ":8080"
	}
	b := &bridge{glpi: newGLPI(), open: map[string]int{}}
	http.HandleFunc("/webhook", b.handle)
	http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
	log.Printf("kuma-glpi-bridge listening on %s", listen)
	log.Fatal(http.ListenAndServe(listen, nil))
}
