package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sync"
	"time"

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

// ErrNotFound menandai short code yang tidak ada di penyimpanan.
var ErrNotFound = errors.New("pemetaan tidak ditemukan")

// ErrCodeExists menandai short code yang sudah dipakai pemetaan lain.
var ErrCodeExists = errors.New("short code sudah dipakai")

// URLRecord adalah pemetaan satu short code ke URL asli beserta
// statistik kliknya — inti domain layanan pemendek URL.
type URLRecord struct {
	Code        string    `json:"code"`
	OriginalURL string    `json:"original_url"`
	Clicks      int64     `json:"clicks"`
	CreatedAt   time.Time `json:"created_at"`
}

// URLRepository adalah kontrak penyimpanan pemetaan URL. Implementasi
// PostgreSQL dipakai di produksi; implementasi memori dipakai untuk
// unit test dan mode demo.
type URLRepository interface {
	// Save menyimpan pemetaan baru; mengembalikan ErrCodeExists bila
	// kode sudah dipakai.
	Save(ctx context.Context, rec URLRecord) error
	// FindByCode mengambil satu pemetaan beserta statistiknya.
	FindByCode(ctx context.Context, code string) (URLRecord, error)
	// AddClick menaikkan klik satu kali dan mengembalikan pemetaan
	// terbaru dalam satu operasi penyimpanan.
	AddClick(ctx context.Context, code string) (URLRecord, error)
}

// PostgresURLRepository menyimpan pemetaan URL di PostgreSQL.
type PostgresURLRepository struct {
	db *sql.DB
}

// NewPostgresURLRepository membangun repository di atas pool *sql.DB.
func NewPostgresURLRepository(db *sql.DB) *PostgresURLRepository {
	return &PostgresURLRepository{db: db}
}

// Save memasukkan pemetaan baru. Pelanggaran primary key
// (unique_violation, kode SQL 23505) diterjemahkan menjadi
// ErrCodeExists sehingga service cukup memeriksa sentinel error.
func (r *PostgresURLRepository) Save(ctx context.Context, rec URLRecord) error {
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO urls (code, original_url, created_at) VALUES ($1, $2, $3)`,
		rec.Code, rec.OriginalURL, rec.CreatedAt)
	if err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
			return fmt.Errorf("kode %s: %w", rec.Code, ErrCodeExists)
		}
		return fmt.Errorf("menyimpan urls: %w", err)
	}
	return nil
}

// FindByCode membaca satu pemetaan; baris kosong menjadi ErrNotFound.
func (r *PostgresURLRepository) FindByCode(ctx context.Context, code string) (URLRecord, error) {
	var rec URLRecord
	err := r.db.QueryRowContext(ctx,
		`SELECT code, original_url, clicks, created_at FROM urls WHERE code = $1`, code,
	).Scan(&rec.Code, &rec.OriginalURL, &rec.Clicks, &rec.CreatedAt)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
		}
		return URLRecord{}, fmt.Errorf("membaca urls: %w", err)
	}
	return rec, nil
}

// AddClick menaikkan klik dan mengembalikan baris terbaru dalam satu
// round trip lewat RETURNING. UPDATE berjalan atomik di database,
// sehingga dua klik bersamaan tidak bisa saling menimpa.
func (r *PostgresURLRepository) AddClick(ctx context.Context, code string) (URLRecord, error) {
	var rec URLRecord
	err := r.db.QueryRowContext(ctx,
		`UPDATE urls SET clicks = clicks + 1 WHERE code = $1
		RETURNING code, original_url, clicks, created_at`, code,
	).Scan(&rec.Code, &rec.OriginalURL, &rec.Clicks, &rec.CreatedAt)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
		}
		return URLRecord{}, fmt.Errorf("menaikkan klik: %w", err)
	}
	return rec, nil
}

// MemoryURLRepository menyimpan pemetaan di map dengan mutex untuk
// unit test dan mode demo. Perilakunya meniru implementasi
// PostgreSQL, termasuk ErrCodeExists dan ErrNotFound.
type MemoryURLRepository struct {
	mu   sync.Mutex
	urls map[string]URLRecord
}

// NewMemoryURLRepository membuat penyimpanan kosong.
func NewMemoryURLRepository() *MemoryURLRepository {
	return &MemoryURLRepository{urls: make(map[string]URLRecord)}
}

func (r *MemoryURLRepository) Save(ctx context.Context, rec URLRecord) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ada := r.urls[rec.Code]; ada {
		return fmt.Errorf("kode %s: %w", rec.Code, ErrCodeExists)
	}
	r.urls[rec.Code] = rec
	return nil
}

func (r *MemoryURLRepository) FindByCode(ctx context.Context, code string) (URLRecord, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	rec, ada := r.urls[code]
	if !ada {
		return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
	}
	return rec, nil
}

// AddClick mengunci map, menaikkan klik, lalu mengembalikan salinan —
// padanan atomik dari UPDATE ... RETURNING versi SQL.
func (r *MemoryURLRepository) AddClick(ctx context.Context, code string) (URLRecord, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	rec, ada := r.urls[code]
	if !ada {
		return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
	}
	rec.Clicks++
	r.urls[code] = rec
	return rec, nil
}
