package smarthome

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// statesFixture is a trimmed /api/states response from a Home Assistant with
// one light, one switch, one sensor and two entities Maven must ignore.
const statesFixture = `[
 {"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}},
 {"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}},
 {"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"unit_of_measurement":"°C"}},
 {"entity_id":"person.kami","state":"home","attributes":{}},
 {"entity_id":"automation.wake","state":"on","attributes":[]}
]`

func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
	t.Helper()
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)
	c := NewClient(Config{URL: srv.URL, Token: "tok"})
	return c, srv
}

func TestStatesFiltersAndNames(t *testing.T) {
	var auth string
	c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		auth = r.Header.Get("Authorization")
		if r.URL.Path != "/api/states" {
			t.Errorf("path = %q", r.URL.Path)
		}
		_, _ = w.Write([]byte(statesFixture))
	})
	got, err := c.States(context.Background())
	if err != nil {
		t.Fatalf("States: %v", err)
	}
	if auth != "Bearer tok" {
		t.Errorf("Authorization = %q", auth)
	}
	// person and automation are neither controllable nor sensors.
	want := []string{"light.living_room", "sensor.bedroom_temp", "switch.kettle"}
	if len(got) != len(want) {
		t.Fatalf("got %d entities, want %d: %+v", len(got), len(want), got)
	}
	for i, id := range want {
		if got[i].ID != id {
			t.Errorf("entity %d = %q, want %q (sorted by id)", i, got[i].ID, id)
		}
	}
	if got[0].Name != "Гостиная" || got[0].Domain != "light" || got[0].State != "on" {
		t.Errorf("light = %+v", got[0])
	}
	if got[1].Unit != "°C" {
		t.Errorf("sensor unit = %q", got[1].Unit)
	}
	// An attributes value of the wrong shape must not lose the entity.
	if got[2].Name != "Чайник" {
		t.Errorf("switch name = %q", got[2].Name)
	}
}

func TestStatesRespectsConfiguredDomainsAndCap(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(statesFixture))
	}))
	defer srv.Close()

	c := NewClient(Config{URL: srv.URL, Token: "t", Domains: []string{"switch"}})
	got, err := c.States(context.Background())
	if err != nil {
		t.Fatalf("States: %v", err)
	}
	if len(got) != 1 || got[0].ID != "switch.kettle" {
		t.Fatalf("domain filter: %+v", got)
	}

	c = NewClient(Config{URL: srv.URL, Token: "t", MaxEntities: 2})
	got, err = c.States(context.Background())
	if err != nil {
		t.Fatalf("States: %v", err)
	}
	if len(got) != 2 {
		t.Fatalf("cap: got %d entities, want 2", len(got))
	}
}

func TestCallServicePostsEntityID(t *testing.T) {
	var path, body string
	c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		path = r.URL.Path
		b := make([]byte, 256)
		n, _ := r.Body.Read(b)
		body = string(b[:n])
		_, _ = w.Write([]byte(`[{"entity_id":"light.living_room","state":"off"}]`))
	})
	out, err := c.CallService(context.Background(), "light.living_room", "turn_off")
	if err != nil {
		t.Fatalf("CallService: %v", err)
	}
	if out != "готово" {
		t.Errorf("out = %q", out)
	}
	if path != "/api/services/light/turn_off" {
		t.Errorf("path = %q", path)
	}
	if !strings.Contains(body, `"entity_id":"light.living_room"`) {
		t.Errorf("body = %q", body)
	}
}

// A service that is not in the domain's table never leaves the box. The
// allowlist is the gate, and it is enforced on the way out too, so a corrupted
// or hand-edited cmd column cannot reach an arbitrary Home Assistant service.
func TestCallServiceRefusesUnknownServiceAndDomain(t *testing.T) {
	called := false
	c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		called = true
		_, _ = w.Write([]byte(`[]`))
	})
	for _, tc := range []struct {
		entity, service string
		want            error
	}{
		{"light.living_room", "delete_everything", ErrNotControllable},
		{"sensor.bedroom_temp", "turn_on", ErrNotControllable},
		{"nodot", "turn_on", ErrUnknownEntity},
	} {
		if _, err := c.CallService(context.Background(), tc.entity, tc.service); !errors.Is(err, tc.want) {
			t.Errorf("CallService(%q,%q) err = %v, want %v", tc.entity, tc.service, err, tc.want)
		}
	}
	if called {
		t.Error("a refused call still reached the network")
	}
}

func TestHTTPErrorIsAnError(t *testing.T) {
	c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusUnauthorized)
	})
	if _, err := c.States(context.Background()); err == nil {
		t.Fatal("want error on 401")
	}
}

func TestUnconfiguredClientRefuses(t *testing.T) {
	c := NewClient(Config{})
	if _, err := c.States(context.Background()); !errors.Is(err, ErrNotConfigured) {
		t.Fatalf("err = %v, want ErrNotConfigured", err)
	}
}

func TestValidate(t *testing.T) {
	ok := Config{URL: "http://ha.lan:8123", Token: "t"}
	if err := Validate(ok); err != nil {
		t.Fatalf("Validate(ok): %v", err)
	}
	for name, c := range map[string]Config{
		"no url":      {Token: "t"},
		"no token":    {URL: "http://ha.lan:8123"},
		"bad scheme":  {URL: "ftp://ha.lan", Token: "t"},
		"no host":     {URL: "http://", Token: "t"},
		"not a url":   {URL: "://x", Token: "t"},
		"bare string": {URL: "ha.lan:8123", Token: "t"},
	} {
		if err := Validate(c); err == nil {
			t.Errorf("Validate(%s) = nil, want error", name)
		}
	}
}

func TestAllowlistEncoding(t *testing.T) {
	cmd := Cmd("light.living_room", "turn_off")
	id, svc, ok := ParseCmd(cmd)
	if !ok || id != "light.living_room" || svc != "turn_off" {
		t.Fatalf("ParseCmd(%v) = %q,%q,%v", cmd, id, svc, ok)
	}
	// Anything that is not exactly a three-element smarthome row stays a
	// process row, or the executor would swallow a real shell tool.
	for _, bad := range [][]string{
		nil,
		{"smarthome"},
		{"smarthome", "light.x"},
		{"smarthome", "light.x", "turn_on", "extra"},
		{"smarthome", "", "turn_on"},
		{"smarthome", "light.x", ""},
		{"systemctl", "restart", "nginx"},
	} {
		if _, _, ok := ParseCmd(bad); ok {
			t.Errorf("ParseCmd(%v) = ok, want not a smarthome row", bad)
		}
	}
	if got := LocalName("light.living_room", "off"); got != "home_light_living_room_off" {
		t.Errorf("LocalName = %q", got)
	}
	if got := Scope("light"); got != "smarthome:light" {
		t.Errorf("Scope = %q", got)
	}
	if Services("light") == nil || Services("sensor") != nil {
		t.Error("Services: light must be controllable and sensor must not")
	}
	if DomainOf("light.x") != "light" || DomainOf("nodot") != "" || DomainOf(".x") != "" {
		t.Error("DomainOf")
	}
}

// Home Assistant answers a service call with the states it changed, and an
// entity that has been removed or whose integration is offline gets 200 and an
// empty array. "готово" for that is Maven asserting something false about the
// physical world: he says выключи свет, she says done, the light stays on.
func TestCallServiceOnAnEntityThatChangedNothing(t *testing.T) {
	c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`[]`))
	})
	if _, err := c.CallService(context.Background(), "light.living_room", "turn_off"); !errors.Is(err, ErrUnknownEntity) {
		t.Errorf("err = %v, want ErrUnknownEntity for a call that changed nothing", err)
	}
	// A response shape we cannot parse is not evidence of failure: HA answered
	// 2xx, so the call is reported as made.
	c2, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"result":"ok"}`))
	})
	if out, err := c2.CallService(context.Background(), "light.living_room", "turn_off"); err != nil || out != "готово" {
		t.Errorf("out,err = %q,%v", out, err)
	}
}

// The cap must not be spent on sensors. Entity ids sort by domain prefix and
// binary_sensor sorts first, so a globally sorted truncation handed all forty
// slots to connectivity sensors: propose found nothing controllable and
// homeSummary said "всё выключено" with the lights on.
func TestCapKeepsControllableEntitiesFirst(t *testing.T) {
	var all []Entity
	for i := 0; i < 40; i++ {
		all = append(all, Entity{ID: fmt.Sprintf("binary_sensor.b%02d", i), Domain: "binary_sensor", State: "off"})
	}
	for i := 0; i < 30; i++ {
		all = append(all, Entity{ID: fmt.Sprintf("sensor.s%02d", i), Domain: "sensor", State: "1"})
	}
	for i := 0; i < 4; i++ {
		all = append(all, Entity{ID: fmt.Sprintf("switch.w%d", i), Domain: "switch", State: "on"})
	}
	for i := 0; i < 3; i++ {
		all = append(all, Entity{ID: fmt.Sprintf("light.l%d", i), Domain: "light", State: "on"})
	}
	got := capEntities(all, 40)
	if len(got) != 40 {
		t.Fatalf("kept %d entities, want 40", len(got))
	}
	kept := map[string]int{}
	for _, e := range got {
		kept[e.Domain]++
	}
	if kept["switch"] != 4 || kept["light"] != 3 {
		t.Errorf("kept %d switches and %d lights, want all 4 and all 3: %v", kept["switch"], kept["light"], kept)
	}
	// The remainder still carries readable sensors, spread across both sensor
	// domains rather than exhausting the one that sorts first.
	if kept["sensor"] == 0 || kept["binary_sensor"] == 0 {
		t.Errorf("the read domains were starved: %v", kept)
	}
	// Deterministic across restarts: /tools has to read the same each time.
	for i := 1; i < len(got); i++ {
		if got[i-1].ID >= got[i].ID {
			t.Fatalf("output is not sorted by id at %d", i)
		}
	}
}

// A deadbolt is a different class of object from a lamp. A bare url+token block
// must not auto-propose an unlock row for every door in the flat.
func TestLockIsNotInTheDefaultDomains(t *testing.T) {
	c := NewClient(Config{URL: "http://x", Token: "t"})
	if c.wanted("lock") {
		t.Error("lock is enumerated without being named in domains")
	}
	if !c.wanted("light") || !c.wanted("sensor") {
		t.Error("the ordinary default domains were lost")
	}
	named := NewClient(Config{URL: "http://x", Token: "t", Domains: []string{"lock"}})
	if !named.wanted("lock") {
		t.Error("lock named in domains is still not enumerated")
	}
}
