package smarthome

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

// DefaultTimeout — per-call budget. A house that takes longer than this to
// answer is not usable in a spoken turn.
const DefaultTimeout = 10 * time.Second

// DefaultMaxEntities — cap on how many entities become allowlist proposals.
// The resident model is a 1.7B with a 4096-token context: a tool name it
// half-remembers is a wrong act, so a bounded, deliberate catalogue beats a
// complete one.
const DefaultMaxEntities = 40

// maxBody — cap on one /api/states response. A Home Assistant with hundreds of
// entities would otherwise stream megabytes into a daemon that wants forty
// names.
const maxBody = 4 << 20

// Config — what a Home Assistant instance needs to be reachable.
type Config struct {
	// URL — the base, "http://homeassistant.local:8123". No trailing path.
	URL string
	// Token — a long-lived access token. Sent as a bearer header and never
	// logged.
	Token string
	// Domains — the entity domains to take. Empty ⇒ every domain in the
	// controllable table EXCEPT lock, plus sensor/binary_sensor for reads.
	// A lock is only enumerated when it is named here.
	Domains []string
	// MaxEntities — 0 ⇒ DefaultMaxEntities.
	MaxEntities int
	// Timeout — 0 ⇒ DefaultTimeout.
	Timeout time.Duration
}

// Validate rejects a block that cannot work, at config-load time rather than at
// the first spoken act.
func Validate(c Config) error {
	if c.URL == "" {
		return errors.New("smarthome: url is required")
	}
	u, err := url.Parse(c.URL)
	if err != nil {
		return fmt.Errorf("smarthome: url: %w", err)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return fmt.Errorf("smarthome: url scheme %q: want http or https", u.Scheme)
	}
	if u.Host == "" {
		return errors.New("smarthome: url has no host")
	}
	if c.Token == "" {
		return errors.New("smarthome: token is required")
	}
	return nil
}

// Client is a Home Assistant REST client. Read (States) and one write
// (CallService); no WebSocket, because a spoken turn is request/response and an
// event stream is a second failure mode for no gain yet.
type Client struct {
	cfg  Config
	http *http.Client
}

// NewClient builds a client. Validate first — this does not.
func NewClient(cfg Config) *Client {
	if cfg.Timeout <= 0 {
		cfg.Timeout = DefaultTimeout
	}
	if cfg.MaxEntities <= 0 {
		cfg.MaxEntities = DefaultMaxEntities
	}
	return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
}

// SetHTTPClient swaps the transport. Tests use it; nothing else should.
func (c *Client) SetHTTPClient(h *http.Client) { c.http = h }

// wanted reports whether an entity's domain is one Maven takes. The config list
// wins when set; otherwise every controllable domain plus the two read-only
// sensor domains.
func (c *Client) wanted(domain string) bool {
	if len(c.cfg.Domains) > 0 {
		for _, d := range c.cfg.Domains {
			if d == domain {
				return true
			}
		}
		return false
	}
	if _, ok := controllable[domain]; ok {
		// A deadbolt is a different class of object from a lamp, so "lock" is
		// not in the implicit set: a bare url+token block must not auto-propose
		// an unlock row for every door in the flat. Naming it in domains is the
		// operator saying he meant it.
		return domain != "lock"
	}
	return domain == "sensor" || domain == "binary_sensor"
}

type haState struct {
	EntityID   string          `json:"entity_id"`
	State      string          `json:"state"`
	Attributes json.RawMessage `json:"attributes"`
}

type haAttrs struct {
	FriendlyName string `json:"friendly_name"`
	Unit         string `json:"unit_of_measurement"`
}

// capEntities cuts a state list to at most max entries, taking a controllable
// entity before any sensor and then round-robin across domains.
//
// The cap used to be applied to a globally id-sorted list, and entity ids sort
// by domain prefix: binary_sensor < cover < fan < light < lock < sensor <
// switch. A stock Home Assistant carries dozens of binary_sensor rows before it
// carries anything else, so forty slots went entirely to connectivity and
// update-available sensors. propose then found zero controllable entities, and
// homeSummary, reading the same list, said "всё выключено" with the lights on.
//
// The cap itself stays. The resident model is a 1.7B with a 4096-token context
// and a tool name it half-remembers is a wrong act, so a bounded deliberate
// catalogue still beats a complete one. What changes is which forty: every
// switch and light before any sensor, and an even spread inside each group so
// one crowded domain cannot starve the others. The result is sorted by id, so
// /tools reads the same across restarts.
func capEntities(all []Entity, max int) []Entity {
	if max <= 0 || len(all) <= max {
		sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID })
		return all
	}
	byDomain := map[string][]Entity{}
	for _, e := range all {
		byDomain[e.Domain] = append(byDomain[e.Domain], e)
	}
	var control, read []string
	for d := range byDomain {
		sort.Slice(byDomain[d], func(i, j int) bool { return byDomain[d][i].ID < byDomain[d][j].ID })
		if _, ok := controllable[d]; ok {
			control = append(control, d)
		} else {
			read = append(read, d)
		}
	}
	sort.Strings(control)
	sort.Strings(read)

	out := make([]Entity, 0, max)
	take := func(domains []string) {
		for i := 0; len(out) < max; i++ {
			took := false
			for _, d := range domains {
				l := byDomain[d]
				if i >= len(l) || len(out) >= max {
					continue
				}
				out = append(out, l[i])
				took = true
			}
			if !took {
				return
			}
		}
	}
	take(control)
	take(read)
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out
}

// States reads every entity Maven cares about, sorted by id and capped at
// MaxEntities so the catalogue is deterministic across restarts — a proposal
// list that reshuffles itself would make /tools unreadable. See capEntities for
// what the cap keeps.
func (c *Client) States(ctx context.Context) ([]Entity, error) {
	body, err := c.do(ctx, http.MethodGet, "/api/states", nil)
	if err != nil {
		return nil, err
	}
	var raw []haState
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, fmt.Errorf("smarthome: decode states: %w", err)
	}
	out := make([]Entity, 0, len(raw))
	for _, s := range raw {
		domain := DomainOf(s.EntityID)
		if domain == "" || !c.wanted(domain) {
			continue
		}
		e := Entity{ID: s.EntityID, Domain: domain, Name: s.EntityID, State: s.State}
		if len(s.Attributes) > 0 {
			var a haAttrs
			// Attributes are free-form per integration; a shape we cannot read
			// costs the friendly name, not the entity.
			if err := json.Unmarshal(s.Attributes, &a); err == nil {
				if a.FriendlyName != "" {
					e.Name = a.FriendlyName
				}
				e.Unit = a.Unit
			}
		}
		out = append(out, e)
	}
	return capEntities(out, c.cfg.MaxEntities), nil
}

// CallService performs one service call against one entity and returns a short
// Russian confirmation.
//
// The entity id and service are NOT taken from the utterance: they come from
// the allowlist row that Kami enabled, so the router can only pick a row, never
// compose a target. That is the whole reason control is encoded in the cmd
// column instead of parsed out of speech.
func (c *Client) CallService(ctx context.Context, entityID, service string) (string, error) {
	domain := DomainOf(entityID)
	if domain == "" {
		return "", ErrUnknownEntity
	}
	svcs := Services(domain)
	if len(svcs) == 0 {
		return "", ErrNotControllable
	}
	known := false
	for _, s := range svcs {
		if s.Name == service {
			known = true
			break
		}
	}
	if !known {
		return "", fmt.Errorf("%w: %s has no service %q", ErrNotControllable, domain, service)
	}
	payload, err := json.Marshal(map[string]string{"entity_id": entityID})
	if err != nil {
		return "", fmt.Errorf("smarthome: encode call: %w", err)
	}
	path := "/api/services/" + url.PathEscape(domain) + "/" + url.PathEscape(service)
	body, err := c.do(ctx, http.MethodPost, path, payload)
	if err != nil {
		return "", err
	}
	// Home Assistant answers a service call with the states it changed. An
	// entity that was removed since discovery, or one whose integration is
	// offline, gets 200 and an empty array. Reporting "готово" for that is
	// Maven asserting something false about the physical world: he says
	// "выключи свет", she says done, the light stays on.
	var changed []haState
	if err := json.Unmarshal(body, &changed); err != nil {
		// A shape we cannot read is not evidence of failure. HA has answered
		// 2xx, so report the call as made rather than inventing a fault.
		return "готово", nil
	}
	if len(changed) == 0 {
		return "", fmt.Errorf("%w: %s did not change anything", ErrUnknownEntity, entityID)
	}
	return "готово", nil
}

// do issues one authenticated request and returns the (capped) body.
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
	if c.cfg.URL == "" || c.cfg.Token == "" {
		return nil, ErrNotConfigured
	}
	target := strings.TrimRight(c.cfg.URL, "/") + path
	var rdr io.Reader
	if body != nil {
		rdr = bytes.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, method, target, rdr)
	if err != nil {
		return nil, fmt.Errorf("smarthome: request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("smarthome: %s %s: %w", method, path, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		// The status only, and read before the body: an error page can carry the
		// instance's own detail, and there is no reason to pull it into memory to
		// discard it.
		return nil, fmt.Errorf("smarthome: %s %s: http %d", method, path, resp.StatusCode)
	}
	out, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
	if err != nil {
		return nil, fmt.Errorf("smarthome: read %s: %w", path, err)
	}
	return out, nil
}
