// Package mqtt is a minimal MQTT 3.1.1 client, written against the OASIS spec
// rather than pulled in as a dependency: the whole API server is stdlib-only, so
// this sits beside internal/ocpp's hand-rolled RFC 6455 WebSocket for the same
// reason.
//
// It is deliberately scoped to what the Greencell EVSE connector needs
// (internal/plugins/builtin/greencell): connect to a broker, subscribe to a
// handful of topics, publish a discovery broadcast, and read the telemetry that
// comes back. Concretely that means:
//
//   - QoS 0 for everything this client *sends* — subscriptions request QoS 0 and
//     publishes are QoS 0. The Greencell device and Home Assistant's own
//     integration both work at QoS 0.
//   - Inbound PUBLISH at QoS 0, 1 and 2 is accepted and acknowledged correctly,
//     because the QoS of a delivery is the broker's choice, not ours.
//   - Clean session only. There is no persistent session, no offline queue and no
//     automatic reconnect: a connection lives for the length of one plugin call
//     and is closed again, which is exactly how the plugin manager constructs and
//     tears down a plugin instance per request.
//
// A Client is safe for concurrent use; writes are serialized and a single reader
// goroutine owns the connection.
package mqtt

import (
	"bufio"
	"context"
	"crypto/rand"
	"crypto/tls"
	"encoding/binary"
	"encoding/hex"
	"errors"
	"fmt"
	"net"
	"sync"
	"time"
)

// Defaults applied by Options.normalize when a field is left zero.
const (
	defaultKeepalive      = 30 * time.Second
	defaultConnectTimeout = 10 * time.Second
	defaultBuffer         = 256
)

// ErrClosed is returned by Publish and Subscribe once the connection is gone.
var ErrClosed = errors.New("mqtt: connection closed")

// Options configures a broker connection.
type Options struct {
	// Address is the broker's host:port. Required.
	Address string
	// TLS wraps the connection in TLS. TLSConfig overrides the default, which
	// verifies the broker against the system roots using the host from Address.
	TLS       bool
	TLSConfig *tls.Config
	// ClientID identifies this session to the broker. Empty means a random one.
	ClientID string
	// Username and Password are sent in CONNECT when Username is non-empty.
	Username string
	Password string
	// Keepalive is the interval the broker is told to expect traffic within; the
	// client pings at half of it.
	Keepalive time.Duration
	// ConnectTimeout bounds the TCP/TLS dial and the wait for CONNACK.
	ConnectTimeout time.Duration
	// Buffer sizes the delivery channel returned by Messages.
	Buffer int
}

func (o *Options) normalize() {
	if o.Keepalive <= 0 {
		o.Keepalive = defaultKeepalive
	}
	if o.ConnectTimeout <= 0 {
		o.ConnectTimeout = defaultConnectTimeout
	}
	if o.Buffer <= 0 {
		o.Buffer = defaultBuffer
	}
	if o.ClientID == "" {
		o.ClientID = randomClientID()
	}
}

// Message is one delivered PUBLISH.
type Message struct {
	Topic   string
	Payload []byte
	Retain  bool
}

// Client is a connected MQTT session.
type Client struct {
	raw net.Conn
	br  *bufio.Reader

	keepalive time.Duration

	wmu sync.Mutex // serializes writes; the spec requires whole packets

	msgs chan Message

	mu      sync.Mutex
	nextID  uint16
	pending map[uint16]chan []byte // packet id -> acknowledgement body
	err     error

	closeOnce sync.Once
	closed    chan struct{}
}

// Connect dials the broker and completes the MQTT handshake. The returned Client
// owns the connection; call Close when done.
func Connect(ctx context.Context, opt Options) (*Client, error) {
	opt.normalize()
	if opt.Address == "" {
		return nil, errors.New("mqtt: broker address is required")
	}

	dialer := &net.Dialer{Timeout: opt.ConnectTimeout}
	var (
		conn net.Conn
		err  error
	)
	if opt.TLS {
		cfg := opt.TLSConfig
		if cfg == nil {
			host, _, splitErr := net.SplitHostPort(opt.Address)
			if splitErr != nil {
				host = opt.Address
			}
			cfg = &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
		}
		conn, err = (&tls.Dialer{NetDialer: dialer, Config: cfg}).DialContext(ctx, "tcp", opt.Address)
	} else {
		conn, err = dialer.DialContext(ctx, "tcp", opt.Address)
	}
	if err != nil {
		return nil, fmt.Errorf("mqtt: cannot reach broker %s: %w", opt.Address, err)
	}

	c := &Client{
		raw:       conn,
		br:        bufio.NewReader(conn),
		keepalive: opt.Keepalive,
		msgs:      make(chan Message, opt.Buffer),
		nextID:    1,
		pending:   map[uint16]chan []byte{},
		closed:    make(chan struct{}),
	}

	deadline := time.Now().Add(opt.ConnectTimeout)
	if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
		deadline = d
	}
	if err := c.handshake(opt, deadline); err != nil {
		_ = conn.Close()
		return nil, err
	}

	go c.readLoop()
	go c.pingLoop()
	return c, nil
}

// handshake writes CONNECT and waits for an accepting CONNACK. It runs before the
// read loop starts, so it reads the response itself.
func (c *Client) handshake(opt Options, deadline time.Time) error {
	if len(opt.ClientID) > 65535 || len(opt.Username) > 65535 || len(opt.Password) > 65535 {
		return errors.New("mqtt: client id or credentials too long")
	}

	var flags byte = 0x02 // clean session
	if opt.Username != "" {
		flags |= 0x80
		if opt.Password != "" {
			flags |= 0x40
		}
	}

	body := make([]byte, 0, 64)
	body = encodeString(body, protocolName)
	body = append(body, protocolLevel, flags)
	body = encodeUint16(body, uint16(opt.Keepalive/time.Second))
	body = encodeString(body, opt.ClientID)
	if opt.Username != "" {
		body = encodeString(body, opt.Username)
		if opt.Password != "" {
			body = encodeString(body, opt.Password)
		}
	}

	if err := c.raw.SetWriteDeadline(deadline); err != nil {
		return err
	}
	if _, err := c.raw.Write(buildPacket(pktConnect, 0, body)); err != nil {
		return fmt.Errorf("mqtt: sending CONNECT: %w", err)
	}
	if err := c.raw.SetWriteDeadline(time.Time{}); err != nil {
		return err
	}

	if err := c.raw.SetReadDeadline(deadline); err != nil {
		return err
	}
	pkt, err := readPacket(c.br)
	if err != nil {
		return fmt.Errorf("mqtt: waiting for CONNACK: %w", err)
	}
	if err := c.raw.SetReadDeadline(time.Time{}); err != nil {
		return err
	}
	if pkt.typ != pktConnack || len(pkt.body) < 2 {
		return errors.New("mqtt: broker did not answer with a CONNACK")
	}
	return connackError(pkt.body[1])
}

// Messages returns the stream of delivered publications. It is closed when the
// connection ends; check Err for the reason.
func (c *Client) Messages() <-chan Message { return c.msgs }

// Err reports why the connection ended, or nil while it is healthy or after a
// clean Close.
func (c *Client) Err() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.err
}

// Subscribe requests QoS 0 on every given topic filter in one SUBSCRIBE and waits
// for the broker's SUBACK.
func (c *Client) Subscribe(ctx context.Context, topics ...string) error {
	if len(topics) == 0 {
		return nil
	}
	id, ack := c.reserveID()
	defer c.releaseID(id)

	body := encodeUint16(make([]byte, 0, 16*len(topics)), id)
	for _, t := range topics {
		if t == "" || len(t) > 65535 {
			return fmt.Errorf("mqtt: invalid topic filter %q", t)
		}
		body = encodeString(body, t)
		body = append(body, 0) // requested QoS 0
	}
	// SUBSCRIBE reserves the fixed-header flag bits 0010 (spec 3.8.1).
	if err := c.write(buildPacket(pktSubscribe, 0x02, body)); err != nil {
		return err
	}

	select {
	case codes := <-ack:
		for i, code := range codes {
			if code == 0x80 {
				return fmt.Errorf("mqtt: broker refused subscription to %s", topics[min(i, len(topics)-1)])
			}
		}
		return nil
	case <-c.closed:
		return c.closedErr()
	case <-ctx.Done():
		return ctx.Err()
	}
}

// Publish sends a QoS 0, non-retained publication. QoS 0 is fire-and-forget, so
// it returns as soon as the packet is on the wire.
func (c *Client) Publish(ctx context.Context, topic string, payload []byte) error {
	if topic == "" || len(topic) > 65535 {
		return fmt.Errorf("mqtt: invalid topic %q", topic)
	}
	if err := ctx.Err(); err != nil {
		return err
	}
	body := encodeString(make([]byte, 0, len(topic)+len(payload)+2), topic)
	body = append(body, payload...)
	return c.write(buildPacket(pktPublish, 0, body))
}

// Close ends the session, sending DISCONNECT while the connection still works.
func (c *Client) Close() error {
	c.closeOnce.Do(func() {
		// Best effort: a broker that has already gone away needs no goodbye.
		_ = c.write(buildPacket(pktDisconnect, 0, nil))
		close(c.closed)
		_ = c.raw.Close()
	})
	return nil
}

// write sends one whole packet under the write lock.
func (c *Client) write(b []byte) error {
	select {
	case <-c.closed:
		return c.closedErr()
	default:
	}
	c.wmu.Lock()
	defer c.wmu.Unlock()
	if err := c.raw.SetWriteDeadline(time.Now().Add(c.keepalive)); err != nil {
		return err
	}
	_, err := c.raw.Write(b)
	return err
}

// closedErr reports the failure that ended the connection, falling back to
// ErrClosed after a clean shutdown.
func (c *Client) closedErr() error {
	if err := c.Err(); err != nil {
		return err
	}
	return ErrClosed
}

// reserveID allocates a packet identifier and the channel its acknowledgement
// will be delivered on. Identifier 0 is not valid on the wire, so it is skipped
// on wrap.
func (c *Client) reserveID() (uint16, chan []byte) {
	ch := make(chan []byte, 1)
	c.mu.Lock()
	defer c.mu.Unlock()
	id := c.nextID
	c.nextID++
	if c.nextID == 0 {
		c.nextID = 1
	}
	c.pending[id] = ch
	return id, ch
}

func (c *Client) releaseID(id uint16) {
	c.mu.Lock()
	defer c.mu.Unlock()
	delete(c.pending, id)
}

// resolve hands an acknowledgement body to whoever is waiting on that packet id.
func (c *Client) resolve(id uint16, body []byte) {
	c.mu.Lock()
	ch, ok := c.pending[id]
	c.mu.Unlock()
	if !ok {
		return
	}
	select {
	case ch <- body:
	default: // a waiter that already gave up
	}
}

// fail records the error that ended the connection and tears it down.
func (c *Client) fail(err error) {
	c.mu.Lock()
	if c.err == nil {
		c.err = err
	}
	c.mu.Unlock()
	_ = c.Close()
}

// readLoop owns the read side of the connection for the life of the session.
func (c *Client) readLoop() {
	defer close(c.msgs)
	for {
		// The broker is expected to answer our pings, so silence for two
		// keep-alive periods means the connection is dead.
		if err := c.raw.SetReadDeadline(time.Now().Add(2 * c.keepalive)); err != nil {
			c.fail(err)
			return
		}
		pkt, err := readPacket(c.br)
		if err != nil {
			select {
			case <-c.closed:
				return // our own Close raced the read; not a failure
			default:
			}
			c.fail(fmt.Errorf("mqtt: reading from broker: %w", err))
			return
		}
		if !c.dispatch(pkt) {
			return
		}
	}
}

// dispatch handles one received packet and reports whether the loop continues.
func (c *Client) dispatch(pkt packet) bool {
	switch pkt.typ {
	case pktPublish:
		return c.deliver(pkt)
	case pktSuback, pktUnsuback, pktPuback:
		if len(pkt.body) >= 2 {
			c.resolve(binary.BigEndian.Uint16(pkt.body), pkt.body[2:])
		}
	case pktPubrel:
		// Second half of an inbound QoS 2 delivery: the broker released the
		// message, so complete the exchange.
		if len(pkt.body) >= 2 {
			body := encodeUint16(nil, binary.BigEndian.Uint16(pkt.body))
			if err := c.write(buildPacket(pktPubcomp, 0, body)); err != nil {
				c.fail(err)
				return false
			}
		}
	case pktPingresp, pktPubrec, pktPubcomp:
		// Nothing to do: we send no QoS >0 publications of our own.
	}
	return true
}

// deliver parses an inbound PUBLISH, acknowledges it at its QoS, and hands it to
// the consumer.
func (c *Client) deliver(pkt packet) bool {
	qos := (pkt.flags >> 1) & 0x03
	topic, rest, err := readString(pkt.body)
	if err != nil {
		c.fail(fmt.Errorf("mqtt: malformed PUBLISH: %w", err))
		return false
	}
	if qos > 0 {
		if len(rest) < 2 {
			c.fail(errors.New("mqtt: PUBLISH missing packet identifier"))
			return false
		}
		id := binary.BigEndian.Uint16(rest)
		rest = rest[2:]
		// QoS 1 completes with PUBACK; QoS 2 starts the four-way exchange whose
		// PUBREL half is handled in dispatch.
		ackType := pktPuback
		if qos == 2 {
			ackType = pktPubrec
		}
		if err := c.write(buildPacket(ackType, 0, encodeUint16(nil, id))); err != nil {
			c.fail(err)
			return false
		}
	}

	msg := Message{Topic: topic, Payload: rest, Retain: pkt.flags&0x01 != 0}
	select {
	case c.msgs <- msg:
	case <-c.closed:
		return false
	}
	return true
}

// pingLoop keeps the session alive, pinging at half the negotiated interval so a
// single lost PINGREQ does not expire it.
func (c *Client) pingLoop() {
	t := time.NewTicker(c.keepalive / 2)
	defer t.Stop()
	for {
		select {
		case <-c.closed:
			return
		case <-t.C:
			if err := c.write(buildPacket(pktPingreq, 0, nil)); err != nil {
				c.fail(err)
				return
			}
		}
	}
}

// randomClientID builds an identifier the broker has not seen before, so two
// concurrent plugin calls never evict each other's session.
func randomClientID() string {
	var b [8]byte
	if _, err := rand.Read(b[:]); err != nil {
		return fmt.Sprintf("drivervault-%d", time.Now().UnixNano())
	}
	return "drivervault-" + hex.EncodeToString(b[:])
}
