package system

import (
	"context"
	"encoding/json"
	"net/http"
	"strconv"
	"strings"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/pkg/logger"
)

// LogWriter records a row for the audit trail. Implemented by Repository.
type LogWriter interface {
	InsertLog(ctx context.Context, p db.CreateActivityLogParams) error
}

// ActivityLogMiddleware returns a middleware that records successful (status<400)
// mutating requests (POST/PUT/PATCH/DELETE) for authenticated users
// (domain-map.md D11). Paths containing any skip substring are excluded (e.g.
// the dashboard and sales-tracking/location pings). Recording is best-effort:
// a failed insert never fails the request.
func ActivityLogMiddleware(log LogWriter, skip ...string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
			next.ServeHTTP(sw, r)

			u := auth.UserFrom(r.Context())
			if u == nil || sw.status >= http.StatusBadRequest {
				return
			}
			switch r.Method {
			case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
			default:
				return
			}
			p := r.URL.Path
			for _, s := range skip {
				if strings.Contains(p, s) {
					return
				}
			}

			module, action, modelID := classify(p, r.Method)
			// A POST to a collection path carries no id, so the created object
			// would otherwise be unidentifiable ("Admin menambahkan pengguna"
			// — which one?). The id is in the response envelope, so recover it
			// there rather than in every handler.
			if modelID == nil && r.Method == http.MethodPost {
				modelID = createdID(sw.body)
			}
			ip := r.RemoteAddr
			if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
				ip = firstHost(xff)
			} else {
				ip = firstHost(ip)
			}
			ua := r.UserAgent()
			desc := r.Method + " " + p
			modelType := module
			params := db.CreateActivityLogParams{
				UserID:      int8(u.ID),
				Module:      str(&module),
				Action:      str(&action),
				Description: str(&desc),
				ModelType:   str(&modelType),
				ModelID:     int8FromPtr(modelID),
				IpAddress:   str(&ip),
				UserAgent:   str(&ua),
			}
			// Best-effort (a failed insert never fails the already-written
			// request) but never silent (A9): a lost audit row logs loudly.
			if err := log.InsertLog(r.Context(), params); err != nil {
				logger.FromContext(r.Context()).Warn("audit log insert failed",
					"module", module, "action", action, "user_id", u.ID, "err", err)
			}
		})
	}
}

// classify derives the audit module, action and affected model id from a path
// like /api/v1/sales-orders/12/confirm → ("sales-orders", "post/confirm", 12).
func classify(path, method string) (module, action string, modelID *int64) {
	if i := strings.Index(path, "/api/v1"); i >= 0 {
		path = path[i+len("/api/v1"):]
	}
	segs := strings.FieldsFunc(path, func(c rune) bool { return c == '/' })
	action = strings.ToLower(method)
	if len(segs) == 0 {
		return "", action, nil
	}
	module = segs[0]
	var actionParts []string
	for i := 1; i < len(segs); i++ {
		s := segs[i]
		if id, err := strconv.ParseInt(s, 10, 64); err == nil {
			modelID = &id
		} else if i == 1 && !isActionVerb(s) {
			// second segment that is not an id is a sub-resource (e.g. stock-in)
			module += "/" + s
		} else {
			actionParts = append(actionParts, strings.ToLower(s))
		}
	}
	if len(actionParts) > 0 {
		action += "/" + strings.Join(actionParts, "/")
	}
	return module, action, modelID
}

// actionVerbs are second-path-segments that name an operation, not a
// sub-resource. Without this, POST /api/v1/auth/logout classified as
// module "auth/logout" + action "post" — three renderings where the verb
// carried no meaning ("membuat di auth/logout"). Hyphenated segments stay
// sub-resources (stock-in), and anything unlisted keeps the old behavior,
// so this can only ever move a row from a wrong module to a right action.
var actionVerbs = map[string]struct{}{
	"login": {}, "logout": {}, "register": {}, "cancel": {}, "confirm": {},
	"approve": {}, "toggle": {}, "close": {}, "complete": {}, "merge": {},
	"split": {}, "void": {}, "pay": {}, "payments": {}, "status": {},
	"verify": {}, "sweep": {}, "render": {}, "export": {}, "print": {},
	"receipt": {}, "barcodes": {}, "shipments": {},
}

func isActionVerb(s string) bool {
	_, ok := actionVerbs[strings.ToLower(s)]
	return ok
}

type statusWriter struct {
	http.ResponseWriter
	status int
	// body holds the first bodyCap bytes of a JSON response, so a POST to a
	// collection path can recover the created object's id. Bounded because
	// some POSTs return files (PDF/CSV exports), which must never be buffered
	// in full for an audit side-channel.
	body []byte
}

const bodyCap = 64 * 1024

func (w *statusWriter) Write(b []byte) (int, error) {
	if len(w.body) < bodyCap && strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
		room := bodyCap - len(w.body)
		cap := b
		if len(cap) > room {
			cap = cap[:room]
		}
		w.body = append(w.body, cap...)
	}
	return w.ResponseWriter.Write(b)
}

// createdID recovers the created object's id from a JSON response envelope
// ({data: {id}} or {data: [...]} — no, single object only). Anything
// unparseable, truncated, or id-less yields nil: the row is still recorded,
// just without a model id, exactly as before.
func createdID(body []byte) *int64 {
	if len(body) == 0 {
		return nil
	}
	var env struct {
		Data *struct {
			ID *int64 `json:"id"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return nil
	}
	if env.Data == nil || env.Data.ID == nil {
		return nil
	}
	return env.Data.ID
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

// Flush forwards to the underlying writer when supported, so handlers that
// stream (SSE, CSV) keep working through this middleware.
func (w *statusWriter) Flush() {
	if f, ok := w.ResponseWriter.(http.Flusher); ok {
		f.Flush()
	}
}

func firstHost(s string) string {
	// IPv6 literal like [::1]:5432 -> ::1
	if strings.HasPrefix(s, "[") {
		if i := strings.Index(s, "]"); i >= 0 {
			return s[1:i]
		}
		return s
	}
	for i := 0; i < len(s); i++ {
		if s[i] == ',' || s[i] == ':' {
			return s[:i]
		}
	}
	return s
}
