package e2e

import (
	"fmt"
	"html"
	"io"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"regexp"
	"strings"
	"time"
)

// panelBaseURL is where compose.override.yml publishes the panel's plain-HTTP
// port (PANEL_COOKIE_SECURE=false — no reverse proxy in this stand).
const panelBaseURL = "http://127.0.0.1:20080"

// panelClient drives the panel exactly as a browser would: an HTTP client
// with a cookie jar, HTML forms posted as the templates render them, and
// responses scraped for the bits a later step needs (a domain's id, an
// application's one-shot password, the DKIM record to publish). This is
// deliberate — it is also the only way to prove that what the panel tells an
// administrator to publish is the record that actually verifies (plan C.4).
type panelClient struct {
	http *http.Client
}

func newPanelClient() (*panelClient, error) {
	jar, err := cookiejar.New(nil)
	if err != nil {
		return nil, err
	}
	return &panelClient{http: &http.Client{
		Jar:     jar,
		Timeout: 15 * time.Second,
	}}, nil
}

// waitForPanelReady polls the host-published panel port until /healthz
// returns 200. Supervisor reporting the panel process RUNNING is not enough:
// the setup-token file is written in Start() before ListenAndServe, and
// Docker's host-port publish can lag the in-container bind — either race
// makes the first setup POST fail and leaves sc.panel nil for later steps.
func waitForPanelReady() error {
	client := &http.Client{Timeout: 2 * time.Second}
	return waitFor("panel /healthz on "+panelBaseURL, 60*time.Second, 200*time.Millisecond, func() (bool, error) {
		resp, err := client.Get(panelBaseURL + "/healthz")
		if err != nil {
			return false, err
		}
		defer resp.Body.Close()
		_, _ = io.Copy(io.Discard, resp.Body)
		if resp.StatusCode != http.StatusOK {
			return false, fmt.Errorf("status %d", resp.StatusCode)
		}
		return true, nil
	})
}

// readSetupToken reads the one-time setup URL from /data/setup-token inside
// the running selfpost container. The file is mode 0600 owned by the panel
// UID (security.md / setup.go); on a typical CI bind mount that is not the
// host runner's UID, so a host-side os.ReadFile returns permission denied
// even though the panel already wrote the token (CI: setup_and_login).
// Reading via compose exec matches docs/guide.md ("docker compose exec
// selfpost cat /data/setup-token").
func readSetupToken(s *stack) (string, error) {
	var raw string
	err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) {
		out, err := s.execIn("selfpost", "cat", "/data/setup-token")
		if err != nil {
			return false, err
		}
		raw = out
		return strings.TrimSpace(out) != "", nil
	})
	if err != nil {
		return "", err
	}
	u, err := url.Parse(strings.TrimSpace(raw))
	if err != nil {
		return "", fmt.Errorf("parse setup token file: %w", err)
	}
	return strings.TrimPrefix(u.Path, "/setup/"), nil
}

func (c *panelClient) postForm(path string, form url.Values) (*http.Response, string, error) {
	resp, err := c.http.PostForm(panelBaseURL+path, form)
	if err != nil {
		return nil, "", err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	return resp, string(body), err
}

func (c *panelClient) get(path string) (*http.Response, string, error) {
	resp, err := c.http.Get(panelBaseURL + path)
	if err != nil {
		return nil, "", err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	return resp, string(body), err
}

// setup creates the administrator through the one-time /setup/<token> form.
func (c *panelClient) setup(token, username, password string) error {
	resp, body, err := c.postForm("/setup/"+token, url.Values{
		"username":         {username},
		"password":         {password},
		"password_confirm": {password},
	})
	if err != nil {
		return err
	}
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("setup: status %d: %s", resp.StatusCode, body)
	}
	return nil
}

// login authenticates and stores the session cookie in the client's jar.
func (c *panelClient) login(username, password string) error {
	resp, body, err := c.postForm("/login", url.Values{
		"username": {username},
		"password": {password},
	})
	if err != nil {
		return err
	}
	if resp.Request.URL.Path != "/status" {
		return fmt.Errorf("login did not land on /status (landed on %s): %s", resp.Request.URL.Path, body)
	}
	return nil
}

// addDomain submits the add-domain form and returns its assigned id, read
// back from the redirect target /domains/{id}.
func (c *panelClient) addDomain(name string) (string, error) {
	resp, body, err := c.postForm("/domains", url.Values{"name": {name}})
	if err != nil {
		return "", err
	}
	m := regexp.MustCompile(`^/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
	if m == nil {
		return "", fmt.Errorf("add domain %q: unexpected landing page %s: %s", name, resp.Request.URL.Path, body)
	}
	return m[1], nil
}

// dkimRecord fetches a domain's page and scrapes the DKIM DNS record it tells
// the administrator to publish.
func (c *panelClient) dkimRecord(domainID string) (name, value string, err error) {
	_, body, err := c.get("/domains/" + domainID)
	if err != nil {
		return "", "", err
	}
	name, ok := extractCodeRow(body, "Host / name")
	if !ok {
		return "", "", fmt.Errorf("could not find DKIM record name on domain page")
	}
	value, ok = extractCodeRow(body, "Value")
	if !ok {
		return "", "", fmt.Errorf("could not find DKIM record value on domain page")
	}
	return name, value, nil
}

// addApplication submits the add-application form and returns the one-shot
// login/password the panel renders inline (security.md — never recoverable
// later, so this is the only place to read it).
func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) {
	resp, body, err := c.postForm("/domains/"+domainID+"/applications", url.Values{
		"login":     {login},
		"mode":      {mode},
		"addresses": {addresses},
	})
	if err != nil {
		return "", "", err
	}
	if resp.StatusCode != http.StatusCreated {
		return "", "", fmt.Errorf("add application %q: status %d: %s", login, resp.StatusCode, body)
	}
	appLogin, ok := extractCodeRow(body, "Login")
	if !ok {
		return "", "", fmt.Errorf("could not find new application login in response")
	}
	password, ok = extractCodeRow(body, "Password")
	if !ok {
		return "", "", fmt.Errorf("could not find new application password in response")
	}
	return appLogin, password, nil
}

// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
// or domain (/applications/{id}/ratelimit or /domains/{id}/ratelimit).
func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error {
	vals := url.Values{
		"max_messages":   {fmt.Sprintf("%d", maxMessages)},
		"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
	}
	resp, body, err := c.postForm(path, vals)
	if err != nil {
		return err
	}
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("set rate limit: status %d: %s", resp.StatusCode, body)
	}
	return nil
}

// sendLogRows returns the raw /deliveries/rows HTML fragment, filtered to one
// domain and optionally to one application login, for polling a row's status
// without parsing full HTML into structs. The application is a filter rather
// than something to search the returned rows for: the log's columns identify a
// message (time, from, to, subject, status) and the sending application is only
// named on a row's own /deliveries/{id} page.
func (c *panelClient) sendLogRows(domain, app string) (string, error) {
	q := url.Values{"domain": {domain}}
	if app != "" {
		q.Set("app", app)
	}
	_, body, err := c.get("/deliveries/rows?" + q.Encode())
	return body, err
}

// status fetches the authenticated landing page — used after a container
// restart to confirm the session cookie is still accepted (plan C.4 check 8).
func (c *panelClient) status() (*http.Response, error) {
	resp, _, err := c.get("/status")
	return resp, err
}

// applicationID scrapes an application's numeric id off its block on the domain
// page, keyed by login — needed to build /applications/{id}/ratelimit, which the
// add-application response (just the login/password) does not carry. The id is
// taken from the first action posted under that login, whichever it is, so
// reordering the block's controls does not break the scrape; only the login
// heading itself is anchored on.
func (c *panelClient) applicationID(domainID, login string) (string, error) {
	_, body, err := c.get("/domains/" + domainID)
	if err != nil {
		return "", err
	}
	pattern := `(?s)<p class="app-login">` + regexp.QuoteMeta(login) + `</p>.*?/applications/(\d+)/`
	m := regexp.MustCompile(pattern).FindStringSubmatch(body)
	if m == nil {
		return "", fmt.Errorf("could not find application id for login %q", login)
	}
	return m[1], nil
}

// extractCodeRow scrapes the value of a "<label>LABEL</label> ... <span
// class=\"code\">VALUE</span>" pair from a rendered panel page (see
// internal/web/templates/domain_detail.html). It is deliberately anchored to
// the label text rather than position, so it survives unrelated template
// reordering. html/template's escaper is conservative about which characters
// it entity-encodes in text nodes — a DKIM value's base64 "+" comes back as
// "&#43;" — so the match is HTML-unescaped before returning.
func extractCodeRow(body, label string) (string, bool) {
	pattern := `<label>` + regexp.QuoteMeta(label) + `</label>\s*<div class="code-row">\s*<span class="code">([^<]*)</span>`
	m := regexp.MustCompile(pattern).FindStringSubmatch(body)
	if m == nil {
		return "", false
	}
	return html.UnescapeString(strings.TrimSpace(m[1])), true
}
