// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
//
// SPDX-License-Identifier: LicenseRef-MutuaL-1.2

// Package silverbullet provides an HTTP client for the SilverBullet Runtime API.
package silverbullet

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

// Auth holds authentication credentials for the SilverBullet API.
type Auth struct {
	// Bearer token for Authorization header. Set via SB_TOKEN.
	Token string
	// Username and password for cookie-based session auth.
	// Logs in via POST /.auth and sends session cookie on subsequent requests.
	User string
	Pass string
}

// Client is an HTTP client for the SilverBullet Runtime API.
type Client struct {
	baseURL    string
	httpClient *http.Client
	auth       Auth
	// cached session from password login
	cachedCookieName string
	cachedCookieVal  string
}

// New creates a new SilverBullet client.
// The baseURL is normalized to remove trailing slashes.
func New(baseURL string, auth Auth) *Client {
	return &Client{
		baseURL: strings.TrimRight(baseURL, "/"),
		httpClient: &http.Client{
			Timeout: 6 * time.Hour, // long enough for lua_script with X-Timeout up to 21600s
			CheckRedirect: func(req *http.Request, via []*http.Request) error {
				return http.ErrUseLastResponse
			},
		},
		auth: auth,
	}
}

// LuaResult holds the result from the Lua endpoints.
type LuaResult struct {
	Result json.RawMessage `json:"result,omitempty"`
	Error  string          `json:"error,omitempty"`
}

// LogEntry represents a single console log entry.
type LogEntry struct {
	Level     string `json:"level"`
	Text      string `json:"text"`
	Timestamp int64  `json:"timestamp"`
}

// LogsResult holds the result from the logs endpoint.
type LogsResult struct {
	Logs []LogEntry `json:"logs"`
}

// ExecuteLua sends a Lua script to the /.runtime/lua_script endpoint.
func (c *Client) ExecuteLua(ctx context.Context, script string, timeout int) (*LuaResult, error) {
	if timeout < 1 {
		timeout = 1
	}
	if timeout > 21600 {
		timeout = 21600
	}

	endpoint, err := c.resolveURL("/.runtime/lua_script")
	if err != nil {
		return nil, fmt.Errorf("resolving URL: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(script))
	if err != nil {
		return nil, fmt.Errorf("creating request: %w", err)
	}

	req.Header.Set("Content-Type", "text/plain")
	req.Header.Set("X-Timeout", strconv.Itoa(timeout))
	if err := c.setAuth(req); err != nil {
		return nil, fmt.Errorf("setting auth: %w", err)
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("executing lua: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("reading response: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		var luaErr LuaResult
		if json.Unmarshal(body, &luaErr) == nil && luaErr.Error != "" {
			return &luaErr, nil
		}
		return nil, fmt.Errorf("lua endpoint returned status %d: %s", resp.StatusCode, string(body))
	}

	var result LuaResult
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, fmt.Errorf("parsing lua response: %w", err)
	}

	return &result, nil
}

// Screenshot fetches the current viewport screenshot as PNG bytes.
func (c *Client) Screenshot(ctx context.Context) ([]byte, error) {
	endpoint, err := c.resolveURL("/.runtime/screenshot")
	if err != nil {
		return nil, fmt.Errorf("resolving URL: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, fmt.Errorf("creating request: %w", err)
	}

	if err := c.setAuth(req); err != nil {
		return nil, fmt.Errorf("setting auth: %w", err)
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("fetching screenshot: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("screenshot endpoint returned status %d: %s", resp.StatusCode, string(body))
	}

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("reading screenshot data: %w", err)
	}

	return data, nil
}

// ConsoleLogs fetches recent console log entries.
// limit caps the number of entries (max 1000).
// since filters to entries newer than the given unix millisecond timestamp (0 = no filter).
func (c *Client) ConsoleLogs(ctx context.Context, limit int, since int64) (*LogsResult, error) {
	if limit < 1 {
		limit = 100
	}
	if limit > 1000 {
		limit = 1000
	}

	endpoint, err := c.resolveURL("/.runtime/logs")
	if err != nil {
		return nil, fmt.Errorf("resolving URL: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, fmt.Errorf("creating request: %w", err)
	}

	if err := c.setAuth(req); err != nil {
		return nil, fmt.Errorf("setting auth: %w", err)
	}

	q := req.URL.Query()
	q.Set("limit", strconv.Itoa(limit))
	if since > 0 {
		q.Set("since", strconv.FormatInt(since, 10))
	}
	req.URL.RawQuery = q.Encode()

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("fetching logs: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("reading logs response: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("logs endpoint returned status %d: %s", resp.StatusCode, string(body))
	}

	var result LogsResult
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, fmt.Errorf("parsing logs response: %w", err)
	}

	return &result, nil
}

// resolveURL joins the base URL with a path, ensuring no double slashes.
func (c *Client) resolveURL(path string) (string, error) {
	return url.JoinPath(c.baseURL, path)
}

// setAuth sets authentication headers on the request.
// Bearer token goes on the Authorization header.
// Password auth logs in via POST /.auth and sends the session cookie.
// Both can coexist — they use different headers.
func (c *Client) setAuth(req *http.Request) error {
	// Bearer token on Authorization header
	if c.auth.Token != "" {
		req.Header.Set("Authorization", "Bearer "+c.auth.Token)
	}

	// Password auth via session cookie
	if c.auth.User != "" && c.auth.Pass != "" {
		if err := c.ensureSessionCookie(req); err != nil {
			return fmt.Errorf("session login: %w", err)
		}
	}

	return nil
}

// ensureSessionCookie logs in via POST /.auth if needed and sets the session cookie.
func (c *Client) ensureSessionCookie(req *http.Request) error {
	// Use cached session if available
	if c.cachedCookieName != "" && c.cachedCookieVal != "" {
		req.AddCookie(&http.Cookie{
			Name:  c.cachedCookieName,
			Value: c.cachedCookieVal,
		})
		return nil
	}

	// Log in to get session cookie
	form := url.Values{}
	form.Set("username", c.auth.User)
	form.Set("password", c.auth.Pass)

	loginURL, err := c.resolveURL("/.auth")
	if err != nil {
		return fmt.Errorf("resolving login URL: %w", err)
	}

	loginReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, loginURL, strings.NewReader(form.Encode()))
	if err != nil {
		return fmt.Errorf("creating login request: %w", err)
	}
	loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	loginResp, err := c.httpClient.Do(loginReq)
	if err != nil {
		return fmt.Errorf("login request failed: %w", err)
	}
	defer func() { _ = loginResp.Body.Close() }()

	if loginResp.StatusCode != http.StatusOK {
		return fmt.Errorf("login failed with status %d", loginResp.StatusCode)
	}

	// Extract session cookie from Set-Cookie header
	for _, cookie := range loginResp.Cookies() {
		if strings.HasPrefix(cookie.Name, "auth_") {
			c.cachedCookieName = cookie.Name
			c.cachedCookieVal = cookie.Value
			req.AddCookie(&http.Cookie{
				Name:  cookie.Name,
				Value: cookie.Value,
			})
			return nil
		}
	}

	return fmt.Errorf("login succeeded but no auth cookie returned")
}
