package plugin

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"

	"gogobee/internal/db"

	"maunium.net/go/mautrix"
	"maunium.net/go/mautrix/event"
	"maunium.net/go/mautrix/id"
)

// SpaceInviterPlugin DMs the configured admin whenever a local homeserver
// user is detected as belonging to zero Matrix Spaces. The admin replies
// `yes` / `no` / `ignore` in the DM room; on `yes` the bot invites the user
// to the configured target Space.
type SpaceInviterPlugin struct {
	Base

	enabled bool
	cfg     spaceInviterConfig

	// Resolved at Init: DM room with admin
	adminDMRoom id.RoomID

	// In-memory: user IDs we've sent a (still-pending) prompt for this session.
	// DB is the source of truth across restarts; this just short-circuits before
	// hitting SQLite on the hot path of the reactive handler.
	pendingMu sync.Mutex
	pending   map[id.UserID]struct{}
}

type spaceInviterConfig struct {
	TargetSpaceID    id.RoomID
	HomeDomain       string
	SynapseBaseURL   string
	AdminAccessToken string
	AdminUserID      id.UserID
	InviteDelay      time.Duration
	RepromptCooldown time.Duration
}

func loadSpaceInviterConfig() spaceInviterConfig {
	return spaceInviterConfig{
		TargetSpaceID:    id.RoomID(os.Getenv("SPACE_INVITER_TARGET")),
		HomeDomain:       os.Getenv("SPACE_INVITER_HOME_DOMAIN"),
		SynapseBaseURL:   strings.TrimRight(os.Getenv("SPACE_INVITER_SYNAPSE_URL"), "/"),
		AdminAccessToken: os.Getenv("SPACE_INVITER_ADMIN_TOKEN"),
		AdminUserID:      id.UserID(os.Getenv("SPACE_INVITER_ADMIN_USER")),
		InviteDelay:      envDur("SPACE_INVITER_DELAY", 2*time.Second),
		RepromptCooldown: envDur("SPACE_INVITER_COOLDOWN", 30*24*time.Hour),
	}
}

func envDur(key string, fallback time.Duration) time.Duration {
	v := os.Getenv(key)
	if v == "" {
		return fallback
	}
	if d, err := time.ParseDuration(v); err == nil {
		return d
	}
	if n, err := strconv.Atoi(v); err == nil {
		return time.Duration(n) * time.Second
	}
	return fallback
}

func NewSpaceInviterPlugin(client *mautrix.Client) *SpaceInviterPlugin {
	cfg := loadSpaceInviterConfig()
	enabled := strings.EqualFold(os.Getenv("FEATURE_SPACE_INVITER"), "true")
	if !enabled {
		slog.Info("space_inviter: disabled (set FEATURE_SPACE_INVITER=true to enable)")
	}
	return &SpaceInviterPlugin{
		Base:    NewBase(client),
		enabled: enabled,
		cfg:     cfg,
		pending: make(map[id.UserID]struct{}),
	}
}

func (p *SpaceInviterPlugin) Name() string                     { return "space_inviter" }
func (p *SpaceInviterPlugin) Commands() []CommandDef           { return nil }
func (p *SpaceInviterPlugin) OnReaction(ReactionContext) error { return nil }

func (p *SpaceInviterPlugin) Init() error {
	if !p.enabled {
		return nil
	}

	if err := p.validateConfig(); err != nil {
		return fmt.Errorf("space_inviter: %w", err)
	}

	ctx := context.Background()

	// Permission check: bot must be in target space and able to invite.
	if _, err := p.Client.JoinedMembers(ctx, p.cfg.TargetSpaceID); err != nil {
		return fmt.Errorf("space_inviter: bot not in target space %s: %w", p.cfg.TargetSpaceID, err)
	}
	if err := p.checkInvitePower(ctx); err != nil {
		return fmt.Errorf("space_inviter: %w", err)
	}

	// Resolve DM room with admin.
	dm, err := p.GetDMRoom(p.cfg.AdminUserID)
	if err != nil {
		return fmt.Errorf("space_inviter: resolve admin DM: %w", err)
	}
	p.adminDMRoom = dm

	// Sanity log: enumerate Spaces the bot is in.
	spaces := p.discoverSpaces(ctx)
	slog.Info("space_inviter: ready",
		"target_space", p.cfg.TargetSpaceID,
		"admin", p.cfg.AdminUserID,
		"dm_room", p.adminDMRoom,
		"known_spaces", len(spaces),
	)
	if len(spaces) <= 1 {
		slog.Warn("space_inviter: bot is in only the target space — 'in any space' check degenerates to 'in target space'")
	}

	// Hydrate pending set from DB (rows with reply IS NULL).
	p.hydratePending()

	// Kick off startup sweep in the background; we don't want to block bot startup.
	go p.runStartupSweep()

	return nil
}

func (p *SpaceInviterPlugin) validateConfig() error {
	missing := []string{}
	if p.cfg.TargetSpaceID == "" {
		missing = append(missing, "SPACE_INVITER_TARGET")
	}
	if p.cfg.HomeDomain == "" {
		missing = append(missing, "SPACE_INVITER_HOME_DOMAIN")
	}
	if p.cfg.SynapseBaseURL == "" {
		missing = append(missing, "SPACE_INVITER_SYNAPSE_URL")
	}
	if p.cfg.AdminAccessToken == "" {
		missing = append(missing, "SPACE_INVITER_ADMIN_TOKEN")
	}
	if p.cfg.AdminUserID == "" {
		missing = append(missing, "SPACE_INVITER_ADMIN_USER")
	}
	if len(missing) > 0 {
		return fmt.Errorf("missing required config: %s", strings.Join(missing, ", "))
	}
	return nil
}

func (p *SpaceInviterPlugin) checkInvitePower(ctx context.Context) error {
	var pl event.PowerLevelsEventContent
	if err := p.Client.StateEvent(ctx, p.cfg.TargetSpaceID, event.StatePowerLevels, "", &pl); err != nil {
		return fmt.Errorf("read target space power levels: %w", err)
	}
	want := pl.Invite()
	have := pl.GetUserLevel(p.Client.UserID)
	if have < want {
		return fmt.Errorf("bot power level %d in target space < required invite level %d", have, want)
	}
	return nil
}

// ---------------------------------------------------------------------------
// Space membership
// ---------------------------------------------------------------------------

// discoverSpaces returns the set of Space room IDs the bot is currently joined to.
func (p *SpaceInviterPlugin) discoverSpaces(ctx context.Context) []id.RoomID {
	resp, err := p.Client.JoinedRooms(ctx)
	if err != nil {
		slog.Error("space_inviter: list joined rooms", "err", err)
		return nil
	}
	spaces := make([]id.RoomID, 0)
	for _, room := range resp.JoinedRooms {
		var ce event.CreateEventContent
		if err := p.Client.StateEvent(ctx, room, event.StateCreate, "", &ce); err != nil {
			continue
		}
		if ce.Type == event.RoomTypeSpace {
			spaces = append(spaces, room)
		}
	}
	return spaces
}

// usersInAnySpace returns the union of joined+invited members across every
// Space the bot is in. Pending invites are read from the room state.
func (p *SpaceInviterPlugin) usersInAnySpace(ctx context.Context) map[id.UserID]struct{} {
	out := make(map[id.UserID]struct{})
	for _, space := range p.discoverSpaces(ctx) {
		// Joined members
		jm, err := p.Client.JoinedMembers(ctx, space)
		if err != nil {
			slog.Warn("space_inviter: joined members", "space", space, "err", err)
		} else {
			for uid := range jm.Joined {
				out[uid] = struct{}{}
			}
		}
		// Pending invites — pull all m.room.member state events
		members, err := p.Client.Members(ctx, space)
		if err != nil {
			slog.Warn("space_inviter: members state", "space", space, "err", err)
			continue
		}
		for _, evt := range members.Chunk {
			mem := evt.Content.AsMember()
			if mem == nil {
				continue
			}
			if mem.Membership == event.MembershipInvite {
				out[id.UserID(evt.GetStateKey())] = struct{}{}
			}
		}
	}
	return out
}

// ---------------------------------------------------------------------------
// Synapse admin API: enumerate local users
// ---------------------------------------------------------------------------

type synapseUser struct {
	Name        string `json:"name"`
	UserType    string `json:"user_type"`
	Deactivated int    `json:"deactivated"`
	IsGuest     int    `json:"is_guest"`
	Admin       int    `json:"admin"`
}

func (p *SpaceInviterPlugin) listLocalUsers(ctx context.Context) ([]synapseUser, error) {
	out := []synapseUser{}
	from := 0
	const limit = 100
	for {
		u := fmt.Sprintf("%s/_synapse/admin/v2/users?guests=false&deactivated=false&from=%d&limit=%d",
			p.cfg.SynapseBaseURL, from, limit)
		req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
		req.Header.Set("Authorization", "Bearer "+p.cfg.AdminAccessToken)
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, fmt.Errorf("admin api: %w", err)
		}
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		if resp.StatusCode != http.StatusOK {
			return nil, fmt.Errorf("admin api status %d: %s", resp.StatusCode, string(body))
		}
		var page struct {
			Users     []synapseUser `json:"users"`
			NextToken string        `json:"next_token"`
			Total     int           `json:"total"`
		}
		if err := json.Unmarshal(body, &page); err != nil {
			return nil, fmt.Errorf("admin api decode: %w", err)
		}
		out = append(out, page.Users...)
		if page.NextToken == "" {
			break
		}
		n, err := strconv.Atoi(page.NextToken)
		if err != nil {
			break
		}
		from = n
	}
	return out, nil
}

// ---------------------------------------------------------------------------
// User filter
// ---------------------------------------------------------------------------

func (p *SpaceInviterPlugin) eligibleUser(uid id.UserID, userType string, isGuest, deactivated int) bool {
	if uid == "" {
		return false
	}
	if !strings.HasSuffix(string(uid), ":"+p.cfg.HomeDomain) {
		return false
	}
	if uid == p.Client.UserID || uid == p.cfg.AdminUserID {
		return false
	}
	if userType != "" {
		// Synapse marks bots/appservice puppets with non-empty user_type
		// (e.g. "bot", "support", or appservice-set values). Skip them.
		return false
	}
	if isGuest != 0 || deactivated != 0 {
		return false
	}
	return true
}

// ---------------------------------------------------------------------------
// Eligibility (DB-backed)
// ---------------------------------------------------------------------------

// dbEligible returns true if a fresh prompt may be sent for user. Encodes the
// pending / ignore / cooldown rules from the spec.
func (p *SpaceInviterPlugin) dbEligible(uid id.UserID) bool {
	cooldownCutoff := time.Now().Add(-p.cfg.RepromptCooldown).Unix()
	row := db.Get().QueryRow(`
		SELECT 1 FROM space_inviter_prompts
		WHERE user_id = ?
		  AND (reply IS NULL OR reply = 'ignore' OR prompt_sent_at > ?)
		LIMIT 1`,
		string(uid), cooldownCutoff)
	var n int
	err := row.Scan(&n)
	if err == nil {
		return false // a blocking row exists
	}
	return true
}

// hydratePending preloads the in-memory pending set from DB rows with NULL reply.
func (p *SpaceInviterPlugin) hydratePending() {
	rows, err := db.Get().Query(`SELECT user_id FROM space_inviter_prompts WHERE reply IS NULL`)
	if err != nil {
		slog.Warn("space_inviter: hydrate pending", "err", err)
		return
	}
	defer rows.Close()
	p.pendingMu.Lock()
	defer p.pendingMu.Unlock()
	for rows.Next() {
		var u string
		if err := rows.Scan(&u); err == nil {
			p.pending[id.UserID(u)] = struct{}{}
		}
	}
}

// ---------------------------------------------------------------------------
// Startup sweep
// ---------------------------------------------------------------------------

func (p *SpaceInviterPlugin) runStartupSweep() {
	ctx := context.Background()
	users, err := p.listLocalUsers(ctx)
	if err != nil {
		slog.Error("space_inviter: startup sweep aborted", "err", err)
		return
	}
	inSpace := p.usersInAnySpace(ctx)

	totalEligible, prompts := 0, 0
	for _, u := range users {
		uid := id.UserID(u.Name)
		if !p.eligibleUser(uid, u.UserType, u.IsGuest, u.Deactivated) {
			continue
		}
		totalEligible++
		if _, ok := inSpace[uid]; ok {
			continue
		}
		if !p.dbEligible(uid) {
			continue
		}
		// Pending invite to target?
		if p.hasPendingInvite(ctx, uid) {
			continue
		}
		if err := p.sendPrompt(uid, "sweep"); err != nil {
			slog.Warn("space_inviter: send prompt failed", "user", uid, "err", err)
			continue
		}
		prompts++
		time.Sleep(p.cfg.InviteDelay)
	}
	slog.Info("space_inviter: startup sweep complete",
		"total_local_users", len(users),
		"eligible_after_filter", totalEligible,
		"prompts_sent", prompts,
	)
}

// hasPendingInvite returns true if uid is currently invited to the target Space.
func (p *SpaceInviterPlugin) hasPendingInvite(ctx context.Context, uid id.UserID) bool {
	var mem event.MemberEventContent
	err := p.Client.StateEvent(ctx, p.cfg.TargetSpaceID, event.StateMember, string(uid), &mem)
	if err != nil {
		return false
	}
	return mem.Membership == event.MembershipInvite
}

// ---------------------------------------------------------------------------
// Reactive handler — called from main.go on m.room.member joins
// ---------------------------------------------------------------------------

// OnMemberJoin is invoked by the global member event handler in main.go for
// any join event. It only acts on local-domain users.
func (p *SpaceInviterPlugin) OnMemberJoin(uid id.UserID) {
	if !p.enabled {
		return
	}
	if !p.eligibleUser(uid, "", 0, 0) {
		return
	}

	p.pendingMu.Lock()
	if _, already := p.pending[uid]; already {
		p.pendingMu.Unlock()
		return
	}
	p.pendingMu.Unlock()

	if !p.dbEligible(uid) {
		return
	}

	ctx := context.Background()
	inSpace := p.usersInAnySpace(ctx)
	if _, ok := inSpace[uid]; ok {
		return
	}
	if p.hasPendingInvite(ctx, uid) {
		return
	}

	if err := p.sendPrompt(uid, "reactive"); err != nil {
		slog.Warn("space_inviter: reactive prompt failed", "user", uid, "err", err)
	}
}

// ---------------------------------------------------------------------------
// Prompt + reply
// ---------------------------------------------------------------------------

func (p *SpaceInviterPlugin) sendPrompt(uid id.UserID, trigger string) error {
	text := fmt.Sprintf(
		"🐝 `%s` isn't in any Space on the homeserver.\n"+
			"Want me to invite them to the target Space?\n\n"+
			"Reply with:\n"+
			"  `yes`    — invite them\n"+
			"  `no`     — skip for now (won't ask again for %s)\n"+
			"  `ignore` — never ask about this user again",
		uid, formatCooldown(p.cfg.RepromptCooldown),
	)
	evtID, err := p.SendMessageID(p.adminDMRoom, text)
	if err != nil {
		return err
	}
	now := time.Now().Unix()
	_, err = db.Get().Exec(`
		INSERT INTO space_inviter_prompts
		    (user_id, prompt_sent_at, trigger_kind, dm_room_id, dm_event_id)
		VALUES (?, ?, ?, ?, ?)`,
		string(uid), now, trigger, string(p.adminDMRoom), string(evtID),
	)
	if err != nil {
		return fmt.Errorf("record prompt: %w", err)
	}
	p.pendingMu.Lock()
	p.pending[uid] = struct{}{}
	p.pendingMu.Unlock()
	slog.Info("space_inviter: prompt_sent", "user", uid, "trigger", trigger, "event", evtID)
	return nil
}

func formatCooldown(d time.Duration) string {
	if d >= 24*time.Hour {
		return fmt.Sprintf("%d days", int(d.Hours())/24)
	}
	if d >= time.Hour {
		return fmt.Sprintf("%d hours", int(d.Hours()))
	}
	return d.String()
}

// OnMessage handles admin replies in the DM room. Other messages pass through.
func (p *SpaceInviterPlugin) OnMessage(ctx MessageContext) error {
	if !p.enabled {
		return nil
	}
	if ctx.RoomID != p.adminDMRoom {
		return nil
	}
	if ctx.Sender != p.cfg.AdminUserID {
		return nil
	}

	reply := strings.ToLower(strings.TrimSpace(ctx.Body))
	if reply != "yes" && reply != "no" && reply != "ignore" {
		return nil // silently ignore unknown replies
	}

	// Find the most recent pending prompt in this room. If the admin's message
	// is a Matrix reply, prefer the row matching that target event.
	targetEvent := ""
	if rel := ctx.Event.Content.AsMessage().RelatesTo; rel != nil && rel.InReplyTo != nil {
		targetEvent = string(rel.InReplyTo.EventID)
	}

	user, promptAt, err := p.findPendingPrompt(targetEvent)
	if err != nil {
		return nil // nothing pending
	}

	now := time.Now().Unix()
	_, _ = db.Get().Exec(`
		UPDATE space_inviter_prompts
		SET reply = ?, replied_at = ?
		WHERE user_id = ? AND prompt_sent_at = ?`,
		reply, now, string(user), promptAt,
	)
	p.pendingMu.Lock()
	delete(p.pending, user)
	p.pendingMu.Unlock()

	slog.Info("space_inviter: prompt_reply", "user", user, "reply", reply)

	if reply == "yes" {
		p.attemptInvite(user, promptAt)
	}
	return nil
}

func (p *SpaceInviterPlugin) findPendingPrompt(targetEvent string) (id.UserID, int64, error) {
	if targetEvent != "" {
		row := db.Get().QueryRow(`
			SELECT user_id, prompt_sent_at FROM space_inviter_prompts
			WHERE dm_event_id = ? AND reply IS NULL
			LIMIT 1`, targetEvent)
		var u string
		var t int64
		if err := row.Scan(&u, &t); err == nil {
			return id.UserID(u), t, nil
		}
	}
	// Fallback: most recent pending prompt in the admin DM.
	row := db.Get().QueryRow(`
		SELECT user_id, prompt_sent_at FROM space_inviter_prompts
		WHERE dm_room_id = ? AND reply IS NULL
		ORDER BY prompt_sent_at DESC
		LIMIT 1`, string(p.adminDMRoom))
	var u string
	var t int64
	if err := row.Scan(&u, &t); err != nil {
		return "", 0, err
	}
	return id.UserID(u), t, nil
}

func (p *SpaceInviterPlugin) attemptInvite(uid id.UserID, promptAt int64) {
	ctx := context.Background()

	// Re-validate at reply time: still Space-less, no pending invite.
	if p.hasPendingInvite(ctx, uid) {
		_, _ = db.Get().Exec(`UPDATE space_inviter_prompts SET invite_attempted=1, invite_outcome='skipped_race' WHERE user_id=? AND prompt_sent_at=?`,
			string(uid), promptAt)
		_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf("ℹ️ `%s` already has a pending invite — skipped.", uid))
		return
	}
	if _, ok := p.usersInAnySpace(ctx)[uid]; ok {
		_, _ = db.Get().Exec(`UPDATE space_inviter_prompts SET invite_attempted=1, invite_outcome='skipped_race' WHERE user_id=? AND prompt_sent_at=?`,
			string(uid), promptAt)
		_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf("ℹ️ `%s` is already in a Space — skipped.", uid))
		return
	}

	_, err := p.Client.InviteUser(ctx, p.cfg.TargetSpaceID, &mautrix.ReqInviteUser{UserID: uid})
	if err == nil {
		_, _ = db.Get().Exec(`UPDATE space_inviter_prompts SET invite_attempted=1, invite_outcome='ok' WHERE user_id=? AND prompt_sent_at=?`,
			string(uid), promptAt)
		_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf("✅ Invited `%s` to the Space.", uid))
		slog.Info("space_inviter: invite_attempt", "user", uid, "outcome", "ok")
		return
	}

	errStr := err.Error()
	outcome := "error"
	if strings.Contains(errStr, "M_FORBIDDEN") {
		outcome = "forbidden"
		_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf(
			"🚨 Tried to invite `%s` but got M_FORBIDDEN.\nMy power level may have dropped — check Space permissions.", uid))
	} else {
		_ = p.SendMessage(p.adminDMRoom, fmt.Sprintf("⚠️ Invite failed for `%s`: %s", uid, errStr))
	}
	_, _ = db.Get().Exec(`UPDATE space_inviter_prompts SET invite_attempted=1, invite_outcome=?, invite_error=? WHERE user_id=? AND prompt_sent_at=?`,
		outcome, errStr, string(uid), promptAt)
	slog.Error("space_inviter: invite_attempt", "user", uid, "outcome", outcome, "err", errStr)
}
