//go:build integration

package devices_test

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"
	_ "github.com/go-sql-driver/mysql"
	"github.com/testcontainers/testcontainers-go"
	tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"

	"github.com/wangjia/pangolin/server/internal/devices"
)

// --------------------------------------------------------------------------
// Container + schema setup
// --------------------------------------------------------------------------

func setupMySQL(t *testing.T) *sql.DB {
	t.Helper()
	ctx := context.Background()

	ctr, err := tcmysql.Run(ctx, "mysql:8.0",
		tcmysql.WithDatabase("pangolin_test"),
		tcmysql.WithUsername("root"),
		tcmysql.WithPassword("test"),
	)
	testcontainers.CleanupContainer(t, ctr)
	if err != nil {
		t.Fatalf("mysql container: %v", err)
	}

	dsn, err := ctr.ConnectionString(ctx, "parseTime=true", "loc=UTC", "time_zone='+00:00'")
	if err != nil {
		t.Fatalf("mysql dsn: %v", err)
	}

	db, err := sql.Open("mysql", dsn)
	if err != nil {
		t.Fatalf("open mysql: %v", err)
	}
	t.Cleanup(func() { db.Close() })

	if err := applySchema(db); err != nil {
		t.Fatalf("schema: %v", err)
	}
	return db
}

func applySchema(db *sql.DB) error {
	stmts := []string{
		`CREATE TABLE IF NOT EXISTS users (
			id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			uuid       CHAR(36) NOT NULL UNIQUE,
			email      VARCHAR(255) NOT NULL UNIQUE,
			pw_hash    VARCHAR(255) NOT NULL DEFAULT '',
			dp_uuid    CHAR(36) NOT NULL,
			status     ENUM('active','banned') NOT NULL DEFAULT 'active',
			created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`CREATE TABLE IF NOT EXISTS devices (
			id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			uuid       CHAR(36) NOT NULL UNIQUE,
			user_id    BIGINT UNSIGNED NOT NULL,
			name       VARCHAR(64) NOT NULL,
			platform   ENUM('ios','android','windows','macos') NOT NULL,
			last_seen  DATETIME(6) NULL,
			created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
			FOREIGN KEY (user_id) REFERENCES users(id),
			INDEX idx_user (user_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`CREATE TABLE IF NOT EXISTS plans (
			id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			code          ENUM('free','pro','team') NOT NULL UNIQUE,
			max_devices   INT NOT NULL,
			daily_minutes INT NULL,
			ad_gate       BOOLEAN NOT NULL DEFAULT FALSE
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`CREATE TABLE IF NOT EXISTS subscriptions (
			id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			user_id    BIGINT UNSIGNED NOT NULL,
			plan_id    BIGINT UNSIGNED NOT NULL,
			expires_at DATETIME(6) NOT NULL,
			source     ENUM('trial','code') NOT NULL,
			created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
			FOREIGN KEY (user_id) REFERENCES users(id),
			FOREIGN KEY (plan_id) REFERENCES plans(id),
			INDEX idx_user_exp (user_id, expires_at)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`CREATE TABLE IF NOT EXISTS audit_log (
			id     BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			actor  VARCHAR(64) NOT NULL,
			action VARCHAR(64) NOT NULL,
			target VARCHAR(128) NOT NULL,
			meta   JSON NULL,
			at     DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
			INDEX idx_at (at)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
		 VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`,
	}
	for _, stmt := range stmts {
		if _, err := db.Exec(stmt); err != nil {
			return fmt.Errorf("schema exec: %w\nSQL: %s", err, stmt)
		}
	}
	return nil
}

// createUser inserts a user (status active) and returns its id.
func createUser(t *testing.T, db *sql.DB, email, status string) int64 {
	t.Helper()
	res, err := db.Exec(
		`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status)
		 VALUES (UUID(), ?, 'x', UUID(), ?)`, email, status)
	if err != nil {
		t.Fatalf("createUser: %v", err)
	}
	id, _ := res.LastInsertId()
	return id
}

// giveSubscription inserts a subscription for the user.
func giveSubscription(t *testing.T, db *sql.DB, userID int64, plan, source string, expiresAt time.Time) {
	t.Helper()
	var planID int64
	if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, plan).Scan(&planID); err != nil {
		t.Fatalf("plan lookup: %v", err)
	}
	if _, err := db.Exec(
		`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,?,?)`,
		userID, planID, expiresAt.UTC(), source); err != nil {
		t.Fatalf("giveSubscription: %v", err)
	}
}

func newUUID(t *testing.T, db *sql.DB) string {
	t.Helper()
	var u string
	if err := db.QueryRow(`SELECT UUID()`).Scan(&u); err != nil {
		t.Fatalf("uuid: %v", err)
	}
	return u
}

// --------------------------------------------------------------------------
// Tests
// --------------------------------------------------------------------------

// TestFullChain exercises register → implicit re-register → list → delete and
// asserts credential revocation is triggered on delete.
func TestFullChain(t *testing.T) {
	db := setupMySQL(t)
	store := devices.NewStore(db)
	revoker := &devices.NoopRevoker{}
	svc := devices.NewService(store, revoker)
	ctx := context.Background()

	userID := createUser(t, db, "chain@example.com", "active")
	// 7-day pro trial → effective plan pro (max 5).
	giveSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(7*24*time.Hour))

	plan, apiErr := svc.ResolvePlan(ctx, userID)
	if apiErr != nil {
		t.Fatalf("ResolvePlan: %v", apiErr)
	}
	if plan.PlanCode != "pro" || plan.MaxDevices != 5 {
		t.Fatalf("want pro/5, got %s/%d", plan.PlanCode, plan.MaxDevices)
	}

	devUUID := newUUID(t, db)
	in := devices.RegisterInput{UserID: userID, DeviceUUID: devUUID, Name: "iPhone 15 Pro", Platform: "ios", MaxDevices: plan.MaxDevices}

	// First sight → insert.
	d1, apiErr := svc.RegisterIfAbsent(ctx, in)
	if apiErr != nil {
		t.Fatalf("RegisterIfAbsent: %v", apiErr)
	}
	if d1.UUID != devUUID || d1.LastSeen == nil {
		t.Fatalf("unexpected device: %+v", d1)
	}

	// Second sight → idempotent (no new row), last_seen refreshed.
	if _, apiErr := svc.RegisterIfAbsent(ctx, in); apiErr != nil {
		t.Fatalf("re-register: %v", apiErr)
	}
	list, apiErr := svc.ListDevices(ctx, userID)
	if apiErr != nil {
		t.Fatalf("ListDevices: %v", apiErr)
	}
	if len(list) != 1 {
		t.Fatalf("expected 1 device after re-register, got %d", len(list))
	}

	// Delete → list drops to 0, audit row exists, revoker called.
	if apiErr := svc.DeleteDevice(ctx, userID, devUUID); apiErr != nil {
		t.Fatalf("DeleteDevice: %v", apiErr)
	}
	list, _ = svc.ListDevices(ctx, userID)
	if len(list) != 0 {
		t.Fatalf("expected 0 devices after delete, got %d", len(list))
	}

	var auditCount int
	db.QueryRow(`SELECT COUNT(1) FROM audit_log WHERE action='device.delete' AND actor=? AND target=?`,
		fmt.Sprintf("user:%d", userID), "device:"+devUUID).Scan(&auditCount)
	if auditCount != 1 {
		t.Errorf("expected 1 device.delete audit row, got %d", auditCount)
	}

	if len(revoker.Calls) != 1 {
		t.Fatalf("expected 1 revoke call, got %d", len(revoker.Calls))
	}
	if revoker.Calls[0].UserID != userID || revoker.Calls[0].Reason != "device_deleted" {
		t.Errorf("unexpected revoke call: %+v", revoker.Calls[0])
	}
}

// TestDeviceLimitEnforced verifies RegisterIfAbsent rejects when the cap is hit.
func TestDeviceLimitEnforced(t *testing.T) {
	db := setupMySQL(t)
	svc := devices.NewService(devices.NewStore(db), nil)
	ctx := context.Background()

	userID := createUser(t, db, "free@example.com", "active")
	// No subscription → free plan, max_devices 1.
	plan, _ := svc.ResolvePlan(ctx, userID)
	if plan.MaxDevices != 1 {
		t.Fatalf("expected free max_devices 1, got %d", plan.MaxDevices)
	}

	first := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "Pixel", Platform: "android", MaxDevices: 1}
	if _, apiErr := svc.RegisterIfAbsent(ctx, first); apiErr != nil {
		t.Fatalf("first register: %v", apiErr)
	}

	second := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "iPad", Platform: "ios", MaxDevices: 1}
	_, apiErr := svc.RegisterIfAbsent(ctx, second)
	if apiErr == nil {
		t.Fatal("expected second register to be rejected")
	}
	if apiErr.Code != "DEVICE_LIMIT_EXCEEDED" {
		t.Errorf("want DEVICE_LIMIT_EXCEEDED, got %s", apiErr.Code)
	}
}

// TestDeleteOthersDevice verifies ownership enforcement and not-found handling.
func TestDeleteOthersDevice(t *testing.T) {
	db := setupMySQL(t)
	svc := devices.NewService(devices.NewStore(db), nil)
	ctx := context.Background()

	owner := createUser(t, db, "owner@example.com", "active")
	other := createUser(t, db, "other@example.com", "active")

	devUUID := newUUID(t, db)
	if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
		UserID: owner, DeviceUUID: devUUID, Name: "Mac", Platform: "macos", MaxDevices: 5,
	}); apiErr != nil {
		t.Fatalf("register: %v", apiErr)
	}

	// Other user cannot delete it → 403 FORBIDDEN.
	if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "FORBIDDEN" {
		t.Errorf("want FORBIDDEN, got %v", apiErr)
	}
	// Non-existent device → 404 NOT_FOUND.
	if apiErr := svc.DeleteDevice(ctx, owner, newUUID(t, db)); apiErr == nil || apiErr.Code != "NOT_FOUND" {
		t.Errorf("want NOT_FOUND, got %v", apiErr)
	}
}

// TestBannedUserRejected verifies the resolver/middleware path rejects banned users.
func TestBannedUserRejected(t *testing.T) {
	db := setupMySQL(t)
	svc := devices.NewService(devices.NewStore(db), nil)
	ctx := context.Background()

	userID := createUser(t, db, "banned@example.com", "banned")
	if _, apiErr := svc.ResolvePlan(ctx, userID); apiErr == nil || apiErr.Code != "ACCOUNT_BANNED" {
		t.Errorf("want ACCOUNT_BANNED, got %v", apiErr)
	}
}

// TestHTTPHandlers exercises the chi routes end-to-end through the subscription
// middleware (userID injected as the JWT middleware would).
func TestHTTPHandlers(t *testing.T) {
	db := setupMySQL(t)
	svc := devices.NewService(devices.NewStore(db), &devices.NoopRevoker{})
	ctx := context.Background()

	userID := createUser(t, db, "http@example.com", "active")
	giveSubscription(t, db, userID, "pro", "code", time.Now().UTC().Add(30*24*time.Hour))

	devUUID := newUUID(t, db)
	if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
		UserID: userID, DeviceUUID: devUUID, Name: "Win", Platform: "windows", MaxDevices: 5,
	}); apiErr != nil {
		t.Fatalf("register: %v", apiErr)
	}

	mw := devices.NewMiddleware(svc, nil, 0)
	h := devices.NewHandler(svc)

	r := chi.NewRouter()
	// Simulate the JWT auth middleware setting the user ID.
	r.Use(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			next.ServeHTTP(w, req.WithContext(devices.WithUserID(req.Context(), userID)))
		})
	})
	r.Route("/v1/me", func(r chi.Router) {
		r.Use(mw.Handler)
		h.RegisterRoutes(r)
	})

	// GET /v1/me/devices
	req := httptest.NewRequest(http.MethodGet, "/v1/me/devices", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("GET devices: want 200, got %d body=%s", w.Code, w.Body.String())
	}
	var listResp struct {
		Devices []devices.Device `json:"devices"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil {
		t.Fatalf("decode list: %v", err)
	}
	if len(listResp.Devices) != 1 || listResp.Devices[0].UUID != devUUID {
		t.Fatalf("unexpected list: %+v", listResp.Devices)
	}

	// DELETE /v1/me/devices/{id}
	req = httptest.NewRequest(http.MethodDelete, "/v1/me/devices/"+devUUID, nil)
	w = httptest.NewRecorder()
	r.ServeHTTP(w, req)
	if w.Code != http.StatusNoContent {
		t.Fatalf("DELETE device: want 204, got %d body=%s", w.Code, w.Body.String())
	}

	// DELETE a non-existent device → 404.
	req = httptest.NewRequest(http.MethodDelete, "/v1/me/devices/"+newUUID(t, db), nil)
	w = httptest.NewRecorder()
	r.ServeHTTP(w, req)
	if w.Code != http.StatusNotFound {
		t.Fatalf("DELETE missing device: want 404, got %d body=%s", w.Code, w.Body.String())
	}
}
