package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/internal/salestracking"
)

// fakeHR answers EmployeeByUserID from a map, so a "no row" case is just a
// missing key — which is what the old fallback was papering over.
type fakeHR struct{ rows map[int64]int64 }

func (f fakeHR) EmployeeByUserID(_ context.Context, userID int64) (db.GetEmployeeByUserIDRow, error) {
	id, ok := f.rows[userID]
	if !ok {
		return db.GetEmployeeByUserIDRow{}, errors.New("no rows")
	}
	return db.GetEmployeeByUserIDRow{ID: id}, nil
}

// The regression this file exists for: a user with no employees row must get
// ErrNoEmployee and must never be handed some other employee's id. The old
// resolver ended in `SELECT id FROM employees ... ORDER BY id LIMIT 1`, which
// returned employee 1 (Budi Hartono) for the 51 of 58 accounts on this
// database that have no employees row — every salesperson included, each one
// reading that person's visit schedule and LHPP worksheets.
func TestEmployeeLookup_NoEmployeeRowFailsClosed(t *testing.T) {
	// A real pool, because the resolver will consult the email join before
	// giving up — a nil *pgxpool.Pool panics on Acquire rather than
	// returning the error, which would test nothing about fail-closed.
	pool := scratchPool(t)
	got := employeeLookup(pool, fakeHR{rows: map[int64]int64{1: 1}})

	id, err := got(context.Background(), 999)
	if !errors.Is(err, salestracking.ErrNoEmployee) {
		t.Fatalf("err = %v, want ErrNoEmployee (a user without an employee row must not resolve)", err)
	}
	if id != 0 {
		t.Errorf("id = %d, want 0", id)
	}
}

func TestEmployeeLookup_NilHRStoreFailsClosed(t *testing.T) {
	pool := scratchPool(t)
	if id, err := employeeLookup(pool, nil)(context.Background(), 999); !errors.Is(err, salestracking.ErrNoEmployee) {
		t.Fatalf("err = %v, want ErrNoEmployee when the store is absent", err)
	} else if id != 0 {
		t.Errorf("id = %d, want 0", id)
	}
}

// The sanctioned link still wins, untouched by the removal. A nil pool is safe
// HERE specifically because user 42 resolves on the first branch and never
// reaches the email query — which is also the behaviour being asserted.
func TestEmployeeLookup_PrefersEmployeeUserLink(t *testing.T) {
	id, err := employeeLookup(nil, fakeHR{rows: map[int64]int64{42: 7}})(context.Background(), 42)
	if err != nil {
		t.Fatal(err)
	}
	if id != 7 {
		t.Errorf("id = %d, want 7 (employees.user_id link)", id)
	}
}

// A row that exists but carries id 0 is not a resolution: 0 is not a valid
// employees id, and returning it would propagate a useless answer.
func TestEmployeeLookup_ZeroEmployeeIDIsNotAResolution(t *testing.T) {
	pool := scratchPool(t)
	if id, err := employeeLookup(pool, fakeHR{rows: map[int64]int64{999: 0}})(context.Background(), 999); !errors.Is(err, salestracking.ErrNoEmployee) {
		t.Fatalf("err = %v, want ErrNoEmployee for a zero employee id", err)
	} else if id != 0 {
		t.Errorf("id = %d, want 0", id)
	}
}

// The email path is load-bearing, not a convenience: the seeded Sales
// Representative account has employees.user_id NULL and is matched by email.
// Removing it along with the fallback would 404 a real salesperson, so it is
// exercised against a real PostgreSQL rather than asserted textually.
var employeeLookupDDL = []string{
	`CREATE TABLE users (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL)`,
	`CREATE TABLE employees (
		id BIGSERIAL PRIMARY KEY,
		employee_number TEXT NOT NULL,
		user_id BIGINT REFERENCES users(id),
		email TEXT,
		deleted_at TIMESTAMPTZ
	)`,
	`INSERT INTO users (id, email) VALUES
		(1, 'admin@kmart.id'),
		(2, 'sales@kmart.id'),
		(3, 'kasir@kmart.id')`,
	`INSERT INTO employees (id, employee_number, user_id, email, deleted_at) VALUES
		(1, 'EMP-001', 1, 'admin@kmart.id', NULL),
		-- The load-bearing case: linked by email only, no user_id.
		(2, 'EMP-002', NULL, 'sales@kmart.id', NULL),
		-- Soft-deleted: must never resolve.
		(3, 'EMP-003', NULL, 'kasir@kmart.id', now())`,
}

// scratchPool gives the test its own schema on a throwaway pool, so it never
// reads or writes the developer's data. Same DATABASE_URL-gated pattern the
// sales repository tests use.
func scratchPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	url := os.Getenv("DATABASE_URL")
	if url == "" {
		t.Skip("DATABASE_URL is not set")
	}
	ctx := context.Background()
	base, err := pgxpool.New(ctx, url)
	if err != nil {
		t.Fatal(err)
	}
	schema := fmt.Sprintf("employee_lookup_probe_%d", time.Now().UnixNano())
	if _, err := base.Exec(ctx, `CREATE SCHEMA `+schema); err != nil {
		base.Close()
		t.Fatal(err)
	}
	cfg, err := pgxpool.ParseConfig(url)
	if err != nil {
		base.Close()
		t.Fatal(err)
	}
	cfg.ConnConfig.RuntimeParams["search_path"] = schema
	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		base.Close()
		t.Fatal(err)
	}
	t.Cleanup(func() {
		pool.Close()
		if _, err := base.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`); err != nil {
			t.Logf("drop schema %s: %v", schema, err)
		}
		base.Close()
	})
	for _, stmt := range employeeLookupDDL {
		if _, err := pool.Exec(ctx, stmt); err != nil {
			t.Fatalf("%s: %v", stmt, err)
		}
	}
	return pool
}

func TestEmployeeLookup_EmailMatchStillResolves(t *testing.T) {
	pool := scratchPool(t)
	// No employees.user_id for user 2, so only the email join can resolve it.
	got := employeeLookup(pool, fakeHR{rows: map[int64]int64{1: 1}})

	id, err := got(context.Background(), 2)
	if err != nil {
		t.Fatalf("user 2 resolves by email in production (Sales Representative): %v", err)
	}
	if id != 2 {
		t.Errorf("id = %d, want 2", id)
	}
}

func TestEmployeeLookup_SoftDeletedEmployeeDoesNotResolve(t *testing.T) {
	pool := scratchPool(t)
	got := employeeLookup(pool, fakeHR{rows: map[int64]int64{1: 1}})

	if id, err := got(context.Background(), 3); !errors.Is(err, salestracking.ErrNoEmployee) {
		t.Fatalf("err = %v, want ErrNoEmployee — a soft-deleted employee row must not resolve", err)
	} else if id != 0 {
		t.Errorf("id = %d, want 0", id)
	}
}

// The leak, stated as a test: a user with no employee row of any kind gets
// nothing, rather than the first employee in the table.
func TestEmployeeLookup_UnknownUserGetsNoEmployeeNotTheFirstOne(t *testing.T) {
	pool := scratchPool(t)
	// The table HAS an active, non-deleted employee (id 1) — exactly the
	// condition that used to make the old fallback return it.
	var firstEmployee int64
	if err := pool.QueryRow(context.Background(), `SELECT id FROM employees WHERE deleted_at IS NULL ORDER BY id LIMIT 1`).Scan(&firstEmployee); err != nil {
		t.Fatal(err)
	}
	if firstEmployee == 0 {
		t.Fatal("fixture must contain a resolvable employee for this test to mean anything")
	}

	got := employeeLookup(pool, fakeHR{rows: map[int64]int64{1: 1}})
	id, err := got(context.Background(), 404)
	if !errors.Is(err, salestracking.ErrNoEmployee) {
		t.Fatalf("err = %v, want ErrNoEmployee, not employee %d", err, firstEmployee)
	}
	if id != 0 {
		t.Errorf("id = %d, want 0 — user 404 has no employee row and must not borrow employee %d's id",
			id, firstEmployee)
	}
}

var _ = pgx.ErrNoRows
