// Package authd — tenants.go: Tenant (a.k.a. company) CRUD.
//
// The M13a schema (009_auth.up.sql) introduced the auth.tenants
// table. M13b W1 turns that into a fully-managed resource in the
// admin UI: super-admins can create / list / edit / suspend /
// activate tenants; tenant-admins get a read-only view of their
// own tenant. All state transitions write an audit_log row so the
// Audit log UI (M13c) can show "who suspended tenant X, when".
//
// Threading: safe for concurrent use (pgx pool is goroutine-safe).

package authd

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

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

)

// Tenant is the wire shape returned to handlers / JSON callers.
// The wire shape is kept flat and snake_case to match the rest
// of the M13a / M13b admin API.
type Tenant struct {
	ID                string     `json:"id"`
	Slug              string     `json:"slug"`
	DisplayName       string     `json:"display_name"`
	Status            string     `json:"status"`
	ContactEmail      string     `json:"contact_email"`
	RateLimitPerSec   int        `json:"rate_limit_per_sec"`
	FCMShared         bool       `json:"fcm_shared"`
	CreatedAt         time.Time  `json:"created_at"`
	UpdatedAt         time.Time  `json:"updated_at"`
	ArchivedAt        *time.Time `json:"archived_at,omitempty"`
}

// ErrTenantNotFound is returned when a tenant id or slug does not
// exist. Distinct from ErrUserNotFound so callers can disambiguate.
var ErrTenantNotFound = errors.New("authd: tenant not found")

// ErrTenantSlugTaken is returned when CreateTenant sees a slug
// collision. UI surfaces this as a 409.
var ErrTenantSlugTaken = errors.New("authd: tenant slug already taken")

// ErrTenantInvalid is returned when input validation fails (e.g.
// slug doesn't match the regex, or rate_limit_per_sec is out of
// range). The wrapped error string is safe to surface to the UI.
var ErrTenantInvalid = errors.New("authd: tenant input invalid")

// TenantFilter controls ListTenants. Empty fields mean "no filter".
// Limit caps the result count; 0 → default of 100. Max 500.
type TenantFilter struct {
	Q      string // matches slug OR display_name (ILIKE)
	Status string // exact match: "active" | "suspended" | "archived" | ""
	Limit  int
	Offset int
	// Scope controls what's visible.
	//   "all"   — super_admin only: every tenant
	//   "self"  — returns the single tenant matching CallerTenantID
	CallerRole     string
	CallerTenantID string
}

// ListTenants returns the tenants visible to the caller under the
// given filter, plus the total count (for pagination in the UI).
func (s *Store) ListTenants(ctx context.Context, f TenantFilter) ([]Tenant, int, error) {
	if s.pool == nil {
		return nil, 0, errors.New("authd: no DB pool (test mode)")
	}
	if f.Limit <= 0 {
		f.Limit = 100
	}
	if f.Limit > 500 {
		f.Limit = 500
	}

	// Build the WHERE clause. We use $N-style placeholders that
	// we count as we go so it's safe to extend.
	args := []any{}
	conds := []string{}

	if strings.TrimSpace(f.Status) != "" {
		args = append(args, f.Status)
		conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
	}
	if strings.TrimSpace(f.Q) != "" {
		args = append(args, "%"+strings.TrimSpace(f.Q)+"%")
		conds = append(conds, fmt.Sprintf("(slug ILIKE $%d OR display_name ILIKE $%d)", len(args), len(args)))
	}
	// Scope: tenant_admin only sees their own tenant.
	if f.CallerRole != "super_admin" {
		if f.CallerTenantID == "" {
			// A non-super_admin without a tenant_id has no business
			// listing tenants. Return an empty page so the UI
			// shows "0 results" rather than leaking the existence
			// of other tenants.
			return []Tenant{}, 0, nil
		}
		args = append(args, f.CallerTenantID)
		conds = append(conds, fmt.Sprintf("id = $%d", len(args)))
	}
	where := ""
	if len(conds) > 0 {
		where = "WHERE " + strings.Join(conds, " AND ")
	}

	// Count first (cheap, uses the same WHERE).
	var total int
	countQ := "SELECT COUNT(*) FROM auth.tenants " + where
	if err := s.pool.QueryRow(ctx, countQ, args...).Scan(&total); err != nil {
		return nil, 0, fmt.Errorf("count tenants: %w", err)
	}

	// Then the page.
	args = append(args, f.Limit, f.Offset)
	pageQ := fmt.Sprintf(`
		SELECT id::text, slug, display_name, status, contact_email,
		       rate_limit_per_sec, fcm_shared, created_at, updated_at, archived_at
		FROM auth.tenants
		%s
		ORDER BY created_at DESC
		LIMIT $%d OFFSET $%d
	`, where, len(args)-1, len(args))
	rows, err := s.pool.Query(ctx, pageQ, args...)
	if err != nil {
		return nil, 0, fmt.Errorf("list tenants: %w", err)
	}
	defer rows.Close()
	out := make([]Tenant, 0, f.Limit)
	for rows.Next() {
		var t Tenant
		if err := rows.Scan(
			&t.ID, &t.Slug, &t.DisplayName, &t.Status, &t.ContactEmail,
			&t.RateLimitPerSec, &t.FCMShared, &t.CreatedAt, &t.UpdatedAt, &t.ArchivedAt,
		); err != nil {
			return nil, 0, fmt.Errorf("scan tenant: %w", err)
		}
		out = append(out, t)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, fmt.Errorf("rows: %w", err)
	}
	return out, total, nil
}

// GetTenant fetches a single tenant by id.
func (s *Store) GetTenant(ctx context.Context, id string) (*Tenant, error) {
	if s.pool == nil {
		return nil, errors.New("authd: no DB pool (test mode)")
	}
	const q = `
		SELECT id::text, slug, display_name, status, contact_email,
		       rate_limit_per_sec, fcm_shared, created_at, updated_at, archived_at
		FROM auth.tenants
		WHERE id = $1
	`
	t := &Tenant{}
	err := s.pool.QueryRow(ctx, q, id).Scan(
		&t.ID, &t.Slug, &t.DisplayName, &t.Status, &t.ContactEmail,
		&t.RateLimitPerSec, &t.FCMShared, &t.CreatedAt, &t.UpdatedAt, &t.ArchivedAt,
	)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, ErrTenantNotFound
		}
		return nil, fmt.Errorf("get tenant: %w", err)
	}
	return t, nil
}

// CreateTenantInput is the validated create payload.
type CreateTenantInput struct {
	Slug            string
	DisplayName     string
	ContactEmail    string
	RateLimitPerSec int
	FCMShared       *bool // nil → use default true
}

// Validate runs the constraints the DB enforces, but earlier and
// with friendlier error messages for the UI.
func (in *CreateTenantInput) Validate() error {
	if !validSlug(in.Slug) {
		return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTenantInvalid)
	}
	if strings.TrimSpace(in.DisplayName) == "" {
		return fmt.Errorf("%w: display_name is required", ErrTenantInvalid)
	}
	if !looksLikeEmail(in.ContactEmail) {
		return fmt.Errorf("%w: contact_email is not a valid email", ErrTenantInvalid)
	}
	if in.RateLimitPerSec < 1 || in.RateLimitPerSec > 1_000_000 {
		return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrTenantInvalid)
	}
	return nil
}

// CreateTenant inserts a new tenant in 'active' status and writes
// an audit_log row. Returns the new id. Duplicate slug →
// ErrTenantSlugTaken (so the UI can show a 409).
func (s *Store) CreateTenant(ctx context.Context, in CreateTenantInput, actorUserID, actorIP, actorUA string) (*Tenant, error) {
	if s.pool == nil {
		return nil, errors.New("authd: no DB pool (test mode)")
	}
	if err := in.Validate(); err != nil {
		return nil, err
	}
	fcmShared := true
	if in.FCMShared != nil {
		fcmShared = *in.FCMShared
	}
	const q = `
		INSERT INTO auth.tenants
		    (slug, display_name, contact_email, rate_limit_per_sec, fcm_shared, status)
		VALUES
		    ($1, $2, $3, $4, $5, 'active')
		RETURNING id::text
	`
	var id string
	err := s.pool.QueryRow(ctx, q,
		in.Slug, in.DisplayName, in.ContactEmail, in.RateLimitPerSec, fcmShared,
	).Scan(&id)
	if err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
			return nil, ErrTenantSlugTaken
		}
		return nil, fmt.Errorf("create tenant: %w", err)
	}
	if err := s.WriteAudit(ctx, "tenant.create", actorUserID, actorIP, actorUA, id, id, map[string]any{
		"slug":              in.Slug,
		"display_name":      in.DisplayName,
		"contact_email":     in.ContactEmail,
		"rate_limit_per_sec": in.RateLimitPerSec,
		"fcm_shared":        fcmShared,
	}); err != nil {
		// Audit failure is non-fatal: log and continue. The tenant
		// was created; the audit row is observability, not authz.
		// Errors are returned via fmt.Errorf wrapping; callers may
		// log them. We don't return the error to the caller.
		_ = err
	}
	return s.GetTenant(ctx, id)
}

// UpdateTenantInput is the validated update payload. Pointer
// fields mean "leave unchanged" when nil — this is the standard
// PATCH semantics.
type UpdateTenantInput struct {
	DisplayName     *string
	ContactEmail    *string
	RateLimitPerSec *int
	FCMShared       *bool
}

// Validate runs the constraints the DB enforces, but earlier.
func (in *UpdateTenantInput) Validate() error {
	if in.DisplayName != nil && strings.TrimSpace(*in.DisplayName) == "" {
		return fmt.Errorf("%w: display_name cannot be empty", ErrTenantInvalid)
	}
	if in.ContactEmail != nil && !looksLikeEmail(*in.ContactEmail) {
		return fmt.Errorf("%w: contact_email is not a valid email", ErrTenantInvalid)
	}
	if in.RateLimitPerSec != nil && (*in.RateLimitPerSec < 1 || *in.RateLimitPerSec > 1_000_000) {
		return fmt.Errorf("%w: rate_limit_per_sec must be 1..1000000", ErrTenantInvalid)
	}
	return nil
}

// UpdateTenant applies a partial update and writes an audit_log
// row with the changed fields. Returns the new state.
//
// "actorScopeAll" controls whether the caller can edit every
// field (super_admin) or only display_name + contact_email
// (tenant_admin on their own tenant). If false and the patch
// includes a restricted field, returns ErrTenantInvalid.
func (s *Store) UpdateTenant(
	ctx context.Context,
	id string,
	in UpdateTenantInput,
	actorScopeAll bool,
	actorUserID, actorIP, actorUA string,
) (*Tenant, error) {
	if s.pool == nil {
		return nil, errors.New("authd: no DB pool (test mode)")
	}
	if err := in.Validate(); err != nil {
		return nil, err
	}
	// Tenant_admin is restricted to display_name + contact_email.
	if !actorScopeAll {
		if in.RateLimitPerSec != nil || in.FCMShared != nil {
			return nil, fmt.Errorf("%w: only super_admin can change rate_limit_per_sec or fcm_shared", ErrTenantInvalid)
		}
	}

	// Build the SET clause incrementally so unset fields don't
	// touch the row.
	sets := []string{}
	args := []any{pgid(id)}
	if in.DisplayName != nil {
		args = append(args, strings.TrimSpace(*in.DisplayName))
		sets = append(sets, fmt.Sprintf("display_name = $%d", len(args)))
	}
	if in.ContactEmail != nil {
		args = append(args, *in.ContactEmail)
		sets = append(sets, fmt.Sprintf("contact_email = $%d", len(args)))
	}
	if in.RateLimitPerSec != nil {
		args = append(args, *in.RateLimitPerSec)
		sets = append(sets, fmt.Sprintf("rate_limit_per_sec = $%d", len(args)))
	}
	if in.FCMShared != nil {
		args = append(args, *in.FCMShared)
		sets = append(sets, fmt.Sprintf("fcm_shared = $%d", len(args)))
	}
	if len(sets) == 0 {
		// Nothing to change. Return the current state.
		return s.GetTenant(ctx, id)
	}
	q := fmt.Sprintf(`UPDATE auth.tenants SET %s WHERE id = $1`, strings.Join(sets, ", "))
	tag, err := s.pool.Exec(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("update tenant: %w", err)
	}
	if tag.RowsAffected() == 0 {
		return nil, ErrTenantNotFound
	}

	// Build audit payload (only the fields the caller sent).
	payload := map[string]any{}
	if in.DisplayName != nil {
		payload["display_name"] = *in.DisplayName
	}
	if in.ContactEmail != nil {
		payload["contact_email"] = *in.ContactEmail
	}
	if in.RateLimitPerSec != nil {
		payload["rate_limit_per_sec"] = *in.RateLimitPerSec
	}
	if in.FCMShared != nil {
		payload["fcm_shared"] = *in.FCMShared
	}
	if err := s.WriteAudit(ctx, "tenant.update", actorUserID, actorIP, actorUA, id, id, payload); err != nil {
		_ = err
	}
	return s.GetTenant(ctx, id)
}

// SetTenantStatus changes the status. Allowed transitions:
//   active    -> suspended, archived
//   suspended -> active, archived
//   archived  -> (terminal — no transitions out of archived)
//
// archived is terminal. Setting status=archived also stamps
// archived_at = NOW(). Writes an audit_log row with the
// {from, to} transition.
func (s *Store) SetTenantStatus(
	ctx context.Context,
	id, newStatus string,
	actorUserID, actorIP, actorUA string,
) (*Tenant, error) {
	if s.pool == nil {
		return nil, errors.New("authd: no DB pool (test mode)")
	}
	switch newStatus {
	case "active", "suspended", "archived":
	default:
		return nil, fmt.Errorf("%w: status must be active|suspended|archived", ErrTenantInvalid)
	}
	cur, err := s.GetTenant(ctx, id)
	if err != nil {
		return nil, err
	}
	if cur.Status == "archived" {
		return nil, fmt.Errorf("%w: tenant is archived (terminal)", ErrTenantInvalid)
	}
	if cur.Status == newStatus {
		// No-op transition. Return the current state.
		return cur, nil
	}

	var q string
	var args []any
	if newStatus == "archived" {
		q = `UPDATE auth.tenants SET status = $2, archived_at = NOW() WHERE id = $1`
		args = []any{pgid(id), newStatus}
	} else {
		q = `UPDATE auth.tenants SET status = $2, archived_at = NULL WHERE id = $1`
		args = []any{pgid(id), newStatus}
	}
	tag, err := s.pool.Exec(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("set tenant status: %w", err)
	}
	if tag.RowsAffected() == 0 {
		return nil, ErrTenantNotFound
	}
	if err := s.WriteAudit(ctx, "tenant.status", actorUserID, actorIP, actorUA, id, id, map[string]any{
		"from": cur.Status,
		"to":   newStatus,
	}); err != nil {
		_ = err
	}
	return s.GetTenant(ctx, id)
}

// pgid is a tiny helper that keeps the call sites readable: we
// only ever pass a single id as the first arg, and we want it to
// be parsed as a UUID by Postgres.
func pgid(id string) any { return id }

// -------------------------------------------------------------------
// input validation
// -------------------------------------------------------------------

// validSlug matches the regex on the slug column:
//
//   ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$
//
// Inlined (not via regexp package) because the pattern is fixed
// and the package would add 50KB of binary.
func validSlug(s string) bool {
	if len(s) < 2 || len(s) > 64 {
		return false
	}
	if !isAlnumOrDash(s[0]) || s[0] == '-' {
		return false
	}
	if !isAlnumOrDash(s[len(s)-1]) || s[len(s)-1] == '-' {
		return false
	}
	for i := 1; i < len(s)-1; i++ {
		if !isAlnumOrDash(s[i]) {
			return false
		}
	}
	return true
}

func isAlnumOrDash(c byte) bool {
	return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
}

// looksLikeEmail is intentionally permissive: we just enforce
// the local-part, the '@', and a non-empty domain with at least
// one dot that isn't at the edge. Bounces are caught by the
// actual mail server, not by the admin UI.
func looksLikeEmail(s string) bool {
	s = strings.TrimSpace(s)
	if s == "" || len(s) > 254 {
		return false
	}
	at := strings.IndexByte(s, '@')
	if at < 1 || at == len(s)-1 {
		return false
	}
	domain := s[at+1:]
	if len(domain) < 3 {
		return false
	}
	if domain[0] == '.' || domain[len(domain)-1] == '.' {
		return false
	}
	return strings.Contains(domain, ".")
}
