package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"net/http"
	"strings"
	"time"
)

// maxBodyBytes membatasi ukuran body JSON permintaan.
const maxBodyBytes = 1 << 20 // 1 MiB

// Kode galat yang stabil untuk konsumen API.
const (
	codeNotFound      = "NOT_FOUND"
	codeValidasi      = "VALIDATION_ERROR"
	codeJSONInvalid   = "INVALID_JSON"
	codeMediaType     = "UNSUPPORTED_MEDIA_TYPE"
	codeBodyKebesaran = "REQUEST_TOO_LARGE"
	codeInternal      = "INTERNAL_ERROR"
)

// errorEnvelope adalah bentuk body galat yang konsisten di seluruh
// endpoint: {"error":{"code":"...","message":"..."}}.
type errorEnvelope struct {
	Error errorBody `json:"error"`
}

type errorBody struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

// galatBody adalah galat transport HTTP yang sudah membawa status dan
// kode galatnya sendiri, misalnya body JSON yang rusak.
type galatBody struct {
	status  int
	code    string
	message string
}

func (e *galatBody) Error() string { return e.message }

// writeJSON menuliskan v sebagai respons JSON dengan status code yang
// diberikan; Content-Type diatur sebelum WriteHeader.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// writeError menerjemahkan error domain menjadi respons JSON
// konsisten: ValidationError menjadi 400 dengan peta fields,
// ErrNotFound menjadi 404, galat lain menjadi 500 tanpa detail.
func writeError(w http.ResponseWriter, err error) {
	var ve ValidationError
	if errors.As(err, &ve) {
		writeJSON(w, http.StatusBadRequest, errorEnvelope{Error: errorBody{
			Code:    codeValidasi,
			Message: "masukan tidak lolos validasi",
			Fields:  map[string]string{ve.Field: ve.Message},
		}})
		return
	}
	status, code, message := terjemahkanGalat(err)
	writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}})
}

// terjemahkanGalat memetakan error domain ke status HTTP dan kode
// galat yang aman untuk klien.
func terjemahkanGalat(err error) (status int, code, message string) {
	if errors.Is(err, ErrNotFound) {
		return http.StatusNotFound, codeNotFound, err.Error()
	}
	return http.StatusInternalServerError, codeInternal, "galat internal server"
}

// tulisGalatBody menuliskan galat transport (galatBody) atau galat
// domain sebagai respons JSON.
func tulisGalatBody(w http.ResponseWriter, err error) {
	var gb *galatBody
	if errors.As(err, &gb) {
		writeJSON(w, gb.status, errorEnvelope{Error: errorBody{
			Code:    gb.code,
			Message: gb.message,
		}})
		return
	}
	writeError(w, err)
}

// ShortenHandler menerjemahkan permintaan HTTP menjadi pemanggilan
// ShortenService, lalu hasilnya kembali menjadi respons.
type ShortenHandler struct {
	svc     *ShortenService
	baseURL string
}

// NewRouter membangun ServeMux dengan seluruh pattern layanan.
// Pattern "GET /stats/{code}" lebih spesifik daripada "GET /{code}"
// sehingga ServeMux memilihnya untuk path dua segmen; "POST /shorten"
// tidak bertabrakan dengan "GET /{code}" karena method-nya berbeda.
func NewRouter(svc *ShortenService, baseURL string) http.Handler {
	h := &ShortenHandler{svc: svc, baseURL: strings.TrimSuffix(baseURL, "/")}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", h.Healthz)
	mux.HandleFunc("POST /shorten", h.Shorten)
	mux.HandleFunc("GET /{code}", h.Redirect)
	mux.HandleFunc("GET /stats/{code}", h.Stats)
	return mux
}

// Healthz melaporkan proses hidup dan siap menerima permintaan.
func (h *ShortenHandler) Healthz(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// shortenRequest adalah body permintaan POST /shorten.
type shortenRequest struct {
	URL string `json:"url"`
}

// shortenResponse adalah body respons POST /shorten.
type shortenResponse struct {
	Code     string `json:"code"`
	URL      string `json:"url"`
	ShortURL string `json:"short_url"`
}

// Shorten menangani POST /shorten: validasi URL, buat kode, lalu balas
// 201 Created dengan Location menuju short URL.
func (h *ShortenHandler) Shorten(w http.ResponseWriter, r *http.Request) {
	var req shortenRequest
	if err := bacaJSON(w, r, &req); err != nil {
		tulisGalatBody(w, err)
		return
	}
	rec, err := h.svc.Shorten(r.Context(), req.URL)
	if err != nil {
		writeError(w, err)
		return
	}
	shortURL := fmt.Sprintf("%s/%s", h.baseURL, rec.Code)
	w.Header().Set("Location", shortURL)
	writeJSON(w, http.StatusCreated, shortenResponse{
		Code:     rec.Code,
		URL:      rec.OriginalURL,
		ShortURL: shortURL,
	})
}

// Redirect menangani GET /{code}: klik dihitung, lalu klien dialihkan
// ke URL asli dengan 302 Found dan header Location.
func (h *ShortenHandler) Redirect(w http.ResponseWriter, r *http.Request) {
	asli, err := h.svc.Redirect(r.Context(), r.PathValue("code"))
	if err != nil {
		writeError(w, err)
		return
	}
	http.Redirect(w, r, asli, http.StatusFound)
}

// statsBody adalah body respons GET /stats/{code}.
type statsBody struct {
	Code      string    `json:"code"`
	URL       string    `json:"url"`
	Clicks    int64     `json:"clicks"`
	CreatedAt time.Time `json:"created_at"`
}

// Stats menangani GET /stats/{code} dan mengembalikan statistik klik
// tanpa menaikkannya.
func (h *ShortenHandler) Stats(w http.ResponseWriter, r *http.Request) {
	rec, err := h.svc.Stats(r.Context(), r.PathValue("code"))
	if err != nil {
		writeError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, statsBody{
		Code:      rec.Code,
		URL:       rec.OriginalURL,
		Clicks:    rec.Clicks,
		CreatedAt: rec.CreatedAt,
	})
}

// bacaJSON memeriksa Content-Type, membatasi ukuran body, lalu
// mendekode JSON ke dst sambil menolak field yang tidak dikenal.
func bacaJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || ct != "application/json" {
		return &galatBody{
			status:  http.StatusUnsupportedMediaType,
			code:    codeMediaType,
			message: "Content-Type harus application/json",
		}
	}
	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		var maxErr *http.MaxBytesError
		switch {
		case errors.As(err, &maxErr):
			return &galatBody{
				status:  http.StatusRequestEntityTooLarge,
				code:    codeBodyKebesaran,
				message: fmt.Sprintf("body melebihi batas %d byte", maxErr.Limit),
			}
		case errors.Is(err, io.EOF):
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body kosong; kirim JSON seperti {\"url\":\"https://example.com\"}",
			}
		default:
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body bukan JSON valid: " + err.Error(),
			}
		}
	}
	return nil
}
