package relaydev

import (
	"context"
	"fmt"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"sync"
)

// A generic HTTP relay board: one URL to switch a relay on, one to switch it off.
//
// This is for the home-made switch — an ESP8266 with a web page, a Shelly, a
// Sonoff running third-party firmware, any of the boxes that answer a GET and
// have no protocol worth naming. The named drivers beside it exist because
// their boards need something specific; this one exists because most of them
// need nothing at all.
//
// TWO WAYS TO CONFIGURE IT, and the difference matters:
//
//   - one URL pair with {relay} in it, used for every relay:
//     http://192.168.1.9/relay?n={relay}&state=on
//   - one pair per relay, when the box has no pattern to speak of:
//     relay 1 → http://192.168.1.9/FF0101 , relay 2 → .../FF0201
//
// The second is the reason this driver exists. A hand-made switch often has
// URLs with nothing in common between channels, and a template with {relay}
// cannot express that.
//
// TWO SUBSTITUTIONS are available in either form:
//
//	{relay}   the relay number, 1-based. {relay-1} for a board that counts its
//	          channels from zero — otherwise the whole pattern has to be given
//	          up for eight hand-typed URLs over one missing offset.
//	{value}   that relay's LABEL, the name given to it in Relay labels. A switch
//	          addressed by antenna name rather than by channel number
//	          (…/relay?on=Ant1) is then one pattern instead of eight URLs, and
//	          renaming the antenna re-addresses it — the name the operator reads
//	          on the button and the name on the wire cannot drift apart because
//	          they are the same string.
//
// The label is percent-encoded, so a name with a space or an accent goes out as
// a valid URL rather than a request the board rejects without saying why.
//
// STATE IS REMEMBERED, NOT READ. Most of these boxes have no status endpoint,
// or answer with a web page nobody can parse reliably. Status therefore returns
// what we last commanded — see the method for what that costs.
type httpGen struct {
	onURLs  []string // index 0 = relay 1; "" falls back to the pattern
	offURLs []string
	onPat   string // pattern with {relay}, used when the per-relay URL is empty
	offPat  string
	labels  []string // index 0 = relay 1; what {value} resolves to
	user    string
	pass    string
	count   int
	// insecure accepts a certificate nothing can verify — the self-signed one a
	// relay board on the LAN presents. Per board, and the operator's choice.
	insecure bool

	mu    sync.Mutex
	state []bool
}

// NewHTTPGeneric builds the driver. onURLs/offURLs are per relay (index 0 =
// relay 1) and may be short or hold empty entries; onPat/offPat are the
// fallback patterns; labels are the relay names {value} substitutes.
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int, labels []string, insecure bool) Device {
	if count <= 0 {
		count = len(onURLs)
	}
	if count <= 0 {
		count = 1
	}
	return &httpGen{
		onURLs: onURLs, offURLs: offURLs,
		onPat: onPat, offPat: offPat, labels: labels,
		user: user, pass: pass, count: count, insecure: insecure,
		state: make([]bool, count),
	}
}

func (h *httpGen) Count() int   { return h.count }
func (h *httpGen) Close() error { return nil } // stateless HTTP, nothing to release

// patFor returns the pattern for a direction, trimmed.
func (h *httpGen) patFor(on bool) string {
	if on {
		return strings.TrimSpace(h.onPat)
	}
	return strings.TrimSpace(h.offPat)
}

// entryFor returns what was typed in the per-relay box for a direction.
func (h *httpGen) entryFor(relay int, on bool) string {
	list := h.offURLs
	if on {
		list = h.onURLs
	}
	if i := relay - 1; i >= 0 && i < len(list) {
		return strings.TrimSpace(list[i])
	}
	return ""
}

// labelFor returns the relay's name, as typed in Relay labels.
func (h *httpGen) labelFor(relay int) string {
	if i := relay - 1; i >= 0 && i < len(h.labels) {
		return strings.TrimSpace(h.labels[i])
	}
	return ""
}

// urlFor builds the request for one relay in one direction: the per-relay URL
// if there is one, the pattern otherwise, with both substitutions applied.
func (h *httpGen) urlFor(relay int, on bool) string {
	u := h.entryFor(relay, on)
	if u == "" {
		u = h.patFor(on)
	}
	if u == "" {
		return ""
	}
	return expand(u, relay, h.labelFor(relay))
}

// escapeValue percent-encodes a relay label for use anywhere in a URL.
//
// url.QueryEscape alone is wrong: it writes a space as "+", which is a space
// only in a query string and a literal plus sign in a path. Encoding it as %20
// instead is correct in both, and {value} may land in either.
func escapeValue(s string) string {
	return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}

// withScheme supplies http:// when none was typed, and leaves https:// alone.
//
// The same rule the named boards get from relayBase, and it has to be here too:
// this driver takes whole URLs rather than a host, and a line typed as
// "192.168.1.9/Set0/1" would otherwise fail with "unsupported protocol scheme"
// — an error about a scheme, for a field where nobody knew one was expected.
// An https:// board (a reverse proxy fronting the shack, most often) is passed
// through untouched and needs no other handling: it is the same HTTP client.
func withScheme(u string) string {
	if u == "" {
		return ""
	}
	if l := strings.ToLower(u); strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
		return u
	}
	return "http://" + u
}

// relayToken matches {relay} and its offset forms, {relay-1} / {relay+2}.
var relayToken = regexp.MustCompile(`\{relay([+-]\d+)?\}`)

// expand substitutes {value} with the relay's label and {relay} with its
// number, honouring an offset. A board that numbers its channels from zero is
// written {relay-1}; without that the whole pattern has to be abandoned for
// four hand-typed URLs.
func expand(s string, relay int, label string) string {
	s = strings.ReplaceAll(s, "{value}", escapeValue(label))
	return relayToken.ReplaceAllStringFunc(s, func(m string) string {
		n := relay
		if i := strings.IndexAny(m, "+-"); i >= 0 {
			if off, err := strconv.Atoi(m[i : len(m)-1]); err == nil {
				n += off
			}
		}
		return strconv.Itoa(n)
	})
}

func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
	if relay < 1 || relay > h.count {
		return fmt.Errorf("relay %d out of range 1..%d", relay, h.count)
	}
	// Naming the direction matters: an operator who filled the ON URLs and left
	// OFF empty gets a switch that latches, and "no URL configured" alone would
	// not say which half is missing.
	dir := "OFF"
	if on {
		dir = "ON"
	}
	tmpl := h.entryFor(relay, on)
	if tmpl == "" {
		tmpl = h.patFor(on)
	}
	if tmpl == "" {
		return fmt.Errorf("no %s URL configured for relay %d", dir, relay)
	}
	// {value} with no label would send "?on=" — an empty parameter to an antenna
	// switch, which most boards answer with a cheerful 200 and no movement. Say
	// what is missing instead of firing it.
	if strings.Contains(tmpl, "{value}") && h.labelFor(relay) == "" {
		return fmt.Errorf("the %s URL for relay %d uses {value}, but relay %d has no label to put there", dir, relay, relay)
	}
	u := h.urlFor(relay, on)
	u = withScheme(u)
	if _, err := get(ctx, u, h.user, h.pass, h.insecure); err != nil {
		return err
	}
	h.mu.Lock()
	if i := relay - 1; i < len(h.state) {
		h.state[i] = on
	}
	h.mu.Unlock()
	return nil
}

// Status returns what was last commanded, not what the board is doing.
//
// These boxes rarely have a status endpoint worth trusting, so there is nothing
// to read. The cost is real and worth stating: after OpsLog restarts, every
// relay reads as off until something switches it, so the automatic control
// re-commands each one once. That is a harmless extra command on a board with
// no memory of its own — and far better than guessing a state and leaving an
// antenna disconnected because we assumed it was already selected.
func (h *httpGen) Status(ctx context.Context) ([]bool, error) {
	h.mu.Lock()
	defer h.mu.Unlock()
	out := make([]bool, h.count)
	copy(out, h.state)
	return out, nil
}
