package repository

import (
	"context"
	"errors"
	"time"

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

	"github.com/warmbly/warmbly/internal/models"
)

// OAuthRepository owns persistence for the OAuth 2.1 authorization server:
// registered apps (clients), single-use authorization codes, and issued
// access+refresh grants. Tokens/secrets are stored hashed; lookups are by hash.
type OAuthRepository interface {
	// Applications
	CreateApplication(ctx context.Context, a *models.OAuthApplication) error
	ListApplications(ctx context.Context, orgID uuid.UUID) ([]models.OAuthApplication, error)
	GetApplication(ctx context.Context, orgID, id uuid.UUID) (*models.OAuthApplication, error)
	GetApplicationByClientID(ctx context.Context, clientID string) (*models.OAuthApplication, error)
	UpdateApplication(ctx context.Context, a *models.OAuthApplication) error
	UpdateApplicationSecret(ctx context.Context, orgID, id uuid.UUID, secretHash string) error
	DeleteApplication(ctx context.Context, orgID, id uuid.UUID) error
	// GetAllowedWebhookDomains fetches an app's webhook-domain allowlist by id
	// alone (no org), for delivery-time enforcement on app-scoped endpoints.
	GetAllowedWebhookDomains(ctx context.Context, id uuid.UUID) ([]string, error)
	// GetApplicationByID fetches an app by id alone (no org), for app-webhook
	// reconciliation which spans every org that authorized the app.
	GetApplicationByID(ctx context.Context, id uuid.UUID) (*models.OAuthApplication, error)
	// ListActiveGrantOrgs returns every org with an active (non-revoked) grant for
	// the app, with the union of scopes that org granted. Used to materialize the
	// app's per-org webhook endpoints.
	ListActiveGrantOrgs(ctx context.Context, appID uuid.UUID) ([]models.OAuthGrantOrg, error)

	// Authorization codes
	CreateAuthorizationCode(ctx context.Context, c *models.OAuthAuthorizationCode) error
	// TakeAuthorizationCode atomically consumes a valid, unexpired, unused code.
	TakeAuthorizationCode(ctx context.Context, codeHash string) (*models.OAuthAuthorizationCode, error)

	// Access grants
	CreateAccessGrant(ctx context.Context, g *models.OAuthAccessGrant) error
	GetGrantByAccessTokenHash(ctx context.Context, hash string) (*models.OAuthAccessGrant, error)
	GetGrantByRefreshTokenHash(ctx context.Context, hash string) (*models.OAuthAccessGrant, error)
	RotateGrantTokens(ctx context.Context, id uuid.UUID, accessHash, refreshHash string, accessExp time.Time, refreshExp *time.Time) error
	TouchGrantLastUsed(ctx context.Context, id uuid.UUID) error
	RevokeGrant(ctx context.Context, id uuid.UUID) error
	RevokeGrantByTokenHash(ctx context.Context, appID uuid.UUID, hash string) error
	ListAuthorizedApps(ctx context.Context, orgID, userID uuid.UUID) ([]models.OAuthAuthorizedApp, error)
	RevokeAuthorization(ctx context.Context, orgID, userID, appID uuid.UUID) error
}

type oauthRepository struct {
	db *pgxpool.Pool
}

func NewOAuthRepository(db *pgxpool.Pool) OAuthRepository {
	return &oauthRepository{db: db}
}

const oauthAppCols = `id, organization_id, created_by, name, description, logo_url, website_url,
	client_id, client_secret_hash, redirect_uris, allowed_webhook_domains,
	webhook_url, webhook_events, webhook_secret, scopes, status, is_public, dynamically_registered, created_at, updated_at`

// nullableUUID renders uuid.Nil as SQL NULL, so a dynamically-registered client
// (which has no owning org/user) writes NULL into the nullable FK columns rather
// than the all-zero uuid, which would violate the foreign key.
func nullableUUID(id uuid.UUID) any {
	if id == uuid.Nil {
		return nil
	}
	return id
}

func scanOAuthApp(row pgx.Row, a *models.OAuthApplication) error {
	var scopes int64
	var status string
	// organization_id/created_by are nullable for dynamically-registered clients.
	var orgID, createdBy *uuid.UUID
	if err := row.Scan(&a.ID, &orgID, &createdBy, &a.Name, &a.Description, &a.LogoURL, &a.WebsiteURL,
		&a.ClientID, &a.ClientSecretHash, &a.RedirectURIs, &a.AllowedWebhookDomains,
		&a.WebhookURL, &a.WebhookEvents, &a.WebhookSecret, &scopes, &status, &a.IsPublic, &a.DynamicallyRegistered, &a.CreatedAt, &a.UpdatedAt); err != nil {
		return err
	}
	if orgID != nil {
		a.OrganizationID = *orgID
	}
	if createdBy != nil {
		a.CreatedBy = *createdBy
	}
	a.Scopes = uint64(scopes)
	a.Status = models.OAuthAppStatus(status)
	if a.WebhookEvents == nil {
		a.WebhookEvents = []string{}
	}
	if a.RedirectURIs == nil {
		a.RedirectURIs = []string{}
	}
	if a.AllowedWebhookDomains == nil {
		a.AllowedWebhookDomains = []string{}
	}
	return nil
}

func (r *oauthRepository) CreateApplication(ctx context.Context, a *models.OAuthApplication) error {
	if a.ID == uuid.Nil {
		a.ID = uuid.New()
	}
	now := time.Now().UTC()
	a.CreatedAt = now
	a.UpdatedAt = now
	if a.Status == "" {
		a.Status = models.OAuthAppActive
	}
	_, err := r.db.Exec(ctx, `
		INSERT INTO oauth_applications (id, organization_id, created_by, name, description, logo_url, website_url,
			client_id, client_secret_hash, redirect_uris, allowed_webhook_domains,
			webhook_url, webhook_events, webhook_secret, scopes, status, is_public, dynamically_registered, created_at, updated_at)
		VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19)`,
		a.ID, nullableUUID(a.OrganizationID), nullableUUID(a.CreatedBy), a.Name, a.Description, a.LogoURL, a.WebsiteURL,
		a.ClientID, a.ClientSecretHash, a.RedirectURIs, a.AllowedWebhookDomains,
		a.WebhookURL, a.WebhookEvents, a.WebhookSecret, int64(a.Scopes), string(a.Status), a.IsPublic, a.DynamicallyRegistered, now)
	return err
}

func (r *oauthRepository) ListApplications(ctx context.Context, orgID uuid.UUID) ([]models.OAuthApplication, error) {
	rows, err := r.db.Query(ctx, `SELECT `+oauthAppCols+` FROM oauth_applications WHERE organization_id = $1 ORDER BY created_at DESC`, orgID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.OAuthApplication{}
	for rows.Next() {
		var a models.OAuthApplication
		if err := scanOAuthApp(rows, &a); err != nil {
			return nil, err
		}
		out = append(out, a)
	}
	return out, rows.Err()
}

func (r *oauthRepository) GetApplication(ctx context.Context, orgID, id uuid.UUID) (*models.OAuthApplication, error) {
	var a models.OAuthApplication
	row := r.db.QueryRow(ctx, `SELECT `+oauthAppCols+` FROM oauth_applications WHERE id = $1 AND organization_id = $2`, id, orgID)
	if err := scanOAuthApp(row, &a); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &a, nil
}

func (r *oauthRepository) GetApplicationByID(ctx context.Context, id uuid.UUID) (*models.OAuthApplication, error) {
	var a models.OAuthApplication
	row := r.db.QueryRow(ctx, `SELECT `+oauthAppCols+` FROM oauth_applications WHERE id = $1`, id)
	if err := scanOAuthApp(row, &a); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &a, nil
}

func (r *oauthRepository) ListActiveGrantOrgs(ctx context.Context, appID uuid.UUID) ([]models.OAuthGrantOrg, error) {
	rows, err := r.db.Query(ctx, `
		SELECT organization_id, bit_or(scopes)
		FROM oauth_access_grants
		WHERE application_id = $1 AND revoked_at IS NULL
		GROUP BY organization_id
	`, appID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.OAuthGrantOrg{}
	for rows.Next() {
		var g models.OAuthGrantOrg
		var scopes int64
		if err := rows.Scan(&g.OrgID, &scopes); err != nil {
			return nil, err
		}
		g.Scopes = uint64(scopes)
		out = append(out, g)
	}
	return out, rows.Err()
}

func (r *oauthRepository) GetApplicationByClientID(ctx context.Context, clientID string) (*models.OAuthApplication, error) {
	var a models.OAuthApplication
	row := r.db.QueryRow(ctx, `SELECT `+oauthAppCols+` FROM oauth_applications WHERE client_id = $1`, clientID)
	if err := scanOAuthApp(row, &a); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &a, nil
}

func (r *oauthRepository) UpdateApplication(ctx context.Context, a *models.OAuthApplication) error {
	now := time.Now().UTC()
	a.UpdatedAt = now
	tag, err := r.db.Exec(ctx, `
		UPDATE oauth_applications SET name=$3, description=$4, logo_url=$5, website_url=$6,
			redirect_uris=$7, allowed_webhook_domains=$8, webhook_url=$9, webhook_events=$10,
			webhook_secret=$11, scopes=$12, status=$13, updated_at=$14
		WHERE id=$1 AND organization_id=$2`,
		a.ID, a.OrganizationID, a.Name, a.Description, a.LogoURL, a.WebsiteURL,
		a.RedirectURIs, a.AllowedWebhookDomains, a.WebhookURL, a.WebhookEvents,
		a.WebhookSecret, int64(a.Scopes), string(a.Status), now)
	if err != nil {
		return err
	}
	if tag.RowsAffected() == 0 {
		return errors.New("application not found")
	}
	return nil
}

func (r *oauthRepository) UpdateApplicationSecret(ctx context.Context, orgID, id uuid.UUID, secretHash string) error {
	tag, err := r.db.Exec(ctx, `UPDATE oauth_applications SET client_secret_hash=$3, updated_at=now() WHERE id=$1 AND organization_id=$2`, id, orgID, secretHash)
	if err != nil {
		return err
	}
	if tag.RowsAffected() == 0 {
		return errors.New("application not found")
	}
	return nil
}

func (r *oauthRepository) DeleteApplication(ctx context.Context, orgID, id uuid.UUID) error {
	_, err := r.db.Exec(ctx, `DELETE FROM oauth_applications WHERE id=$1 AND organization_id=$2`, id, orgID)
	return err
}

func (r *oauthRepository) GetAllowedWebhookDomains(ctx context.Context, id uuid.UUID) ([]string, error) {
	var domains []string
	err := r.db.QueryRow(ctx, `SELECT allowed_webhook_domains FROM oauth_applications WHERE id=$1`, id).Scan(&domains)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	if domains == nil {
		domains = []string{}
	}
	return domains, nil
}

func (r *oauthRepository) CreateAuthorizationCode(ctx context.Context, c *models.OAuthAuthorizationCode) error {
	if c.ID == uuid.Nil {
		c.ID = uuid.New()
	}
	c.CreatedAt = time.Now().UTC()
	_, err := r.db.Exec(ctx, `
		INSERT INTO oauth_authorization_codes (id, code_hash, application_id, organization_id, user_id,
			redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, created_at)
		VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
		c.ID, c.CodeHash, c.ApplicationID, c.OrganizationID, c.UserID,
		c.RedirectURI, int64(c.Scopes), c.CodeChallenge, c.CodeChallengeMethod, c.ExpiresAt, c.CreatedAt)
	return err
}

func (r *oauthRepository) TakeAuthorizationCode(ctx context.Context, codeHash string) (*models.OAuthAuthorizationCode, error) {
	var c models.OAuthAuthorizationCode
	var scopes int64
	row := r.db.QueryRow(ctx, `
		UPDATE oauth_authorization_codes SET used_at = now()
		WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
		RETURNING id, code_hash, application_id, organization_id, user_id, redirect_uri, scopes,
			code_challenge, code_challenge_method, used_at, expires_at, created_at`, codeHash)
	if err := row.Scan(&c.ID, &c.CodeHash, &c.ApplicationID, &c.OrganizationID, &c.UserID, &c.RedirectURI, &scopes,
		&c.CodeChallenge, &c.CodeChallengeMethod, &c.UsedAt, &c.ExpiresAt, &c.CreatedAt); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	c.Scopes = uint64(scopes)
	return &c, nil
}

const oauthGrantCols = `id, application_id, organization_id, user_id, scopes, access_token_hash, refresh_token_hash,
	access_expires_at, refresh_expires_at, revoked_at, last_used_at, created_at`

func scanOAuthGrant(row pgx.Row, g *models.OAuthAccessGrant) error {
	var scopes int64
	var refreshHash *string
	if err := row.Scan(&g.ID, &g.ApplicationID, &g.OrganizationID, &g.UserID, &scopes, &g.AccessTokenHash, &refreshHash,
		&g.AccessExpiresAt, &g.RefreshExpiresAt, &g.RevokedAt, &g.LastUsedAt, &g.CreatedAt); err != nil {
		return err
	}
	g.Scopes = uint64(scopes)
	if refreshHash != nil {
		g.RefreshTokenHash = *refreshHash
	}
	return nil
}

func (r *oauthRepository) CreateAccessGrant(ctx context.Context, g *models.OAuthAccessGrant) error {
	if g.ID == uuid.Nil {
		g.ID = uuid.New()
	}
	g.CreatedAt = time.Now().UTC()
	var refreshHash *string
	if g.RefreshTokenHash != "" {
		refreshHash = &g.RefreshTokenHash
	}
	_, err := r.db.Exec(ctx, `
		INSERT INTO oauth_access_grants (id, application_id, organization_id, user_id, scopes,
			access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, created_at)
		VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
		g.ID, g.ApplicationID, g.OrganizationID, g.UserID, int64(g.Scopes),
		g.AccessTokenHash, refreshHash, g.AccessExpiresAt, g.RefreshExpiresAt, g.CreatedAt)
	return err
}

func (r *oauthRepository) GetGrantByAccessTokenHash(ctx context.Context, hash string) (*models.OAuthAccessGrant, error) {
	var g models.OAuthAccessGrant
	row := r.db.QueryRow(ctx, `SELECT `+oauthGrantCols+` FROM oauth_access_grants WHERE access_token_hash = $1`, hash)
	if err := scanOAuthGrant(row, &g); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &g, nil
}

func (r *oauthRepository) GetGrantByRefreshTokenHash(ctx context.Context, hash string) (*models.OAuthAccessGrant, error) {
	var g models.OAuthAccessGrant
	row := r.db.QueryRow(ctx, `SELECT `+oauthGrantCols+` FROM oauth_access_grants WHERE refresh_token_hash = $1`, hash)
	if err := scanOAuthGrant(row, &g); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &g, nil
}

func (r *oauthRepository) RotateGrantTokens(ctx context.Context, id uuid.UUID, accessHash, refreshHash string, accessExp time.Time, refreshExp *time.Time) error {
	var refresh *string
	if refreshHash != "" {
		refresh = &refreshHash
	}
	_, err := r.db.Exec(ctx, `
		UPDATE oauth_access_grants SET access_token_hash=$2, refresh_token_hash=$3, access_expires_at=$4, refresh_expires_at=$5, last_used_at=now()
		WHERE id=$1`, id, accessHash, refresh, accessExp, refreshExp)
	return err
}

func (r *oauthRepository) TouchGrantLastUsed(ctx context.Context, id uuid.UUID) error {
	_, err := r.db.Exec(ctx, `UPDATE oauth_access_grants SET last_used_at=now() WHERE id=$1`, id)
	return err
}

func (r *oauthRepository) RevokeGrant(ctx context.Context, id uuid.UUID) error {
	_, err := r.db.Exec(ctx, `UPDATE oauth_access_grants SET revoked_at=now() WHERE id=$1 AND revoked_at IS NULL`, id)
	return err
}

func (r *oauthRepository) RevokeGrantByTokenHash(ctx context.Context, appID uuid.UUID, hash string) error {
	_, err := r.db.Exec(ctx, `
		UPDATE oauth_access_grants SET revoked_at=now()
		WHERE application_id=$1 AND (access_token_hash=$2 OR refresh_token_hash=$2) AND revoked_at IS NULL`, appID, hash)
	return err
}

func (r *oauthRepository) ListAuthorizedApps(ctx context.Context, orgID, userID uuid.UUID) ([]models.OAuthAuthorizedApp, error) {
	rows, err := r.db.Query(ctx, `
		SELECT a.id, a.name, a.logo_url, a.website_url,
			bit_or(g.scopes)::bigint AS scopes, min(g.created_at) AS authorized_at, max(g.last_used_at) AS last_used_at
		FROM oauth_access_grants g
		JOIN oauth_applications a ON a.id = g.application_id
		WHERE g.organization_id = $1 AND g.user_id = $2 AND g.revoked_at IS NULL
		GROUP BY a.id, a.name, a.logo_url, a.website_url
		ORDER BY authorized_at DESC`, orgID, userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []models.OAuthAuthorizedApp{}
	for rows.Next() {
		var ap models.OAuthAuthorizedApp
		var scopes int64
		if err := rows.Scan(&ap.ApplicationID, &ap.Name, &ap.LogoURL, &ap.WebsiteURL, &scopes, &ap.AuthorizedAt, &ap.LastUsedAt); err != nil {
			return nil, err
		}
		ap.Scopes = uint64(scopes)
		out = append(out, ap)
	}
	return out, rows.Err()
}

func (r *oauthRepository) RevokeAuthorization(ctx context.Context, orgID, userID, appID uuid.UUID) error {
	_, err := r.db.Exec(ctx, `
		UPDATE oauth_access_grants SET revoked_at=now()
		WHERE organization_id=$1 AND user_id=$2 AND application_id=$3 AND revoked_at IS NULL`, orgID, userID, appID)
	return err
}
