package mysql

import (
	"context"
	"database/sql"
	"errors"

	mysqlerr "github.com/go-sql-driver/mysql"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	messagedomain "hyapp/services/activity-service/internal/domain/message"
)

// SaveInboxMessage inserts one materialized user inbox row and returns an existing row on producer idempotency hits.
func (r *Repository) SaveInboxMessage(ctx context.Context, message messagedomain.InboxMessage) (bool, messagedomain.InboxMessage, error) {
	if r == nil || r.db == nil {
		return false, messagedomain.InboxMessage{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	app := appcode.FromContext(ctx)
	message.AppCode = app

	_, err := r.db.ExecContext(ctx, `
		INSERT INTO user_inbox_messages (
			app_code, inbox_message_id, user_id, message_type, producer, producer_event_id,
			producer_event_type, aggregate_type, aggregate_id, template_id, template_version,
			title, summary, body, icon_url, image_url, action_type, action_param, priority,
			status, read_at_ms, deleted_at_ms, sent_at_ms, expire_at_ms, metadata_json, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)`,
		app,
		message.MessageID,
		message.UserID,
		message.MessageType,
		message.Producer,
		message.ProducerEventID,
		message.ProducerEventType,
		message.AggregateType,
		message.AggregateID,
		message.TemplateID,
		message.TemplateVersion,
		message.Title,
		message.Summary,
		message.Body,
		message.IconURL,
		message.ImageURL,
		message.ActionType,
		message.ActionParam,
		message.Priority,
		message.Status,
		message.SentAtMS,
		nullInt64(message.ExpireAtMS),
		nullString(message.MetadataJSON),
		message.CreatedAtMS,
		message.UpdatedAtMS,
	)
	if err == nil {
		return true, message, nil
	}
	if !isMySQLDuplicate(err) {
		return false, messagedomain.InboxMessage{}, err
	}

	existing, exists, findErr := r.GetInboxMessageByProducerEvent(ctx, message.UserID, message.Producer, message.ProducerEventID)
	if findErr != nil {
		return false, messagedomain.InboxMessage{}, findErr
	}
	if exists {
		return false, existing, nil
	}

	return false, messagedomain.InboxMessage{}, xerr.New(xerr.Conflict, "inbox message id already exists")
}

// GetInboxMessageByProducerEvent reads the idempotency target for a producer event.
func (r *Repository) GetInboxMessageByProducerEvent(ctx context.Context, userID int64, producer string, producerEventID string) (messagedomain.InboxMessage, bool, error) {
	row := r.db.QueryRowContext(ctx, `
		SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id,
		       producer_event_type, aggregate_type, aggregate_id, template_id, template_version,
		       title, summary, body, icon_url, image_url, action_type, action_param, priority,
		       status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0),
		       COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms
		FROM user_inbox_messages
		WHERE app_code = ? AND user_id = ? AND producer = ? AND producer_event_id = ?`,
		appcode.FromContext(ctx), userID, producer, producerEventID,
	)

	message, err := scanInboxMessage(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return messagedomain.InboxMessage{}, false, nil
		}
		return messagedomain.InboxMessage{}, false, err
	}
	return message, true, nil
}

// GetActiveMessageTemplate resolves a server-owned snapshot template.
func (r *Repository) GetActiveMessageTemplate(ctx context.Context, templateID string, templateVersion string, messageType string) (messagedomain.MessageTemplate, bool, error) {
	if r == nil || r.db == nil {
		return messagedomain.MessageTemplate{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, `
		SELECT app_code, template_id, template_version, message_type, locale, title, summary, body,
		       icon_url, image_url, action_type, action_param, status, created_by, created_at_ms, updated_at_ms
		FROM message_templates
		WHERE app_code = ? AND template_id = ? AND template_version = ? AND message_type = ? AND status = 'active'`,
		appcode.FromContext(ctx), templateID, templateVersion, messageType,
	)

	var template messagedomain.MessageTemplate
	if err := row.Scan(&template.AppCode, &template.TemplateID, &template.TemplateVersion, &template.MessageType, &template.Locale, &template.Title, &template.Summary, &template.Body, &template.IconURL, &template.ImageURL, &template.ActionType, &template.ActionParam, &template.Status, &template.CreatedBy, &template.CreatedAtMS, &template.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return messagedomain.MessageTemplate{}, false, nil
		}
		return messagedomain.MessageTemplate{}, false, err
	}
	return template, true, nil
}

// CountUnreadInboxMessages counts visible, unexpired and undeleted unread messages by section.
func (r *Repository) CountUnreadInboxMessages(ctx context.Context, userID int64, section string, nowMS int64) (int64, error) {
	if r == nil || r.db == nil {
		return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM user_inbox_messages
		WHERE app_code = ? AND user_id = ? AND message_type = ?
		  AND status = 'visible' AND read_at_ms IS NULL AND deleted_at_ms IS NULL
		  AND (expire_at_ms IS NULL OR expire_at_ms > ?)`,
		appcode.FromContext(ctx), userID, section, nowMS,
	)
	var count int64
	if err := row.Scan(&count); err != nil {
		return 0, err
	}
	return count, nil
}

// ListInboxMessages reads one cursor page of visible messages for the current app and user.
func (r *Repository) ListInboxMessages(ctx context.Context, query messagedomain.ListQuery) ([]messagedomain.InboxMessage, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	args := []any{appcode.FromContext(ctx), query.UserID, query.Section, query.NowMS}
	cursorSQL := ""
	if query.CursorSentAtMS > 0 || query.CursorMessageID != "" {
		cursorSQL = " AND (sent_at_ms < ? OR (sent_at_ms = ? AND inbox_message_id < ?))"
		args = append(args, query.CursorSentAtMS, query.CursorSentAtMS, query.CursorMessageID)
	}
	args = append(args, query.Limit)

	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id,
		       producer_event_type, aggregate_type, aggregate_id, template_id, template_version,
		       title, summary, body, icon_url, image_url, action_type, action_param, priority,
		       status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0),
		       COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms
		FROM user_inbox_messages
		WHERE app_code = ? AND user_id = ? AND message_type = ?
		  AND status = 'visible' AND deleted_at_ms IS NULL AND (expire_at_ms IS NULL OR expire_at_ms > ?)`+cursorSQL+`
		ORDER BY sent_at_ms DESC, inbox_message_id DESC
		LIMIT ?`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	messages := make([]messagedomain.InboxMessage, 0, query.Limit)
	for rows.Next() {
		message, scanErr := scanInboxMessage(rows)
		if scanErr != nil {
			return nil, scanErr
		}
		messages = append(messages, message)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return messages, nil
}

// MarkInboxMessageRead preserves the first read timestamp and treats repeated reads as success.
func (r *Repository) MarkInboxMessageRead(ctx context.Context, userID int64, messageID string, nowMS int64) (messagedomain.InboxMessage, error) {
	if r == nil || r.db == nil {
		return messagedomain.InboxMessage{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	message, exists, err := r.getVisibleInboxMessage(ctx, userID, messageID, nowMS)
	if err != nil {
		return messagedomain.InboxMessage{}, err
	}
	if !exists {
		return messagedomain.InboxMessage{}, xerr.New(xerr.MessageNotFound, "message not found")
	}
	if message.ReadAtMS == 0 {
		_, err = r.db.ExecContext(ctx, `
			UPDATE user_inbox_messages
			SET read_at_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND user_id = ? AND inbox_message_id = ? AND read_at_ms IS NULL`,
			nowMS, nowMS, appcode.FromContext(ctx), userID, messageID,
		)
		if err != nil {
			return messagedomain.InboxMessage{}, err
		}
		message, _, err = r.getVisibleInboxMessage(ctx, userID, messageID, nowMS)
		if err != nil {
			return messagedomain.InboxMessage{}, err
		}
	}
	return message, nil
}

// MarkInboxSectionRead marks all current visible unread messages in a section.
func (r *Repository) MarkInboxSectionRead(ctx context.Context, userID int64, section string, nowMS int64) (int64, error) {
	if r == nil || r.db == nil {
		return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	result, err := r.db.ExecContext(ctx, `
		UPDATE user_inbox_messages
		SET read_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND message_type = ?
		  AND status = 'visible' AND read_at_ms IS NULL AND deleted_at_ms IS NULL
		  AND (expire_at_ms IS NULL OR expire_at_ms > ?)`,
		nowMS, nowMS, appcode.FromContext(ctx), userID, section, nowMS,
	)
	if err != nil {
		return 0, err
	}
	count, err := result.RowsAffected()
	if err != nil {
		return 0, err
	}
	return count, nil
}

// DeleteInboxMessage hides one visible message for the current user without deleting audit facts.
func (r *Repository) DeleteInboxMessage(ctx context.Context, userID int64, messageID string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if _, exists, err := r.getVisibleInboxMessage(ctx, userID, messageID, nowMS); err != nil {
		return err
	} else if !exists {
		return xerr.New(xerr.MessageNotFound, "message not found")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE user_inbox_messages
		SET deleted_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND inbox_message_id = ? AND deleted_at_ms IS NULL`,
		nowMS, nowMS, appcode.FromContext(ctx), userID, messageID,
	)
	return err
}

// SaveFanoutJob records an async fanout command; worker execution is intentionally outside HTTP request path.
func (r *Repository) SaveFanoutJob(ctx context.Context, job messagedomain.FanoutJob) (bool, messagedomain.FanoutJob, error) {
	if r == nil || r.db == nil {
		return false, messagedomain.FanoutJob{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	app := appcode.FromContext(ctx)
	job.AppCode = app
	_, err := r.db.ExecContext(ctx, `
		INSERT INTO message_fanout_jobs (
			app_code, job_id, command_id, message_type, target_scope, target_filter_json, template_snapshot_json,
			status, attempt_count, cursor_user_id, total_count, success_count, failure_count, batch_size, next_run_at_ms,
			locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		app,
		job.JobID,
		job.CommandID,
		job.MessageType,
		job.TargetScope,
		job.TargetFilterJSON,
		job.TemplateSnapshotJSON,
		job.Status,
		job.AttemptCount,
		job.CursorUserID,
		job.TotalCount,
		job.SuccessCount,
		job.FailureCount,
		job.BatchSize,
		job.NextRunAtMS,
		job.LockedBy,
		job.LockedUntilMS,
		job.ErrorMessage,
		job.CreatedBy,
		job.CreatedAtMS,
		job.UpdatedAtMS,
	)
	if err == nil {
		return true, job, nil
	}
	if !isMySQLDuplicate(err) {
		return false, messagedomain.FanoutJob{}, err
	}
	existing, exists, findErr := r.GetFanoutJobByCommand(ctx, job.CommandID)
	if findErr != nil {
		return false, messagedomain.FanoutJob{}, findErr
	}
	if exists {
		return false, existing, nil
	}
	return false, messagedomain.FanoutJob{}, xerr.New(xerr.Conflict, "fanout job id already exists")
}

// GetFanoutJobByCommand reads the idempotency target for admin command retries.
func (r *Repository) GetFanoutJobByCommand(ctx context.Context, commandID string) (messagedomain.FanoutJob, bool, error) {
	row := r.db.QueryRowContext(ctx, `
		SELECT app_code, job_id, command_id, message_type, target_scope, CAST(target_filter_json AS CHAR),
		       CAST(template_snapshot_json AS CHAR), status, attempt_count, cursor_user_id, total_count, success_count, failure_count,
		       batch_size, next_run_at_ms, locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms
		FROM message_fanout_jobs
		WHERE app_code = ? AND command_id = ?`,
		appcode.FromContext(ctx), commandID,
	)
	var job messagedomain.FanoutJob
	if err := scanFanoutJob(row, &job); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return messagedomain.FanoutJob{}, false, nil
		}
		return messagedomain.FanoutJob{}, false, err
	}
	return job, true, nil
}

// ClaimFanoutJob commits ownership before processing so another worker can take over after lock expiry.
func (r *Repository) ClaimFanoutJob(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, maxRetry int) (messagedomain.FanoutJob, bool, error) {
	if r == nil || r.db == nil {
		return messagedomain.FanoutJob{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return messagedomain.FanoutJob{}, false, err
	}
	defer tx.Rollback()

	row := tx.QueryRowContext(ctx, `
		SELECT app_code, job_id, command_id, message_type, target_scope, CAST(target_filter_json AS CHAR),
		       CAST(template_snapshot_json AS CHAR), status, attempt_count, cursor_user_id, total_count, success_count, failure_count,
		       batch_size, next_run_at_ms, locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms
		FROM message_fanout_jobs
		WHERE app_code = ?
		  AND next_run_at_ms <= ? AND attempt_count < ?
		  AND (status IN (?, ?) OR (status = ? AND locked_until_ms < ?))
		ORDER BY next_run_at_ms ASC, updated_at_ms ASC, job_id ASC
		LIMIT 1
		FOR UPDATE SKIP LOCKED`,
		appcode.FromContext(ctx),
		nowMS,
		maxRetry,
		messagedomain.FanoutStatusPending,
		messagedomain.FanoutStatusRetrying,
		messagedomain.FanoutStatusRunning,
		nowMS,
	)
	var job messagedomain.FanoutJob
	if err := scanFanoutJob(row, &job); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			if commitErr := tx.Commit(); commitErr != nil {
				return messagedomain.FanoutJob{}, false, commitErr
			}
			return messagedomain.FanoutJob{}, false, nil
		}
		return messagedomain.FanoutJob{}, false, err
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE message_fanout_jobs
		SET status = ?, locked_by = ?, locked_until_ms = ?, attempt_count = attempt_count + 1, updated_at_ms = ?
		WHERE app_code = ? AND job_id = ?`,
		messagedomain.FanoutStatusRunning, workerID, lockUntilMS, nowMS, job.AppCode, job.JobID,
	)
	if err != nil {
		return messagedomain.FanoutJob{}, false, err
	}
	if err := tx.Commit(); err != nil {
		return messagedomain.FanoutJob{}, false, err
	}
	job.Status = messagedomain.FanoutStatusRunning
	job.LockedBy = workerID
	job.LockedUntilMS = lockUntilMS
	job.AttemptCount++
	job.UpdatedAtMS = nowMS
	return job, true, nil
}

// FinishFanoutJobBatch commits one processed cursor page and clears the durable worker lock.
func (r *Repository) FinishFanoutJobBatch(ctx context.Context, jobID string, workerID string, cursorUserID int64, totalDelta int64, successDelta int64, failureDelta int64, done bool, nowMS int64, errorMessage string) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	status := messagedomain.FanoutStatusPending
	if done {
		status = messagedomain.FanoutStatusComplete
	}
	result, err := r.db.ExecContext(ctx, `
		UPDATE message_fanout_jobs
		SET status = ?, cursor_user_id = ?, total_count = total_count + ?, success_count = success_count + ?,
		    failure_count = failure_count + ?, next_run_at_ms = ?, locked_by = '', locked_until_ms = 0,
		    error_message = ?, updated_at_ms = ?
		WHERE app_code = ? AND job_id = ? AND status = ? AND locked_by = ?`,
		status,
		cursorUserID,
		totalDelta,
		successDelta,
		failureDelta,
		nowMS,
		truncateFanoutError(errorMessage),
		nowMS,
		appcode.FromContext(ctx),
		jobID,
		messagedomain.FanoutStatusRunning,
		workerID,
	)
	if err != nil {
		return err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected == 0 {
		return xerr.New(xerr.Conflict, "fanout job lock is lost")
	}
	return nil
}

// FailFanoutJob releases a failed claim; exhausted jobs become failed instead of retrying forever.
func (r *Repository) FailFanoutJob(ctx context.Context, jobID string, workerID string, nowMS int64, nextRunAtMS int64, maxRetry int, errorMessage string) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	result, err := r.db.ExecContext(ctx, `
		UPDATE message_fanout_jobs
		SET status = CASE WHEN attempt_count >= ? THEN ? ELSE ? END,
		    next_run_at_ms = CASE WHEN attempt_count >= ? THEN 0 ELSE ? END,
		    locked_by = '', locked_until_ms = 0, error_message = ?, updated_at_ms = ?
		WHERE app_code = ? AND job_id = ? AND status = ? AND locked_by = ?`,
		maxRetry,
		messagedomain.FanoutStatusFailed,
		messagedomain.FanoutStatusRetrying,
		maxRetry,
		nextRunAtMS,
		truncateFanoutError(errorMessage),
		nowMS,
		appcode.FromContext(ctx),
		jobID,
		messagedomain.FanoutStatusRunning,
		workerID,
	)
	if err != nil {
		return err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected == 0 {
		return xerr.New(xerr.Conflict, "fanout job lock is lost")
	}
	return nil
}

func (r *Repository) getVisibleInboxMessage(ctx context.Context, userID int64, messageID string, nowMS int64) (messagedomain.InboxMessage, bool, error) {
	row := r.db.QueryRowContext(ctx, `
		SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id,
		       producer_event_type, aggregate_type, aggregate_id, template_id, template_version,
		       title, summary, body, icon_url, image_url, action_type, action_param, priority,
		       status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0),
		       COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms
		FROM user_inbox_messages
		WHERE app_code = ? AND user_id = ? AND inbox_message_id = ?
		  AND status = 'visible' AND deleted_at_ms IS NULL AND (expire_at_ms IS NULL OR expire_at_ms > ?)`,
		appcode.FromContext(ctx), userID, messageID, nowMS,
	)
	message, err := scanInboxMessage(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return messagedomain.InboxMessage{}, false, nil
		}
		return messagedomain.InboxMessage{}, false, err
	}
	return message, true, nil
}

type inboxScanner interface {
	Scan(dest ...any) error
}

func scanFanoutJob(scanner inboxScanner, job *messagedomain.FanoutJob) error {
	return scanner.Scan(
		&job.AppCode,
		&job.JobID,
		&job.CommandID,
		&job.MessageType,
		&job.TargetScope,
		&job.TargetFilterJSON,
		&job.TemplateSnapshotJSON,
		&job.Status,
		&job.AttemptCount,
		&job.CursorUserID,
		&job.TotalCount,
		&job.SuccessCount,
		&job.FailureCount,
		&job.BatchSize,
		&job.NextRunAtMS,
		&job.LockedBy,
		&job.LockedUntilMS,
		&job.ErrorMessage,
		&job.CreatedBy,
		&job.CreatedAtMS,
		&job.UpdatedAtMS,
	)
}

func scanInboxMessage(scanner inboxScanner) (messagedomain.InboxMessage, error) {
	var message messagedomain.InboxMessage
	err := scanner.Scan(
		&message.AppCode,
		&message.MessageID,
		&message.UserID,
		&message.MessageType,
		&message.Producer,
		&message.ProducerEventID,
		&message.ProducerEventType,
		&message.AggregateType,
		&message.AggregateID,
		&message.TemplateID,
		&message.TemplateVersion,
		&message.Title,
		&message.Summary,
		&message.Body,
		&message.IconURL,
		&message.ImageURL,
		&message.ActionType,
		&message.ActionParam,
		&message.Priority,
		&message.Status,
		&message.ReadAtMS,
		&message.DeletedAtMS,
		&message.SentAtMS,
		&message.ExpireAtMS,
		&message.MetadataJSON,
		&message.CreatedAtMS,
		&message.UpdatedAtMS,
	)
	return message, err
}

func nullInt64(value int64) sql.Null[int64] {
	return sql.Null[int64]{V: value, Valid: value > 0}
}

func nullString(value string) sql.Null[string] {
	return sql.Null[string]{V: value, Valid: value != ""}
}

func truncateFanoutError(message string) string {
	if len(message) <= 512 {
		return message
	}
	return message[:512]
}

func isMySQLDuplicate(err error) bool {
	var mysqlErr *mysqlerr.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}
