package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"sync/atomic"
	"time"
)

// MatrixError is a parsed Matrix CS-API error body ({errcode, error}).
type MatrixError struct {
	StatusCode   int
	ErrCode      string `json:"errcode"`
	Err          string `json:"error"`
	RetryAfterMs int    `json:"retry_after_ms"`
}

func (e *MatrixError) Error() string {
	return fmt.Sprintf("matrix %d %s: %s", e.StatusCode, e.ErrCode, e.Err)
}

// MatrixClient is a thin plaintext CS-API client that authenticates as an
// appservice: every request carries the non-expiring `as_token` plus a
// `?user_id=` identity assertion so the homeserver treats it as the bot user.
// No crypto store — Vojo rooms are unencrypted by default.
type MatrixClient struct {
	base     string
	asToken  string
	asUserID string
	http     *http.Client
	txnSeq   atomic.Uint64
}

func NewMatrixClient(base, asToken, asUserID string) *MatrixClient {
	return &MatrixClient{
		base:     base,
		asToken:  asToken,
		asUserID: asUserID,
		http:     &http.Client{Timeout: 60 * time.Second},
	}
}

func (c *MatrixClient) nextTxnID() string {
	return "aibot-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" +
		strconv.FormatUint(c.txnSeq.Add(1), 36)
}

// do issues a CS-API request as the appservice user and decodes JSON into out.
// Non-2xx responses are returned as *MatrixError.
func (c *MatrixClient) do(ctx context.Context, method, path string, query url.Values, body, out any) error {
	var reader io.Reader
	if body != nil {
		buf, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("marshal request: %w", err)
		}
		reader = bytes.NewReader(buf)
	}

	if query == nil {
		query = url.Values{}
	}
	// Appservice identity assertion: act as the bot user (spec §Identity
	// assertion). The as_token authenticates the appservice; user_id selects
	// which namespaced user we are acting as.
	query.Set("user_id", c.asUserID)

	u := c.base + path + "?" + query.Encode()
	req, err := http.NewRequestWithContext(ctx, method, u, reader)
	if err != nil {
		return err
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	req.Header.Set("Authorization", "Bearer "+c.asToken)

	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		mErr := &MatrixError{StatusCode: resp.StatusCode}
		_ = json.Unmarshal(data, mErr) // best-effort; body may not be JSON
		return mErr
	}
	if out != nil && len(data) > 0 {
		if err := json.Unmarshal(data, out); err != nil {
			return fmt.Errorf("decode response from %s: %w", path, err)
		}
	}
	return nil
}

// Whoami confirms the as_token + user_id resolves to BOT_MXID (startup check).
func (c *MatrixClient) Whoami(ctx context.Context) (string, error) {
	var out struct {
		UserID string `json:"user_id"`
	}
	if err := c.do(ctx, http.MethodGet, "/_matrix/client/v3/account/whoami", nil, nil, &out); err != nil {
		return "", err
	}
	return out.UserID, nil
}

func (c *MatrixClient) JoinRoom(ctx context.Context, roomID string) error {
	return c.do(ctx, http.MethodPost, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", nil, struct{}{}, nil)
}

func (c *MatrixClient) LeaveRoom(ctx context.Context, roomID string) error {
	return c.do(ctx, http.MethodPost, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave", nil, struct{}{}, nil)
}

// SendEvent PUTs a message event with a unique txn id and returns its event id.
func (c *MatrixClient) SendEvent(ctx context.Context, roomID, evType string, content any) (string, error) {
	path := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s",
		url.PathEscape(roomID), url.PathEscape(evType), url.PathEscape(c.nextTxnID()))
	var out struct {
		EventID string `json:"event_id"`
	}
	if err := c.do(ctx, http.MethodPut, path, nil, content, &out); err != nil {
		return "", err
	}
	return out.EventID, nil
}

// ThreadEvents returns a thread's root event plus its child events in chronological
// order, capped at `limit` newest children (the cap matches the context window — older
// turns would be trimmed anyway). Used to rehydrate a conversation buffer after a
// restart/LRU eviction: Matrix is the durable conversation store, so a cold buffer is
// rebuilt from the homeserver instead of answering a continued thread amnesiac.
func (c *MatrixClient) ThreadEvents(ctx context.Context, roomID, rootID string, limit int) (*Event, []Event, error) {
	var root Event
	rootPath := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/event/" + url.PathEscape(rootID)
	if err := c.do(ctx, http.MethodGet, rootPath, nil, nil, &root); err != nil {
		return nil, nil, err
	}
	var out struct {
		Chunk []Event `json:"chunk"`
	}
	relPath := "/_matrix/client/v1/rooms/" + url.PathEscape(roomID) + "/relations/" + url.PathEscape(rootID) + "/m.thread"
	q := url.Values{"dir": {"b"}, "limit": {strconv.Itoa(limit)}}
	if err := c.do(ctx, http.MethodGet, relPath, q, nil, &out); err != nil {
		return nil, nil, err
	}
	// dir=b returns newest-first; reverse to chronological for the buffer.
	for i, j := 0, len(out.Chunk)-1; i < j; i, j = i+1, j-1 {
		out.Chunk[i], out.Chunk[j] = out.Chunk[j], out.Chunk[i]
	}
	return &root, out.Chunk, nil
}

// SetDisplayName sets the bot user's profile display name (F23). Idempotent.
func (c *MatrixClient) SetDisplayName(ctx context.Context, name string) error {
	path := "/_matrix/client/v3/profile/" + url.PathEscape(c.asUserID) + "/displayname"
	return c.do(ctx, http.MethodPut, path, nil, map[string]any{"displayname": name}, nil)
}

// SendTyping sets or clears the bot user's typing indicator in a room. The
// homeserver broadcasts m.typing, which clients render as "… is typing"; the
// timeout (ms) applies only when starting and is omitted when clearing.
func (c *MatrixClient) SendTyping(ctx context.Context, roomID string, typing bool, timeoutMs int) error {
	path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/typing/" + url.PathEscape(c.asUserID)
	body := map[string]any{"typing": typing}
	if typing {
		body["timeout"] = timeoutMs
	}
	return c.do(ctx, http.MethodPut, path, nil, body, nil)
}

// RoomEncrypted checks live encryption state (F15 — never a join-time snapshot).
// A 404/M_NOT_FOUND means no m.room.encryption state → unencrypted.
func (c *MatrixClient) RoomEncrypted(ctx context.Context, roomID string) (bool, error) {
	path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/state/m.room.encryption/"
	err := c.do(ctx, http.MethodGet, path, nil, nil, &struct{}{})
	if err == nil {
		return true, nil
	}
	if mErr, ok := err.(*MatrixError); ok && (mErr.StatusCode == http.StatusNotFound || mErr.ErrCode == "M_NOT_FOUND") {
		return false, nil
	}
	return false, err
}

// RoomMembership returns joined+invited counts and the set of homeservers that
// have a member present (joined or invited). Used both to classify a room as a
// 1:1 (F3) and to enforce that the bot only stays in rooms hosted entirely on
// allowed servers — appservice transactions carry no room summary, so this reads
// /members. The member is identified by the event's state_key (the sender is
// whoever *set* the membership, which may differ).
func (c *MatrixClient) RoomMembership(ctx context.Context, roomID string) (joined, invited int, servers map[string]bool, err error) {
	path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/members"
	var out struct {
		Chunk []Event `json:"chunk"`
	}
	if err = c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil {
		return 0, 0, nil, err
	}
	servers = make(map[string]bool)
	for i := range out.Chunk {
		e := &out.Chunk[i]
		if e.StateKey == nil {
			continue
		}
		switch e.membershipOf() {
		case "join":
			joined++
			servers[serverOf(*e.StateKey)] = true
		case "invite":
			invited++
			servers[serverOf(*e.StateKey)] = true
		}
	}
	return joined, invited, servers, nil
}
