package handlers

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"math/rand"
	"net"
	"net/http"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"

	"wherewoof/frontend/internal/db"
	"wherewoof/frontend/internal/notify"
	"wherewoof/frontend/internal/sms"
)

const (
	alertWindow      = 10 * time.Minute
	alertMinDistance = 250.0 // metres

	// Cost-protection limits (env-overridable).
	maxAlertsPerTagDay = 5
	maxAlertsPerIPHour = 10
)

// ScanRequest is the JSON body posted by the geolocation script.
type ScanRequest struct {
	Lat         *float64 `json:"lat"`
	Lng         *float64 `json:"lng"`
	Phone       *string  `json:"phone"`
	Fingerprint *string  `json:"fingerprint"`
}

// Scan records a scan (with optional location) and alerts the owner per the
// location-aware throttle rules: within the 10-minute window an alert is only
// re-sent when the new location is more than 250 m from the last alerted spot.
func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
	tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "tag not found", http.StatusNotFound)
			return
		}
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	var req ScanRequest
	_ = json.NewDecoder(r.Body).Decode(&req)
	hasLoc := req.Lat != nil && req.Lng != nil

	var lat, lng pgtype.Float8
	if hasLoc {
		lat = pgtype.Float8{Float64: *req.Lat, Valid: true}
		lng = pgtype.Float8{Float64: *req.Lng, Valid: true}
	}

	scan, err := a.Queries.InsertScan(r.Context(), db.InsertScanParams{
		TagID:          tag.ID,
		Lat:            lat,
		Lng:            lng,
		LocationShared: hasLoc,
		ScannerPhone:   textOrNil(ptrStr(req.Phone)),
		Fingerprint:    textOrNil(ptrStr(req.Fingerprint)),
		Ip:             textOrNil(clientIP(r)),
	})
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	alertSent, reason := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone), clientIP(r))
	if alertSent {
		if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
			_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true})
			// Metering: a successful alert consumes one credit on metered tags.
			if tag.SmsAllocated > 0 {
				_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
			}
		}
	} else if reason == "lapsed" || reason == "credits" {
		// Owner unlock notice (ntfy / optional SMS) — exempt from metering.
		if a.Notifier != nil && tag.Phone.Valid {
			a.Notifier.Send(tag.Phone.String, notify.UnlockMessage(tag.TagCode, reason))
		}
	}

	writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent})
}

// shouldAlert applies the throttle rules and sms_enabled flag.
// Returns (send, reason) where reason is "lapsed" or "credits" when those
// gates blocked the alert (so the owner can be notified to renew/top up).
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone, ip string) (bool, string) {
	if !tag.SmsEnabled {
		return false, ""
	}
	if !tag.Phone.Valid {
		return false, ""
	}
	// Closed or suspended tags never alert (page shows unavailable).
	if tag.Status == "closed" || tag.Status == "suspended" {
		return false, ""
	}

	// Customer pause (admin kill-switch): paused owner -> record only.
	if tag.OwnerID.Valid {
		if owner, err := a.Queries.GetUserByID(ctx, tag.OwnerID.Int64); err == nil && owner.Paused {
			return false, ""
		}
	}

	// 24 h per-device block: same fingerprint seen recently (excluding this scan) => record only.
	if fp := scan.Fingerprint.String; fp != "" {
		if _, err := a.Queries.GetRecentScanByFingerprint(ctx, db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fp, Valid: true}, ID: scan.ID}); err == nil {
			return false, ""
		}
	}

	// Paid-order gating with lazy expiry: non-paid status, or a paid order
	// past its renews_at date, both block and trigger the unlock notice.
	if tag.OrderID.Valid {
		if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil {
			if order.Status != "paid" || (order.RenewsAt.Valid && order.RenewsAt.Time.Before(time.Now())) {
				return false, "lapsed"
			}
		}
	}

	// SMS metering: a positive allocation is required; exhausted credits block alerts.
	if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
		return false, "credits"
	}

	// Per-tag daily cap.
	if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
		TagID:     tag.ID,
		ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
	}); err == nil && n >= maxAlertsPerTagDay {
		return false, ""
	}

	// Per-IP hourly rate.
	if ip != "" {
		if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{
			Ip:        pgtype.Text{String: ip, Valid: true},
			ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
		}); err == nil && n >= maxAlertsPerIPHour {
			return false, ""
		}
	}

	last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
	if err != nil {
		return errors.Is(err, pgx.ErrNoRows), "" // no prior alert -> alert
	}

	// Within the window?
	if time.Since(last.ScannedAt.Time) < alertWindow {
		// Re-alert on movement >250 m, a different finder phone, or a location
		// upgrade (previous alert had no coords — send the map link).
		moved := hasLoc && last.Lat.Valid && last.Lng.Valid &&
			sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
		locationUpgrade := hasLoc && !last.Lat.Valid
		differentPhone := phone != "" && phone != last.ScannerPhone.String
		return moved || locationUpgrade || differentPhone, ""
	}
	return true, ""
}

// clientIP returns the client's IP from X-Forwarded-For (set by Caddy) or RemoteAddr.
func clientIP(r *http.Request) string {
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		if i := strings.Index(xff, ","); i > 0 {
			return strings.TrimSpace(xff[:i])
		}
		return strings.TrimSpace(xff)
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}

// alertOwner sends the SMS to the owner and reports success.
func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng *float64) bool {
	if a.Sender == nil {
		return false
	}
	// When a location is present, mint a short link so the SMS stays short and
	// the owner gets a map page instead of raw coordinates.
	link := ""
	if lat != nil && lng != nil {
		if code, err := a.mintShortCode(ctx, scan.ID); err == nil {
			link = "https://where-woof.com/s/" + code
		}
	}
	msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng, link)
	if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
		fmt.Println("alert sms failed:", err)
		return false
	}
	return true
}

// mintShortCode creates a short URL code for a scan (with retry on collision).
func (a *App) mintShortCode(ctx context.Context, scanID int64) (string, error) {
	const alphabet = "abcdefghijklmnopqrstuvwxyz23456789" // no 0/1/o/l
	for i := 0; i < 3; i++ {
		code := make([]byte, 6)
		for j := range code {
			code[j] = alphabet[rand.Intn(len(alphabet))]
		}
		c := string(code)
		if _, err := a.Queries.InsertShortCode(ctx, db.InsertShortCodeParams{Code: c, ScanID: scanID}); err == nil {
			return c, nil
		}
	}
	return "", fmt.Errorf("short code collision")
}

// alertMessage builds the owner alert: item type + name, time, location link, tag link.
func alertMessage(tag db.Tag, t time.Time, lat, lng *float64, link string) string {
	itemType := "Item"
	if tag.ItemType.Valid {
		itemType = titleCase(tag.ItemType.String)
	}
	name := strings.TrimSpace(tag.Description.String)
	if len(name) > 30 {
		name = name[:30] + "…"
	}
	label := itemType
	if name != "" {
		label = itemType + ", " + name
	}

	msg := fmt.Sprintf("Where Woof: your %s was scanned at %s.", label, t.Local().Format("3:04 pm"))
	if link != "" {
		msg += " Location: " + link
	} else if lat != nil && lng != nil {
		msg += fmt.Sprintf(" Location: https://maps.google.com/?q=%.5f,%.5f", *lat, *lng)
	} else {
		msg += " The finder didn't share a location."
	}
	msg += fmt.Sprintf(" See: https://where-woof.com/t/%s", tag.TagCode)
	return msg
}

// FinderContact stores a finder's number on the latest scan and alerts the owner.
// Dedup: same phone within the alert window => store only; fingerprint seen in
// the last 24 h => store only.
func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
	tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "tag not found", http.StatusNotFound)
			return
		}
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	phone := sms.NormalizeAU(strings.TrimSpace(r.FormValue("phone")))
	if len(phone) < 8 || len(phone) > 15 {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprint(w, `<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">Please enter a valid phone number.</p>`)
		return
	}
	fingerprint := strings.TrimSpace(r.FormValue("fingerprint"))

	latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID)
	if err != nil {
		if !errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		// No scan yet: record one carrying the finder's number + fingerprint.
		ins, insErr := a.Queries.InsertScan(r.Context(), db.InsertScanParams{
			TagID: tag.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true},
			Fingerprint: textOrNil(fingerprint),
		})
		if insErr != nil {
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		latest = ins
	} else {
		err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}, Fingerprint: textOrNil(fingerprint)})
	}
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	// Should we notify? Skip when the same phone was alerted within the window,
	// or when this device (fingerprint) has already alerted in the last 24 h.
	notify := tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil
	// Customer pause: paused owner -> store only.
	if notify && tag.OwnerID.Valid {
		if owner, err := a.Queries.GetUserByID(r.Context(), tag.OwnerID.Int64); err == nil && owner.Paused {
			notify = false
		}
	}
	// Metering: metered tags must have credits remaining.
	if notify && tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
		notify = false
	}
	if notify && fingerprint != "" {
		if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
			notify = false
		}
	}
	if notify {
		if last, err := a.Queries.GetLastAlertByTag(r.Context(), tag.ID); err == nil &&
			time.Since(last.ScannedAt.Time) < alertWindow && last.ScannerPhone.String == phone {
			notify = false
		}
	}

	if notify {
		itemType := "item"
		if tag.ItemType.Valid {
			itemType = tag.ItemType.String
		}
		msg := fmt.Sprintf("Where Woof: a finder of your %s left their number: %s. See: https://where-woof.com/t/%s",
			itemType, phone, tag.TagCode)
		if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
			fmt.Println("contact sms failed:", err)
		} else {
			// Mark alerted so dedup (same phone / same device) can see it.
			_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true})
			// Metering: successful contact SMS consumes a credit on metered tags.
			if tag.SmsAllocated > 0 {
				_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
			}
		}
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprint(w, `<p class="mt-4 rounded border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-800">Thanks! The owner has been notified with your number.</p>`)
}

func ptrStr(p *string) string {
	if p == nil {
		return ""
	}
	return *p
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(v)
}
