package webhooks

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"errors"
	"strings"
	"time"
)

// ErrEndpointNotFound is returned when no row matches the lookup id.
var ErrEndpointNotFound = errors.New("webhooks: endpoint not found")

// EndpointRow is the typed shape of webhook_endpoints.
type EndpointRow struct {
	ID               string
	Name             string
	HandlerType      string
	Enabled          bool
	HandlerConfigJSON string
	CreatedAt        int64
	LastCalledAt     *int64
}

// SecretsAccessor is the narrow interface webhooks needs from
// internal/secrets — abstracted so the package doesn't import secrets
// directly (test ergonomics).
type SecretsAccessor interface {
	Get(ctx context.Context, key string) ([]byte, error)
	Set(ctx context.Context, key string, value []byte) error
	Delete(ctx context.Context, key string) error
}

// Registry wraps the webhook_endpoints table plus the secrets-store side
// where the actual tokens live.
type Registry struct {
	db      *sql.DB
	secrets SecretsAccessor
}

// NewRegistry constructs a registry.
func NewRegistry(db *sql.DB, secrets SecretsAccessor) *Registry {
	return &Registry{db: db, secrets: secrets}
}

// CreateRequest is the operator-facing creation shape.
type CreateRequest struct {
	ID                string         `json:"id"`
	Name              string         `json:"name"`
	HandlerType       string         `json:"handler_type"`
	HandlerConfig     map[string]any `json:"handler_config,omitempty"`
}

// CreateResponse is returned exactly once and carries the plaintext token.
// Operators must save it; the token is hashed/secret-stored after this point.
type CreateResponse struct {
	ID    string `json:"id"`
	Token string `json:"token"`
}

// Create inserts a new endpoint, generates a 32-byte token, stores it in
// secrets under `webhook:<id>:token`, and returns the token exactly once.
func (r *Registry) Create(ctx context.Context, req CreateRequest) (CreateResponse, error) {
	if req.ID == "" || req.Name == "" || req.HandlerType == "" {
		return CreateResponse{}, errors.New("id, name, handler_type required")
	}
	switch req.HandlerType {
	case "autobrr", "qbit", "crossseed", "generic":
	default:
		return CreateResponse{}, errors.New("handler_type must be autobrr|qbit|crossseed|generic")
	}

	tok := make([]byte, 32)
	if _, err := rand.Read(tok); err != nil {
		return CreateResponse{}, err
	}
	tokenStr := base64.RawURLEncoding.EncodeToString(tok)
	secretKey := SecretKey(req.ID)

	cfgJSON := ""
	if len(req.HandlerConfig) > 0 {
		b, _ := json.Marshal(req.HandlerConfig)
		cfgJSON = string(b)
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return CreateResponse{}, err
	}
	defer func() { _ = tx.Rollback() }()

	_, err = tx.ExecContext(ctx, `
		INSERT INTO webhook_endpoints(id, name, secret_token, enabled, handler_type, handler_config_json, created_at)
		VALUES (?, ?, ?, 1, ?, ?, ?)
	`, req.ID, req.Name, secretKey, req.HandlerType, nullableText(cfgJSON), time.Now().Unix())
	if err != nil {
		return CreateResponse{}, err
	}

	if err := r.secrets.Set(ctx, secretKey, []byte(tokenStr)); err != nil {
		return CreateResponse{}, err
	}
	if err := tx.Commit(); err != nil {
		return CreateResponse{}, err
	}
	return CreateResponse{ID: req.ID, Token: tokenStr}, nil
}

// Get returns the endpoint row.
func (r *Registry) Get(ctx context.Context, id string) (EndpointRow, error) {
	row := r.db.QueryRowContext(ctx, `
		SELECT id, name, secret_token, enabled, handler_type, handler_config_json,
		       created_at, last_called_at
		FROM webhook_endpoints WHERE id = ?
	`, id)
	var (
		out                 EndpointRow
		enabled             int
		cfgJSON, secretKey  sql.NullString
		last                sql.NullInt64
	)
	if err := row.Scan(&out.ID, &out.Name, &secretKey, &enabled, &out.HandlerType, &cfgJSON, &out.CreatedAt, &last); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return EndpointRow{}, ErrEndpointNotFound
		}
		return EndpointRow{}, err
	}
	out.Enabled = enabled != 0
	if cfgJSON.Valid {
		out.HandlerConfigJSON = cfgJSON.String
	}
	if last.Valid {
		v := last.Int64
		out.LastCalledAt = &v
	}
	return out, nil
}

// LookupToken fetches the plaintext token for HMAC validation. Returns an
// empty byte slice if the endpoint is disabled or missing.
func (r *Registry) LookupToken(ctx context.Context, id string) ([]byte, error) {
	ep, err := r.Get(ctx, id)
	if err != nil {
		return nil, err
	}
	if !ep.Enabled {
		return nil, errors.New("webhooks: endpoint disabled")
	}
	return r.secrets.Get(ctx, SecretKey(id))
}

// List returns all endpoints. Used by the settings UI.
func (r *Registry) List(ctx context.Context) ([]EndpointRow, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT id, name, secret_token, enabled, handler_type, handler_config_json,
		       created_at, last_called_at
		FROM webhook_endpoints ORDER BY created_at DESC
	`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []EndpointRow
	for rows.Next() {
		var (
			ep                 EndpointRow
			enabled            int
			cfgJSON, secretKey sql.NullString
			last               sql.NullInt64
		)
		if err := rows.Scan(&ep.ID, &ep.Name, &secretKey, &enabled, &ep.HandlerType, &cfgJSON, &ep.CreatedAt, &last); err != nil {
			return nil, err
		}
		ep.Enabled = enabled != 0
		if cfgJSON.Valid {
			ep.HandlerConfigJSON = cfgJSON.String
		}
		if last.Valid {
			v := last.Int64
			ep.LastCalledAt = &v
		}
		out = append(out, ep)
	}
	return out, rows.Err()
}

// Patch updates enabled / handler_config_json. handler_type is intentionally
// immutable post-create — changing it would invalidate every webhook the
// operator already wired into their tools.
type PatchRequest struct {
	Enabled       *bool          `json:"enabled,omitempty"`
	HandlerConfig map[string]any `json:"handler_config,omitempty"`
	Name          string         `json:"name,omitempty"`
}

func (r *Registry) Patch(ctx context.Context, id string, req PatchRequest) error {
	ep, err := r.Get(ctx, id)
	if err != nil {
		return err
	}
	if req.Name != "" {
		ep.Name = req.Name
	}
	if req.Enabled != nil {
		ep.Enabled = *req.Enabled
	}
	var cfgJSON sql.NullString
	if req.HandlerConfig != nil {
		b, _ := json.Marshal(req.HandlerConfig)
		cfgJSON = sql.NullString{String: string(b), Valid: true}
	} else if ep.HandlerConfigJSON != "" {
		cfgJSON = sql.NullString{String: ep.HandlerConfigJSON, Valid: true}
	}
	enabledInt := 0
	if ep.Enabled {
		enabledInt = 1
	}
	_, err = r.db.ExecContext(ctx, `
		UPDATE webhook_endpoints SET name = ?, enabled = ?, handler_config_json = ?
		WHERE id = ?
	`, ep.Name, enabledInt, cfgJSON, id)
	return err
}

// RotateToken generates a fresh token and overwrites the secrets entry.
// Returns the new plaintext exactly once.
func (r *Registry) RotateToken(ctx context.Context, id string) (string, error) {
	if _, err := r.Get(ctx, id); err != nil {
		return "", err
	}
	tok := make([]byte, 32)
	if _, err := rand.Read(tok); err != nil {
		return "", err
	}
	tokenStr := base64.RawURLEncoding.EncodeToString(tok)
	if err := r.secrets.Set(ctx, SecretKey(id), []byte(tokenStr)); err != nil {
		return "", err
	}
	return tokenStr, nil
}

// Delete removes the endpoint row and its secret.
func (r *Registry) Delete(ctx context.Context, id string) error {
	res, err := r.db.ExecContext(ctx, `DELETE FROM webhook_endpoints WHERE id = ?`, id)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return ErrEndpointNotFound
	}
	_ = r.secrets.Delete(ctx, SecretKey(id))
	return nil
}

// MarkCalled bumps last_called_at to now. Best-effort; errors are non-fatal.
func (r *Registry) MarkCalled(ctx context.Context, id string) {
	_, _ = r.db.ExecContext(ctx,
		`UPDATE webhook_endpoints SET last_called_at = ? WHERE id = ?`,
		time.Now().Unix(), id)
}

// SecretKey returns the canonical secrets-store key for a webhook endpoint
// token. Public so handler code and the operator-side helper agree.
func SecretKey(id string) string { return "webhook:" + id + ":token" }

func nullableText(s string) any {
	if s == "" {
		return nil
	}
	return s
}

// FormatToken yields the operator-facing token string. Currently a thin
// alias; reserved so future versions can prefix/canonicalize without
// breaking the operator's stored copy.
func FormatToken(t string) string { return strings.TrimSpace(t) }
