// Package relaydev drives network relay boards used for station control (power
// sequencing, switching accessories). Two devices are supported, both over HTTP:
//
//   - WebSwitch 1216H — 5 relays. Control: GET /relaycontrol/{on|off}/{n};
//     status: GET /relaystate/get2/1$2$3$4$5$ → lines "n,state".
//     (Protocol taken from the operator's own working ShackMaster driver.)
//   - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
//     (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
//     <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
//   - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET
//     CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&;
//     status: GET /relay_cgi_load.cgi. Both answer &-separated fields.
//     (IOT Relay Programming Manual V1.9.8.1, §3.)
//
// A Device presents the same surface to the app regardless of wire protocol.
package relaydev

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// Device is one relay board.
type Device interface {
	Count() int                                        // number of user-controllable relays
	Status(ctx context.Context) ([]bool, error)        // state of each relay (index 0 = relay 1)
	Set(ctx context.Context, relay int, on bool) error // relay is 1-based
	// Close releases any OS handle the driver holds (serial port, FTDI handle).
	// Network boards hold nothing and no-op. MUST be called when a cached driver is
	// discarded so the port/handle is freed for the next open — stateful boards
	// (Denkovi, USB-serial) can only be opened by one handle at a time.
	Close() error
}

func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }

// insecureClient talks to a board presenting a certificate nothing can verify.
//
// Which is nearly every board that offers HTTPS at all: a relay box on the LAN
// signs its own certificate, and there is no authority anywhere that could have
// signed it. Refusing that means refusing HTTPS on the hardware, which is not a
// security decision, only an outcome.
//
// So it is offered, per board, and OFF by default — because the other HTTPS
// case is real and opposite: a board reached from outside through a proxy with
// a genuine certificate, where verification is the only thing standing between
// an antenna switch and the internet. One box, on the board that needs it.
//
// Built once. A Transport per request would open a fresh TLS connection every
// time and never reuse one.
var insecureClient = &http.Client{
	Timeout: 5 * time.Second,
	Transport: &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // the operator ticked the box for this board
	},
}

// certError says which box to tick when TLS is what failed.
//
// Go's own message — "x509: certificate signed by unknown authority" — is
// accurate and tells an operator nothing about what to do next. Naming the
// setting turns a dead end into an instruction.
func certError(err error) error {
	var unknown x509.UnknownAuthorityError
	var host x509.HostnameError
	var verify *tls.CertificateVerificationError
	if errors.As(err, &unknown) || errors.As(err, &host) || errors.As(err, &verify) {
		return fmt.Errorf("%w — the board's HTTPS certificate cannot be verified; "+
			"tick \"Accept a self-signed certificate\" for this board if it is on your own network", err)
	}
	return err
}

// get issues a GET with optional basic auth and returns the body on 2xx.
//
// insecure skips certificate verification, for a board that signs its own.
func get(ctx context.Context, url, user, pass string, insecure bool) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	if user != "" || pass != "" {
		req.SetBasicAuth(user, pass)
	}
	client := httpClient()
	if insecure {
		client = insecureClient
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, certError(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
	}
	return body, nil
}

// relayBase turns a configured host into the base URL of a network relay board.
// A bare host or host:port keeps the default http:// (the boards serve plain
// HTTP on their LAN). A host that already carries a scheme is used verbatim, so
// a board can be reached through an HTTPS reverse proxy — e.g. a Nginx Proxy
// Manager entry "https://relay.example.com" fronting the LAN board's port 80,
// which is how an operator reaches shack relays from outside when 80/443 are
// already taken by the proxy. A trailing slash is trimmed so path parts append
// cleanly; an optional sub-path in the host is preserved (a sub-path proxy).
func relayBase(host string) string {
	h := strings.TrimSpace(host)
	if strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://") {
		return strings.TrimRight(h, "/")
	}
	return "http://" + h
}

// ── WebSwitch 1216H ────────────────────────────────────────────────────

type webswitch struct {
	host  string
	count int
}

// NewWebswitch builds a WebSwitch 1216H client (5 relays).
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }

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

func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
	if relay < 1 || relay > w.count {
		return fmt.Errorf("relay %d out of range 1..%d", relay, w.count)
	}
	action := "off"
	if on {
		action = "on"
	}
	_, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "", false)
	return err
}

func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
	// Build the "1$2$3$..." selector the device expects.
	var sel strings.Builder
	for i := 1; i <= w.count; i++ {
		sel.WriteString(strconv.Itoa(i))
		sel.WriteByte('$')
	}
	body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "", false)
	if err != nil {
		return nil, err
	}
	out := make([]bool, w.count)
	// Lines "n,state" — "1,1", "2,0", …
	for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
		parts := strings.Split(strings.TrimSpace(line), ",")
		if len(parts) != 2 {
			continue
		}
		n, e1 := strconv.Atoi(parts[0])
		st, e2 := strconv.Atoi(strings.TrimSpace(parts[1]))
		if e1 != nil || e2 != nil || n < 1 || n > w.count {
			continue
		}
		out[n-1] = st == 1
	}
	return out, nil
}

// ── KMTronic LAN 8-relay WEB ───────────────────────────────────────────

type kmtronic struct {
	host       string
	user, pass string
	count      int
}

// NewKMTronic builds a KMTronic LAN WEB relay client (8 relays). user/pass are
// blank unless the board's HTTP authentication is enabled.
func NewKMTronic(host, user, pass string) Device {
	return &kmtronic{host: host, user: user, pass: pass, count: 8}
}

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

func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
	if relay < 1 || relay > k.count {
		return fmt.Errorf("relay %d out of range 1..%d", relay, k.count)
	}
	state := "00"
	if on {
		state = "01"
	}
	// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
	_, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass, false)
	return err
}

// kmStatus mirrors status.xml. relay0 is reserved; relay1..relay8 are the board.
type kmStatus struct {
	XMLName xml.Name `xml:"response"`
	Relays  []struct {
		XMLName xml.Name
		Value   string `xml:",chardata"`
	} `xml:",any"`
}

func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
	body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass, false)
	if err != nil {
		return nil, err
	}
	var s kmStatus
	if err := xml.Unmarshal(body, &s); err != nil {
		return nil, fmt.Errorf("kmtronic: bad status.xml: %w", err)
	}
	out := make([]bool, k.count)
	for _, r := range s.Relays {
		// Element names are relay0..relay8; relay0 is reserved.
		name := r.XMLName.Local
		if !strings.HasPrefix(name, "relay") {
			continue
		}
		n, e := strconv.Atoi(strings.TrimPrefix(name, "relay"))
		if e != nil || n < 1 || n > k.count {
			continue
		}
		out[n-1] = strings.TrimSpace(r.Value) == "1"
	}
	return out, nil
}

// ── Dingtian IOT relay (DTWONDER) ──────────────────────────────────────
//
// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we
// use the HTTP GET CGI, which needs no connection state and matches how the
// other network boards here are driven.
//
// Both endpoints answer &-separated fields, first one 0 on success:
//
//	/relay_cgi_load.cgi                                  → &0&4&1&0&1&0&
//	                                                       result, count, r1…rN
//	/relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0&     → &0&0&0&1&0&
//	                                                       result, type, relay, on, time
//
// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the
// Device interface is 1-based like every other board here.
type dingtian struct {
	host    string
	session string // optional: the board can require "Cookie: session=<id>"
	pwd     string // CGI password, 0–9999; "0" (or blank) when none is set
	count   int
}

// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID
// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay
// password (blank or "0" when none).
func NewDingtian(host, session, pwd string, count int) Device {
	if count <= 0 {
		count = 2
	}
	if strings.TrimSpace(pwd) == "" {
		pwd = "0"
	}
	return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count}
}

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

// getCGI issues the GET with the session cookie the board may require.
func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	if d.session != "" {
		req.Header.Set("Cookie", "session="+d.session)
	}
	resp, err := httpClient().Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
	}
	return body, nil
}

// dtFields splits an &-separated CGI reply into its fields, dropping the empty
// ones the leading and trailing "&" produce.
func dtFields(body []byte) []string {
	var out []string
	for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") {
		if f = strings.TrimSpace(f); f != "" {
			out = append(out, f)
		}
	}
	return out
}

func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
	if relay < 1 || relay > d.count {
		return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
	}
	state := 0
	if on {
		state = 1
	}
	// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
	// and time is then unused.
	body, err := d.getCGI(ctx, fmt.Sprintf("%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
		relayBase(d.host), relay-1, state, d.pwd))
	if err != nil {
		return err
	}
	// A wrong password or session answers 200 with a non-zero result (e.g.
	// "&302&/&"), so the HTTP status alone does not tell us it worked.
	if f := dtFields(body); len(f) == 0 || f[0] != "0" {
		return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID",
			strings.TrimSpace(string(body)))
	}
	return nil
}

func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
	body, err := d.getCGI(ctx, fmt.Sprintf("%s/relay_cgi_load.cgi", relayBase(d.host)))
	if err != nil {
		return nil, err
	}
	f := dtFields(body)
	if len(f) < 2 || f[0] != "0" {
		return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body)))
	}
	// The board reports its own relay count; trust it over the configured one so
	// a mis-set channel count doesn't silently hide relays.
	if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 {
		d.count = n
	}
	out := make([]bool, d.count)
	for i := 0; i < d.count && i+2 < len(f); i++ {
		out[i] = f[i+2] == "1"
	}
	return out, nil
}
