package main

import (
	"crypto/rand"
	"crypto/sha256"
	_ "embed" // required by the //go:embed icon.png directive below
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"runtime"
	"strings"
	"sync"
	"time"

	"github.com/wailsapp/wails/v3/pkg/application"
	"github.com/wailsapp/wails/v3/pkg/events"
	"github.com/zalando/go-keyring"
)

// assets is the embedded web SPA, defined in assets_embed.go (`desktop` build
// tag, real //go:embed frontend/dist) and assets_stub.go (default build, empty
// FS). frontend/dist is a gitignored artifact populated by `make desktop`; the
// stub keeps `go build ./...` working on a clean checkout.

//go:embed icon.png
var iconPNG []byte

const (
	keyringService   = "com.hubris.oikos-desktop"
	keyringUser      = "oikos"
	updateURL        = "https://git.hubris.network/api/v1/repos/dtoro/oikos-web/releases"
	pollInterval     = 30 * time.Second
	updateInterval   = 6 * time.Hour
	oidcCallbackPort = 18901
)

// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)"
// (Makefile desktop target). The default keeps a non-empty fallback for
// `go build ./cmd/desktop` without ldflags.
var version = "0.1.0-dev"

type OikosConfig struct {
	ApiUrl    string `json:"apiUrl"`
	Token     string `json:"token,omitempty"`
	IsDesktop bool   `json:"isDesktop"`
}

// ---- ConfigService ----

type ConfigService struct{}

func (c *ConfigService) Name() string { return "config" }

func (c *ConfigService) SaveConfig(apiUrl, token string) error {
	cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true}
	data, _ := json.Marshal(cfg)
	return keyring.Set(keyringService, keyringUser, string(data))
}

func (c *ConfigService) ClearConfig() error {
	return keyring.Delete(keyringService, keyringUser)
}

func (c *ConfigService) GetStoredConfig() *OikosConfig {
	return loadConfig()
}

func (c *ConfigService) EnableAutoStart() error {
	if runtime.GOOS != "darwin" {
		return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
	}
	usr, _ := user.Current()
	dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents")
	os.MkdirAll(dir, 0755)

	exe, _ := os.Executable()
	plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>com.hubris.oikos-desktop</string>
	<key>ProgramArguments</key>
	<array>
		<string>%s</string>
	</array>
	<key>RunAtLoad</key>
	<true/>
	<key>KeepAlive</key>
	<false/>
</dict>
</plist>`, exe)

	return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644)
}

func (c *ConfigService) DisableAutoStart() error {
	if runtime.GOOS != "darwin" {
		return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
	}
	usr, _ := user.Current()
	path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist")
	return os.Remove(path)
}

// ---- Local OIDC server (runs alongside the webview) ----

type oidcSession struct {
	apiUrl   string
	verifier string
	state    string
	ch       chan string
}

var (
	oidcSessionsMu sync.Mutex
	oidcSessions   = make(map[string]*oidcSession)
)

func startOIDCServer() *http.Server {
	mux := http.NewServeMux()

	cors := func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		if r.Method == "OPTIONS" {
			w.WriteHeader(http.StatusOK)
		}
	}

	h := func(path string, handler func(http.ResponseWriter, *http.Request)) {
		mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
			cors(w, r)
			if r.Method == "OPTIONS" {
				return
			}
			handler(w, r)
		})
	}

	h("/oidc/start", func(w http.ResponseWriter, r *http.Request) {
		apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
		returnURL := r.URL.Query().Get("ret")
		if apiUrl == "" {
			http.Error(w, "apiUrl required", http.StatusBadRequest)
			return
		}
		if returnURL == "" {
			returnURL = "/?desktop=1"
		}

		oidcCfg, err := fetchOIDCConfig(apiUrl)
		if err != nil {
			http.Error(w, err.Error(), http.StatusServiceUnavailable)
			return
		}

		verifier, challenge, _ := pkceParams()
		state := randomString(32)
		redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)

		ch := make(chan string, 1)
		oidcSessionsMu.Lock()
		sessionID := randomString(16)
		oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
		oidcSessionsMu.Unlock()

		authURL := fmt.Sprintf("%s?%s",
			oidcCfg.AuthorizationEndpoint,
			url.Values{
				"response_type":         {"code"},
				"client_id":             {oidcCfg.ClientID},
				"redirect_uri":          {redirectURI},
				"code_challenge":        {challenge},
				"code_challenge_method": {"S256"},
				"state":                 {state},
				"scope":                 {"openid profile email"},
			}.Encode(),
		)

		exec.Command("open", authURL).Start()

		select {
		case token := <-ch:
			if token != "" {
				c := &ConfigService{}
				c.SaveConfig(apiUrl, token)
				returnURL += "&token=" + url.QueryEscape(token)
			}
		case <-time.After(5 * time.Minute):
		}

		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
<meta http-equiv="refresh" content="0;url=%s">
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, returnURL)
	})

	h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
		code := r.URL.Query().Get("code")
		gotState := r.URL.Query().Get("state")

		w.Header().Set("Content-Type", "text/html; charset=utf-8")

		oidcSessionsMu.Lock()
		var session *oidcSession
		var sessionID string
		for id, s := range oidcSessions {
			if s.state == gotState {
				session = s
				sessionID = id
				break
			}
		}
		oidcSessionsMu.Unlock()

		if session == nil {
			w.WriteHeader(http.StatusBadRequest)
			w.Write([]byte("Invalid state."))
			return
		}

		token, err := exchangeCode(
			session.apiUrl,
			code, session.verifier,
			fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort),
		)

		oidcSessionsMu.Lock()
		delete(oidcSessions, sessionID)
		oidcSessionsMu.Unlock()

		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
			fmt.Fprintf(w, "Token exchange failed: %v", err)
			session.ch <- ""
			return
		}

		w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
		session.ch <- token
	})

	mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) {
		apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
		if apiUrl == "" {
			http.Error(w, "apiUrl required", http.StatusBadRequest)
			return
		}
		cfg, err := fetchOIDCConfig(apiUrl)
		if err != nil {
			http.Error(w, err.Error(), http.StatusServiceUnavailable)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(cfg)
	})

	mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) {
		latest := fetchLatestRelease()
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Access-Control-Allow-Origin", "*")
		if latest == nil {
			json.NewEncoder(w).Encode(map[string]string{"current": version})
			return
		}
		hasAsset := false
		for _, a := range latest.Assets {
			if strings.Contains(a.Name, "darwin") {
				hasAsset = true
				updater.mu.Lock()
				updater.latestURL = a.BrowserDownloadURL
				updater.mu.Unlock()
				break
			}
		}
		json.NewEncoder(w).Encode(map[string]string{
			"current":   version,
			"latest":    latest.Version,
			"has_asset": fmt.Sprintf("%t", hasAsset),
		})
	})

	listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
	if err != nil {
		log.Printf("OIDC server: %v", err)
		return nil
	}
	log.Printf("OIDC server listening on %s", listener.Addr())
	srv := &http.Server{Handler: mux}
	go srv.Serve(listener)
	return srv
}

// ---- Window persistence ----

type windowState struct {
	X      int `json:"x"`
	Y      int `json:"y"`
	Width  int `json:"width"`
	Height int `json:"height"`
}

func windowStatePath() string {
	usr, _ := user.Current()
	return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
}

func loadWindowState() *windowState {
	data, err := os.ReadFile(windowStatePath())
	if err != nil {
		return nil
	}
	var ws windowState
	if err := json.Unmarshal(data, &ws); err != nil {
		return nil
	}
	if ws.Width < 200 || ws.Height < 200 {
		return nil
	}
	return &ws
}

func saveWindowState(w application.Window) {
	x, y := w.Position()
	width, height := w.Size()
	ws := windowState{X: x, Y: y, Width: width, Height: height}
	data, _ := json.Marshal(ws)

	usr, _ := user.Current()
	dir := filepath.Join(usr.HomeDir, ".config", "oikos")
	os.MkdirAll(dir, 0755)
	os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
}

func loadConfig() *OikosConfig {
	data, err := keyring.Get(keyringService, keyringUser)
	if err != nil {
		return nil
	}
	var cfg OikosConfig
	if err := json.Unmarshal([]byte(data), &cfg); err != nil {
		return nil
	}
	cfg.IsDesktop = true
	return &cfg
}

type oidcConfig struct {
	Issuer                string `json:"issuer"`
	ClientID              string `json:"client_id"`
	AuthorizationEndpoint string `json:"authorization_endpoint"`
}

func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) {
	resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config")
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("server returned %d", resp.StatusCode)
	}
	var cfg oidcConfig
	if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
		return nil, err
	}
	return &cfg, nil
}

func pkceParams() (verifier, challenge string, _ error) {
	v := randomString(64)
	h := sha256.Sum256([]byte(v))
	return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
}

func randomString(n int) string {
	b := make([]byte, n)
	rand.Read(b)
	return base64.RawURLEncoding.EncodeToString(b)
}

func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
	body, _ := json.Marshal(map[string]string{
		"grant_type":    "authorization_code",
		"code":          code,
		"code_verifier": verifier,
		"redirect_uri":  redirectURI,
	})

	resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body)))
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		b, _ := io.ReadAll(resp.Body)
		return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b))
	}

	var tokens struct {
		AccessToken string `json:"access_token"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
		return "", err
	}
	if tokens.AccessToken == "" {
		return "", fmt.Errorf("no access_token in response")
	}
	return tokens.AccessToken, nil
}

// ---- Notifications ----

type dashboardSummary struct {
	ApprovalsPending int `json:"approvals_pending"`
	Signals          struct {
		Critical int `json:"critical"`
	} `json:"signals_by_severity"`
}

func (d *dashboardSummary) alertCount() int {
	return d.ApprovalsPending + d.Signals.Critical
}

func notify(title, subtitle string) {
	if runtime.GOOS != "darwin" {
		return
	}
	script := fmt.Sprintf(
		`display notification "%s" with title "%s" sound name "default"`,
		strings.ReplaceAll(subtitle, `"`, `\"`),
		strings.ReplaceAll(title, `"`, `\"`),
	)
	exec.Command("osascript", "-e", script).Run()
}

func pollDashboard(cfg *OikosConfig) {
	if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" {
		return
	}

	var lastCount int
	first := true

	for {
		req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil)
		if err != nil {
			time.Sleep(pollInterval)
			continue
		}
		req.Header.Set("Authorization", "Bearer "+cfg.Token)

		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			time.Sleep(pollInterval)
			continue
		}

		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()

		var summary dashboardSummary
		if err := json.Unmarshal(body, &summary); err != nil {
			time.Sleep(pollInterval)
			continue
		}

		if first {
			lastCount = summary.alertCount()
			first = false
		} else {
			current := summary.alertCount()
			if current > lastCount {
				notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical))
			}
			lastCount = current
		}

		time.Sleep(pollInterval)
	}
}

// ---- Auto-update ----

type giteaRelease struct {
	TagName string `json:"tag_name"`
	Assets  []struct {
		Name               string `json:"name"`
		BrowserDownloadURL string `json:"browser_download_url"`
	} `json:"assets"`
}

type updateState struct {
	mu        sync.Mutex
	latestURL string
}

var updater = &updateState{}

// CheckForUpdates checks Gitea releases for a newer version. If found, stores
// the download URL and returns the latest version string (empty if current).
func (c *ConfigService) CheckForUpdates() string {
	latest := fetchLatestRelease()
	if latest == nil || latest.Version == version {
		return ""
	}
	for _, a := range latest.Assets {
		if strings.Contains(a.Name, "darwin") {
			updater.mu.Lock()
			updater.latestURL = a.BrowserDownloadURL
			updater.mu.Unlock()
			return latest.Version
		}
	}
	return ""
}

// InstallUpdate downloads the stored update, replaces the app, and restarts.
func (c *ConfigService) InstallUpdate() error {
	updater.mu.Lock()
	url := updater.latestURL
	updater.mu.Unlock()
	if url == "" {
		return fmt.Errorf("no update available")
	}
	return doUpdate(url)
}

type latestRelease struct {
	Version string
	Assets  []struct {
		Name               string
		BrowserDownloadURL string
	}
}

func fetchLatestRelease() *latestRelease {
	resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
	if err != nil {
		return nil
	}
	defer resp.Body.Close()

	var releases []giteaRelease
	if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
		return nil
	}

	r := releases[0]
	v := strings.TrimPrefix(r.TagName, "v")
	if v == version {
		return nil
	}

	lr := &latestRelease{Version: v}
	for _, a := range r.Assets {
		lr.Assets = append(lr.Assets, struct {
			Name               string
			BrowserDownloadURL string
		}{a.Name, a.BrowserDownloadURL})
	}
	return lr
}

func doUpdate(downloadURL string) error {
	tmp, err := os.CreateTemp("", "oikos-update-*.zip")
	if err != nil {
		return err
	}
	defer os.Remove(tmp.Name())

	resp, err := http.Get(downloadURL)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if _, err := io.Copy(tmp, resp.Body); err != nil {
		return err
	}
	tmp.Close()

	extractDir, err := os.MkdirTemp("", "oikos-extract")
	if err != nil {
		return err
	}
	defer os.RemoveAll(extractDir)

	cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir)
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("unzip: %w: %s", err, out)
	}

	newApp := filepath.Join(extractDir, "Oikos.app")
	if _, err := os.Stat(newApp); err != nil {
		return fmt.Errorf("extracted app not found: %w", err)
	}

	currentApp := "/Applications/Oikos.app"
	if _, err := os.Stat(currentApp); os.IsNotExist(err) {
		if exe, err := os.Executable(); err == nil {
			currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
		}
	}

	script := fmt.Sprintf(`#!/bin/bash
sleep 2
rm -rf "%s"
mv "%s" "%s"
open "%s"
rm "$0"
`, currentApp, newApp, currentApp, currentApp)

	scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh")
	if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
		return err
	}

	app := application.Get()
	exec.Command("open", scriptPath).Start()
	if app != nil {
		app.Quit()
	}

	return nil
}

func checkUpdates() {
	for {
		time.Sleep(updateInterval)

		latest := fetchLatestRelease()
		if latest == nil {
			continue
		}

		for _, a := range latest.Assets {
			if strings.Contains(a.Name, "darwin") {
				updater.mu.Lock()
				updater.latestURL = a.BrowserDownloadURL
				updater.mu.Unlock()

				app := application.Get()
				if app == nil {
					continue
				}
				msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version)
				app.Dialog.Info().
					SetTitle("Update Available").
					SetMessage(msg).
					Show()
				break
			}
		}
	}
}

// ---- Main ----

func main() {
	oidcSrv := startOIDCServer()
	defer oidcSrv.Close()

	distFS, err := fs.Sub(assets, "frontend/dist")
	if err != nil {
		log.Fatalf("embedded assets: %v", err)
	}

	app := application.New(application.Options{
		Name:        "Oikos",
		Description: "Homelab Control Room",
		Services: []application.Service{
			application.NewService(&ConfigService{}),
		},
		Assets: application.AssetOptions{
			Handler: application.AssetFileServerFS(distFS),
		},
		Mac: application.MacOptions{
			ApplicationShouldTerminateAfterLastWindowClosed: false,
		},
	})

	systemTray := app.SystemTray.New()
	systemTray.SetTooltip("Oikos")
	systemTray.SetIcon(iconPNG)

	trayMenu := application.NewMenu()
	trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) {
		for _, w := range app.Window.GetAll() {
			w.Show()
			w.Focus()
		}
	})
	trayMenu.AddSeparator()
	trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
		go func() {
			latest := fetchLatestRelease()
			if latest == nil {
				app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
				return
			}
			for _, a := range latest.Assets {
				if strings.Contains(a.Name, "darwin") {
					updater.mu.Lock()
					updater.latestURL = a.BrowserDownloadURL
					updater.mu.Unlock()
					msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version)
					d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg)
					yes := d.AddButton("Install")
					yes.OnClick(func() { doUpdate(updater.latestURL) })
					no := d.AddButton("Later")
					d.SetDefaultButton(yes)
					d.SetCancelButton(no)
					d.Show()
					return
				}
			}
			app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
		}()
	})
	trayMenu.AddSeparator()
	trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
		app.Quit()
	})
	systemTray.SetMenu(trayMenu)

	ws := loadWindowState()
	width, height := 1400, 900
	if ws != nil {
		width = ws.Width
		height = ws.Height
	}

	window := app.Window.NewWithOptions(application.WebviewWindowOptions{
		Title:    "Oikos",
		Width:    width,
		Height:   height,
		MinWidth: 1024,
		MinHeight: 700,
		URL:      "/?desktop=1",
	})

	if ws != nil {
		window.SetPosition(ws.X, ws.Y)
	} else {
		window.Center()
	}

	window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
		window.Hide()
		e.Cancel()
	})

	window.Show()

	systemTray.AttachWindow(window)
	systemTray.Run()

	app.OnShutdown(func() {
		saveWindowState(window)
	})

	go pollDashboard(loadConfig())
	go checkUpdates()

	err = app.Run()
	if err != nil {
		log.Fatal(err)
	}
}
