// Command authd is the in-house multi-tenant auth IdP that powers
// the M13 admin UI. It exposes:
//
//	POST /v1/auth/login        — email + password → access JWT + refresh token
//	POST /v1/auth/refresh      — refresh token → new pair
//	POST /v1/auth/logout       — refresh token → revoke
//	POST /v1/auth/magic        — magic-link token + new password → session
//	POST /v1/users/invite      — super/tenant-admin → magic link (email side-effect lives in the caller)
//	GET  /v1/users/me          — current user info
//	GET  /health, /metrics     — observability
//
// All endpoints are unauthenticated except /v1/users/me and
// /v1/users/invite (which require a valid access JWT). The auth
// path is /v1/auth/* (no JWT required to log in, naturally).
//
// Configuration: env vars only. See authdEnv() below.
package main

import (
	"context"
	cryptorand "crypto/rand"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"

	"git3.techno-world.net/lrosales/broad-announce/internal/authd"
	"git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
	"git3.techno-world.net/lrosales/broad-announce/internal/observability"
	"git3.techno-world.net/lrosales/broad-announce/internal/postgres"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
	if err := run(logger); err != nil {
		logger.Error("authd exited with error", "err", err)
		os.Exit(1)
	}
}

func run(logger *slog.Logger) error {
	cfg, err := authdEnv()
	if err != nil {
		return err
	}
	logger.Info("authd starting",
		"env", cfg.Env, "addr", cfg.HTTPAddr,
		"issuer", cfg.Issuer, "access_ttl", cfg.AuthdConfig.AccessTokenTTL)

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
	if err != nil {
		return err
	}
	defer pool.Close()

	// Verify the migration is applied (graceful if not — first boot
	// may need to run `go run ./cmd/seed` first).
	if err := pingSchema(ctx, pool); err != nil {
		logger.Warn("auth schema not yet present, login will fail", "err", err)
	}

	// Generate a JWT secret if not provided. The first-run case
	// (no env) gets a random secret written to a file so restarts
	// produce the same tokens. This is dev-only behavior; in
	// production the secret comes from K8s sealed-secrets.
	if len(cfg.JWTSecret) < 32 {
		if !cfg.AllowGeneratedSecret {
			return errors.New("BA_AUTHD_JWT_SECRET must be at least 32 bytes (set in env or sealed-secret)")
		}
		sec, err := loadOrCreateSecret(cfg.SecretFile, logger)
		if err != nil {
			return err
		}
		cfg.JWTSecret = sec
	}

	ad, err := authd.New(pool, cfg.AuthdConfig)
	if err != nil {
		return err
	}

	reg, _ := observability.NewRegistry("authd")
	srv := httpserver.New(httpserver.Config{
		Addr:        cfg.HTTPAddr,
		ServiceName: "authd",
	}, logger, observability.MetricsHandler(reg))

	mux := srv.Mux()
	mux.HandleFunc("POST /v1/auth/login", loginHandler(ad, logger))
	mux.HandleFunc("POST /v1/auth/refresh", refreshHandler(ad, logger))
	mux.HandleFunc("POST /v1/auth/logout", logoutHandler(ad, logger))
	mux.HandleFunc("POST /v1/auth/magic", magicConsumeHandler(ad, logger))
	mux.Handle("POST /v1/users/invite", ad.RequireRole("super_admin", "tenant_admin")(inviteHandler(ad, logger)))
	mux.Handle("GET /v1/users/me", ad.RequireAuth(meHandler(ad, logger)))

	// M13b W1: Tenant (company) CRUD. See cmd/authd/tenants.go.
	//   GET    /v1/tenants              — any auth (scope: super_admin sees all, others see own)
	//   POST   /v1/tenants              — super_admin only
	//   GET    /v1/tenants/{id}         — any auth (per-id scope check in handler)
	//   PATCH  /v1/tenants/{id}         — any auth (per-id scope + field scope in handler)
	//   POST   /v1/tenants/{id}/status  — super_admin only
	mux.Handle("GET /v1/tenants", ad.RequireAuth(listTenantsHandler(ad, logger)))
	mux.Handle("POST /v1/tenants", ad.RequireRole("super_admin")(createTenantHandler(ad, logger)))
	mux.Handle("GET /v1/tenants/{id}", ad.RequireAuth(getTenantHandler(ad, logger)))
	mux.Handle("PATCH /v1/tenants/{id}", ad.RequireAuth(updateTenantHandler(ad, logger)))
	mux.Handle("POST /v1/tenants/{id}/status", ad.RequireRole("super_admin")(setTenantStatusHandler(ad, logger)))

	// M13b W2: Source CRUD. See cmd/authd/sources.go.
	//   GET    /v1/tenants/{id}/sources                       — any auth, per-tenant scope
	//   POST   /v1/tenants/{id}/sources                       — any auth, per-tenant scope
	//   GET    /v1/tenants/{id}/sources/{sid}                 — any auth, per-tenant scope
	//   PATCH  /v1/tenants/{id}/sources/{sid}                 — any auth, per-tenant scope
	//   POST   /v1/tenants/{id}/sources/{sid}/status          — any auth, per-tenant scope
	//   POST   /v1/tenants/{id}/sources/{sid}/rotate-secrets  — any auth, per-tenant scope
	mux.Handle("GET /v1/tenants/{id}/sources", ad.RequireAuth(listSourcesHandler(ad, logger)))
	mux.Handle("POST /v1/tenants/{id}/sources", ad.RequireAuth(createSourceHandler(ad, logger)))
	mux.Handle("GET /v1/tenants/{id}/sources/{sid}", ad.RequireAuth(getSourceHandler(ad, logger)))
	mux.Handle("PATCH /v1/tenants/{id}/sources/{sid}", ad.RequireAuth(updateSourceHandler(ad, logger)))
	mux.Handle("POST /v1/tenants/{id}/sources/{sid}/status", ad.RequireAuth(setSourceStatusHandler(ad, logger)))
	mux.Handle("POST /v1/tenants/{id}/sources/{sid}/rotate-secrets", ad.RequireAuth(rotateSourceSecretsHandler(ad, logger)))

	// M13b W3: Telegram bot CRUD. See cmd/authd/telegrambots.go.
	//   GET    /v1/tenants/{id}/telegram/bots                       — super_admin only
	//   POST   /v1/tenants/{id}/telegram/bots                       — super_admin only
	//   GET    /v1/tenants/{id}/telegram/bots/{bid}                 — super_admin only
	//   PATCH  /v1/tenants/{id}/telegram/bots/{bid}                 — super_admin only
	//   POST   /v1/tenants/{id}/telegram/bots/{bid}/status          — super_admin only
	//   POST   /v1/tenants/{id}/telegram/bots/{bid}/rotate-token    — super_admin only
	// Bot token is write-only: server never returns the plaintext.
	mux.Handle("GET /v1/tenants/{id}/telegram/bots", ad.RequireRole("super_admin")(listTelegramBotsHandler(ad, logger)))
	mux.Handle("POST /v1/tenants/{id}/telegram/bots", ad.RequireRole("super_admin")(createTelegramBotHandler(ad, logger)))
	mux.Handle("GET /v1/tenants/{id}/telegram/bots/{bid}", ad.RequireRole("super_admin")(getTelegramBotHandler(ad, logger)))
	mux.Handle("PATCH /v1/tenants/{id}/telegram/bots/{bid}", ad.RequireRole("super_admin")(updateTelegramBotHandler(ad, logger)))
	mux.Handle("POST /v1/tenants/{id}/telegram/bots/{bid}/status", ad.RequireRole("super_admin")(setTelegramBotStatusHandler(ad, logger)))
	mux.Handle("POST /v1/tenants/{id}/telegram/bots/{bid}/rotate-token", ad.RequireRole("super_admin")(rotateTelegramBotTokenHandler(ad, logger)))

	// Start in background, wait for signal, then graceful shutdown.
	errCh := make(chan error, 1)
	go func() { errCh <- srv.Start() }()

	select {
	case err := <-errCh:
		return err
	case <-ctx.Done():
		logger.Info("shutdown signal received")
		shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
		defer cancel()
		return srv.Shutdown(shutdownCtx)
	}
}

// ---------------------------------------------------------------------------
// Env loading
// ---------------------------------------------------------------------------

type env struct {
	Env       string
	HTTPAddr  string
	Issuer    string
	AuthdConfig authd.Config

	PostgresDSN  string
	ShutdownGrace time.Duration

	// JWT secret
	JWTSecret         []byte
	AllowGeneratedSecret bool
	SecretFile        string
}

func authdEnv() (env, error) {
	e := env{
		Env:        getenvDefault("BA_ENV", "dev"),
		HTTPAddr:   getenvDefault("BA_AUTHD_HTTP_ADDR", ":8804"),
		Issuer:     getenvDefault("BA_AUTHD_ISSUER", "broad-announce"),
		PostgresDSN: os.Getenv("BA_POSTGRES_DSN"),
	}
	if s := os.Getenv("BA_AUTHD_JWT_SECRET"); s != "" {
		e.JWTSecret = []byte(s)
	}
	e.AllowGeneratedSecret = getenvDefault("BA_AUTHD_ALLOW_GENERATED_SECRET", "") == "1" ||
		os.Getenv("BA_ENV") == "dev"
	e.SecretFile = getenvDefault("BA_AUTHD_SECRET_FILE", "/var/run/broad-announce/authd.jwt")
	if t := os.Getenv("BA_AUTHD_ACCESS_TTL"); t != "" {
		d, err := time.ParseDuration(t)
		if err != nil {
			return e, err
		}
		e.AuthdConfig.AccessTokenTTL = d
	}
	if t := os.Getenv("BA_AUTHD_REFRESH_TTL"); t != "" {
		d, err := time.ParseDuration(t)
		if err != nil {
			return e, err
		}
		e.AuthdConfig.RefreshTokenTTL = d
	}
	if c := os.Getenv("BA_AUTHD_BCRYPT_COST"); c != "" {
		n, err := strconv.Atoi(c)
		if err != nil {
			return e, err
		}
		e.AuthdConfig.BcryptCost = n
	}
	e.AuthdConfig.JWTSecret = e.JWTSecret // may be empty; New() will reject
	e.AuthdConfig.Issuer = e.Issuer
	if e.PostgresDSN == "" {
		return e, errors.New("BA_POSTGRES_DSN must be set")
	}
	grace := 10 * time.Second
	if g := os.Getenv("BA_AUTHD_SHUTDOWN_GRACE"); g != "" {
		d, err := time.ParseDuration(g)
		if err != nil {
			return e, err
		}
		grace = d
	}
	e.ShutdownGrace = grace
	return e, nil
}

func getenvDefault(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

// pingSchema is a best-effort check that the auth schema is migrated.
// Returns nil if the audit_log table exists, an error otherwise.
func pingSchema(ctx context.Context, pool *postgres.Pool) error {
	row := pool.QueryRow(ctx,
		`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='auth' AND table_name='users')`)
	var ok bool
	if err := row.Scan(&ok); err != nil {
		return err
	}
	if !ok {
		return errors.New("auth.users not found; run migrations first")
	}
	return nil
}

// loadOrCreateSecret reads the JWT secret from disk, or generates a
// random one and writes it. The file is chmod 600.
func loadOrCreateSecret(path string, logger *slog.Logger) ([]byte, error) {
	if data, err := os.ReadFile(path); err == nil && len(data) >= 32 {
		return data, nil
	}
	logger.Warn("generating new JWT secret (dev-only)", "file", path)
	if err := os.MkdirAll(parentDir(path), 0o700); err != nil {
		return nil, err
	}
	secret := make([]byte, 48)
	if _, err := readFull(secret); err != nil {
		return nil, err
	}
	if err := os.WriteFile(path, secret, 0o600); err != nil {
		return nil, err
	}
	return secret, nil
}

// readFull fills b with cryptographically random bytes.
func readFull(b []byte) (int, error) {
	return randomRead(b)
}

func parentDir(p string) string {
	for i := len(p) - 1; i >= 0; i-- {
		if p[i] == '/' {
			return p[:i]
		}
	}
	return "."
}

// randomRead is split out so tests can stub it; default uses crypto/rand.
var randomRead = func(b []byte) (int, error) {
	return cryptorand.Read(b)
}

// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------

// clientIP pulls the IP from r.RemoteAddr, respecting X-Forwarded-For
// when BA_AUTHD_TRUST_FORWARDED=1. Returns the bare IP (no port)
// because the audit_log columns are INET.
func clientIP(r *http.Request) string {
	raw := r.RemoteAddr
	if os.Getenv("BA_AUTHD_TRUST_FORWARDED") == "1" {
		if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
			// X-Forwarded-For is a list; first is the client
			if i := indexByte(xff, ','); i >= 0 {
				raw = xff[:i]
			} else {
				raw = xff
			}
		}
	}
	// Strip :port if present (host:port)
	if i := lastByte(raw, ':'); i >= 0 {
		// Make sure it's not part of an IPv6 address
		if !containsByte(raw, ']') || i > indexByte(raw, ']') {
			raw = raw[:i]
		}
	}
	return raw
}

func indexByte(s string, c byte) int {
	for i := 0; i < len(s); i++ {
		if s[i] == c {
			return i
		}
	}
	return -1
}

func lastByte(s string, c byte) int {
	for i := len(s) - 1; i >= 0; i-- {
		if s[i] == c {
			return i
		}
	}
	return -1
}

func containsByte(s string, c byte) bool {
	return indexByte(s, c) >= 0
}

// writeJSON writes status + JSON body. Sets Content-Type.
func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

// writeErr writes a JSON error response.
func writeErr(w http.ResponseWriter, status int, code, msg string) {
	writeJSON(w, status, map[string]string{"error": code, "message": msg})
}

// --- /v1/auth/login -------------------------------------------------------

type loginReq struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

type loginResp struct {
	AccessToken  string    `json:"access_token"`
	RefreshToken string    `json:"refresh_token"`
	TokenType    string    `json:"token_type"`
	ExpiresAt    time.Time `json:"expires_at"`
	UserID       string    `json:"user_id"`
	TenantID     string    `json:"tenant_id,omitempty"`
	Role         string    `json:"role"`
}

func loginHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req loginReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
			return
		}
		if req.Email == "" || req.Password == "" {
			writeErr(w, http.StatusBadRequest, "bad_request", "email and password required")
			return
		}
		res, err := ad.Login(r.Context(), req.Email, req.Password, clientIP(r), r.UserAgent())
		if err != nil {
			if errors.Is(err, authd.ErrInvalidCredentials) || errors.Is(err, authd.ErrUserDisabled) {
				writeErr(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
				return
			}
			logger.Error("login internal error", "err", err)
			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
			return
		}
		writeJSON(w, http.StatusOK, loginResp{
			AccessToken:  res.AccessToken,
			RefreshToken: res.RefreshToken,
			TokenType:    "Bearer",
			ExpiresAt:    res.ExpiresAt,
			UserID:       res.UserID,
			TenantID:     res.TenantID,
			Role:         res.Role,
		})
	}
}

// --- /v1/auth/refresh -----------------------------------------------------

type refreshReq struct {
	RefreshToken string `json:"refresh_token"`
}

func refreshHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req refreshReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
			return
		}
		if req.RefreshToken == "" {
			writeErr(w, http.StatusBadRequest, "bad_request", "refresh_token required")
			return
		}
		res, err := ad.Refresh(r.Context(), req.RefreshToken, clientIP(r), r.UserAgent())
		if err != nil {
			if errors.Is(err, authd.ErrTokenReuse) {
				writeErr(w, http.StatusUnauthorized, "session_killed", "refresh token re-use detected, please log in again")
				return
			}
			if errors.Is(err, authd.ErrUserDisabled) {
				writeErr(w, http.StatusUnauthorized, "unauthorized", "user disabled")
				return
			}
			logger.Error("refresh internal error", "err", err)
			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
			return
		}
		writeJSON(w, http.StatusOK, loginResp{
			AccessToken:  res.AccessToken,
			RefreshToken: res.RefreshToken,
			TokenType:    "Bearer",
			ExpiresAt:    res.ExpiresAt,
			UserID:       res.UserID,
			TenantID:     res.TenantID,
			Role:         res.Role,
		})
	}
}

// --- /v1/auth/logout ------------------------------------------------------

type logoutReq struct {
	RefreshToken string `json:"refresh_token"`
}

func logoutHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req logoutReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
			return
		}
		// Resolve user id from the access JWT in the Authorization
		// header (best effort — logout is allowed without a valid
		// token so a user with a dead access can still kill their
		// refresh). If no claims, audit logs "user_id=''".
		uid := ""
		if c := authd.ClaimsFromContext(r.Context()); c != nil {
			uid = c.UserID
		}
		if err := ad.Logout(r.Context(), req.RefreshToken, uid, clientIP(r), r.UserAgent()); err != nil {
			logger.Error("logout internal error", "err", err)
			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
			return
		}
		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
	}
}

// --- /v1/auth/magic -------------------------------------------------------

type magicReq struct {
	Token       string `json:"token"`
	NewPassword string `json:"new_password"`
}

func magicConsumeHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req magicReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
			return
		}
		if req.Token == "" || req.NewPassword == "" {
			writeErr(w, http.StatusBadRequest, "bad_request", "token and new_password required")
			return
		}
		if len(req.NewPassword) < 12 {
			writeErr(w, http.StatusBadRequest, "weak_password", "password must be at least 12 characters")
			return
		}
		uid, err := ad.ConsumeMagicLink(r.Context(), req.Token, clientIP(r), r.UserAgent())
		if err != nil {
			if errors.Is(err, authd.ErrMagicLinkInvalid) {
				writeErr(w, http.StatusUnauthorized, "invalid_link", "magic link invalid or expired")
				return
			}
			logger.Error("magic consume error", "err", err)
			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
			return
		}
		if err := ad.SetPassword(r.Context(), uid, req.NewPassword); err != nil {
			logger.Error("set password error", "err", err)
			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
			return
		}
		// Auto-login: return a session so the user lands on the
		// dashboard without a separate login round-trip.
		user, err := ad.Store().GetUserByID(r.Context(), uid)
		if err != nil {
			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
			return
		}
		// We don't have the plaintext password to call Login(). The
		// simpler path: sign a session directly using a helper.
		// For now, return success and require the user to log in
		// normally — UX is one extra click, security is cleaner.
		_ = user
		writeJSON(w, http.StatusOK, map[string]any{
			"status":  "ok",
			"user_id": uid,
			"message": "password set. Please log in.",
		})
	}
}

// --- /v1/users/invite -----------------------------------------------------

type inviteReq struct {
	TenantSlug string `json:"tenant_slug"`
	Email      string `json:"email"`
	Role       string `json:"role"`
}

type inviteResp struct {
	UserID         string    `json:"user_id"`
	MagicLinkToken string    `json:"magic_link_token"`
	ExpiresAt      time.Time `json:"expires_at"`
}

func inviteHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		claims := authd.ClaimsFromContext(r.Context())
		if claims == nil {
			// Should never happen — RequireAuth/RequireRole ensured
			// this. Belt-and-suspenders.
			writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
			return
		}
		var req inviteReq
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
			return
		}
		if req.Email == "" || req.Role == "" {
			writeErr(w, http.StatusBadRequest, "bad_request", "email and role required")
			return
		}
		// Resolve tenant: super_admin can target any tenant by slug;
		// tenant_admin can only target their own tenant.
		var (
			tenantID string
			err      error
		)
		if claims.Role == "super_admin" {
			if req.TenantSlug == "" {
				writeErr(w, http.StatusBadRequest, "bad_request", "tenant_slug required for super_admin")
				return
			}
			tenantID, err = ad.Store().TenantIDBySlug(r.Context(), req.TenantSlug)
			if err != nil {
				writeErr(w, http.StatusBadRequest, "unknown_tenant", "tenant_slug not found")
				return
			}
		} else {
			tenantID = claims.TenantID
		}
		token, uid, err := ad.InviteUser(r.Context(), tenantID, req.Email, req.Role, claims.UserID, clientIP(r), r.UserAgent())
		if err != nil {
			logger.Error("invite error", "err", err)
			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
			return
		}
		writeJSON(w, http.StatusOK, inviteResp{
			UserID:         uid,
			MagicLinkToken: token,
		})
	}
}

// --- /v1/users/me ---------------------------------------------------------

func meHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		claims := authd.ClaimsFromContext(r.Context())
		if claims == nil {
			writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
			return
		}
		u, err := ad.Store().GetUserByID(r.Context(), claims.UserID)
		if err != nil {
			writeErr(w, http.StatusInternalServerError, "internal", "user lookup failed")
			return
		}
		writeJSON(w, http.StatusOK, map[string]any{
			"user_id":     u.ID,
			"email":       u.Email,
			"role":        u.Role,
			"status":      u.Status,
			"tenant_id":   u.TenantID,
			"display_name": u.DisplayName,
		})
	}
}

