package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"html"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

type Config struct {
	Port             string
	PublicURL        string
	MCPKey           string
	BasecampClientID string
	BasecampSecret   string
	BasecampRedirect string
	UserAgent        string
	TokenFile        string
}

type TokenState struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	ExpiresAt    int64  `json:"expires_at"`
	AccountID    int64  `json:"account_id"`
	AccountName  string `json:"account_name"`
}

type App struct {
	cfg        Config
	mu         sync.RWMutex
	tok        TokenState
	oauthState string
	http       *http.Client
}

type RPCRequest struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      json.RawMessage `json:"id,omitempty"`
	Method  string          `json:"method"`
	Params  json.RawMessage `json:"params,omitempty"`
}

type RPCResponse struct {
	JSONRPC string      `json:"jsonrpc"`
	ID      interface{} `json:"id,omitempty"`
	Result  interface{} `json:"result,omitempty"`
	Error   *RPCError   `json:"error,omitempty"`
}

type RPCError struct {
	Code    int         `json:"code"`
	Message string      `json:"message"`
	Data    interface{} `json:"data,omitempty"`
}

type Tool struct {
	Name        string                 `json:"name"`
	Description string                 `json:"description"`
	InputSchema map[string]interface{} `json:"inputSchema"`
	Annotations map[string]interface{} `json:"annotations,omitempty"`
}

type ToolCallParams struct {
	Name      string                 `json:"name"`
	Arguments map[string]interface{} `json:"arguments"`
}

type TextContent struct {
	Type string `json:"type"`
	Text string `json:"text"`
}

type Project struct {
	ID          int64      `json:"id"`
	Name        string     `json:"name"`
	Description string     `json:"description"`
	Status      string     `json:"status"`
	AppURL      string     `json:"app_url"`
	CreatedAt   string     `json:"created_at"`
	UpdatedAt   string     `json:"updated_at"`
	Dock        []DockItem `json:"dock"`
}

type DockItem struct {
	Name    string `json:"name"`
	ID      int64  `json:"id"`
	URL     string `json:"url"`
	AppURL  string `json:"app_url"`
	Enabled bool   `json:"enabled"`
}

type TodoList struct {
	ID          int64  `json:"id"`
	Name        string `json:"name"`
        Title       string `json:"title"`
	Description string `json:"description"`
	URL         string `json:"url"`
        TodosURL    string `json:"todos_url"`
	AppURL      string `json:"app_url"`
	Bucket      struct {
		ID   int64  `json:"id"`
		Name string `json:"name"`
	} `json:"bucket"`
}

type Todo struct {
	ID          int64  `json:"id"`
	Content     string `json:"content"`
	Description string `json:"description"`
	Completed   bool   `json:"completed"`
	DueOn       string `json:"due_on"`
	StartsOn    string `json:"starts_on"`
	AppURL      string `json:"app_url"`
	CreatedAt   string `json:"created_at"`
	UpdatedAt   string `json:"updated_at"`
	Assignees   []struct {
		Name  string `json:"name"`
		Email string `json:"email_address"`
	} `json:"assignees"`
	Bucket struct {
		ID   int64  `json:"id"`
		Name string `json:"name"`
	} `json:"bucket"`
	Parent struct {
		ID    int64  `json:"id"`
		Title string `json:"title"`
		URL   string `json:"url"`
	} `json:"parent"`
}

type Message struct {
	ID        int64  `json:"id"`
	Subject   string `json:"subject"`
	Content   string `json:"content"`
	AppURL    string `json:"app_url"`
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
	Creator   struct {
		Name string `json:"name"`
	} `json:"creator"`
	Bucket struct {
		ID   int64  `json:"id"`
		Name string `json:"name"`
	} `json:"bucket"`
}

type Event struct {
        ID             int64  `json:"id"`
        Kind           string `json:"kind"`
        Action         string `json:"action"`
        Target         string `json:"target"`
        Title          string `json:"title"`
        SummaryExcerpt string `json:"summary_excerpt"`
        CreatedAt      string `json:"created_at"`
        AppURL         string `json:"app_url"`
        Creator        struct {
                Name string `json:"name"`
        } `json:"creator"`
        Bucket struct {
                ID   int64  `json:"id"`
                Name string `json:"name"`
        } `json:"bucket"`
}

func main() {
	cfg := Config{
		Port:             env("PORT", "3100"),
		PublicURL:        strings.TrimRight(env("PUBLIC_URL", "http://127.0.0.1:3100"), "/"),
		MCPKey:           os.Getenv("MCP_API_KEY"),
		BasecampClientID: os.Getenv("BASECAMP_CLIENT_ID"),
		BasecampSecret:   os.Getenv("BASECAMP_CLIENT_SECRET"),
		BasecampRedirect: os.Getenv("BASECAMP_REDIRECT_URI"),
		UserAgent:        env("BASECAMP_USER_AGENT", "Agency Client Brain (admin@example.com)"),
		TokenFile:        env("TOKEN_FILE", "./data/basecamp-token.json"),
	}
	if cfg.BasecampRedirect == "" {
		cfg.BasecampRedirect = cfg.PublicURL + "/oauth/callback"
	}
	app := &App{cfg: cfg, http: &http.Client{Timeout: 30 * time.Second}}
	_ = app.loadToken()

	mux := http.NewServeMux()
	mux.HandleFunc("/health", app.health)
	mux.HandleFunc("/oauth/start", app.oauthStart)
	mux.HandleFunc("/oauth/callback", app.oauthCallback)
	mux.HandleFunc("/mcp", app.mcp)

	srv := &http.Server{Addr: "127.0.0.1:" + cfg.Port, Handler: logRequests(mux), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second}
	log.Printf("Basecamp read-only MCP listening on %s", srv.Addr)
	log.Printf("Health: %s/health", cfg.PublicURL)
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

func env(k, v string) string {
	if x := os.Getenv(k); x != "" {
		return x
	}
	return v
}
func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
	})
}

func (a *App) health(w http.ResponseWriter, r *http.Request) {
	a.mu.RLock()
	connected := a.tok.AccessToken != "" && a.tok.AccountID != 0
	acct := a.tok.AccountName
	a.mu.RUnlock()
	writeJSON(w, 200, map[string]interface{}{"ok": true, "service": "basecamp-mcp-readonly", "connected": connected, "account": acct, "time": time.Now().UTC().Format(time.RFC3339)})
}

func (a *App) authMCP(r *http.Request) bool {
	if a.cfg.MCPKey == "" {
		return true
	}
	q := r.URL.Query().Get("key")
	h := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
	return q == a.cfg.MCPKey || h == a.cfg.MCPKey
}

func (a *App) mcp(w http.ResponseWriter, r *http.Request) {
	if !a.authMCP(r) {
		writeJSON(w, 401, map[string]string{"error": "unauthorized"})
		return
	}
	if r.Method == http.MethodGet {
		w.Header().Set("Allow", "POST")
		writeJSON(w, 200, map[string]interface{}{"name": "basecamp-readonly", "transport": "streamable-http", "endpoint": "/mcp"})
		return
	}
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	defer r.Body.Close()
	body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
	if err != nil {
		writeRPCError(w, nil, -32700, "read error", err.Error())
		return
	}
	var req RPCRequest
	if err := json.Unmarshal(body, &req); err != nil {
		writeRPCError(w, nil, -32700, "parse error", err.Error())
		return
	}
	var id interface{}
	if len(req.ID) > 0 {
		_ = json.Unmarshal(req.ID, &id)
	}
	switch req.Method {
	case "initialize":
		writeRPC(w, id, map[string]interface{}{"protocolVersion": "2025-06-18", "capabilities": map[string]interface{}{"tools": map[string]interface{}{"listChanged": false}}, "serverInfo": map[string]interface{}{"name": "basecamp-readonly", "version": "1.0.0"}})
	case "notifications/initialized":
		w.WriteHeader(http.StatusAccepted)
	case "ping":
		writeRPC(w, id, map[string]interface{}{})
	case "tools/list":
		writeRPC(w, id, map[string]interface{}{"tools": tools()})
	case "tools/call":
		var p ToolCallParams
		if err := json.Unmarshal(req.Params, &p); err != nil {
			writeRPCError(w, id, -32602, "invalid params", err.Error())
			return
		}
		text, err := a.callTool(r.Context(), p.Name, p.Arguments)
		if err != nil {
			writeRPC(w, id, map[string]interface{}{"content": []TextContent{{Type: "text", Text: "Error: " + err.Error()}}, "isError": true})
			return
		}
		writeRPC(w, id, map[string]interface{}{"content": []TextContent{{Type: "text", Text: text}}, "isError": false})
	default:
		writeRPCError(w, id, -32601, "method not found", req.Method)
	}
}

func tools() []Tool {
	ro := map[string]interface{}{"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}
	obj := func(props map[string]interface{}, req []string) map[string]interface{} {
		m := map[string]interface{}{"type": "object", "properties": props, "additionalProperties": false}
		if len(req) > 0 {
			m["required"] = req
		}
		return m
	}
	return []Tool{
		{"basecamp_list_projects", "List visible Basecamp projects. Read-only.", obj(map[string]interface{}{"status": map[string]interface{}{"type": "string", "enum": []string{"active", "archived", "trashed"}, "default": "active"}, "limit": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": 100, "default": 50}}, nil), ro},
		{"basecamp_find_project", "Find Basecamp projects by partial name. Read-only.", obj(map[string]interface{}{"query": map[string]interface{}{"type": "string", "description": "Partial project name"}}, []string{"query"}), ro},
		{"basecamp_get_open_todos", "Get incomplete to-dos for a project, including assignees and due dates. Read-only.", obj(map[string]interface{}{"project_id": map[string]interface{}{"type": "integer"}, "limit": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": 200, "default": 100}}, []string{"project_id"}), ro},
		{"basecamp_get_overdue_todos", "Get incomplete project to-dos whose due date is before today. Read-only.", obj(map[string]interface{}{"project_id": map[string]interface{}{"type": "integer"}}, []string{"project_id"}), ro},
		{"basecamp_get_recent_messages", "Get recent message-board posts for a project. Read-only.", obj(map[string]interface{}{"project_id": map[string]interface{}{"type": "integer"}, "limit": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": 50, "default": 10}}, []string{"project_id"}), ro},
		{"basecamp_get_recent_activity", "Get recent activity events for a project. Read-only.", obj(map[string]interface{}{"project_id": map[string]interface{}{"type": "integer"}, "days": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": 90, "default": 7}, "limit": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": 100, "default": 50}}, []string{"project_id"}), ro},
		{"basecamp_project_status", "Produce a compact live status snapshot: open/overdue to-dos, recent messages, and recent activity. Read-only.", obj(map[string]interface{}{"project_id": map[string]interface{}{"type": "integer"}}, []string{"project_id"}), ro},
	}
}

func (a *App) callTool(ctx context.Context, name string, args map[string]interface{}) (string, error) {
	switch name {
	case "basecamp_list_projects":
		return a.toolListProjects(ctx, strArg(args, "status", "active"), intArg(args, "limit", 50))
	case "basecamp_find_project":
		return a.toolFindProject(ctx, strArg(args, "query", ""))
	case "basecamp_get_open_todos":
		return a.toolTodos(ctx, int64Arg(args, "project_id", 0), false, intArg(args, "limit", 100))
	case "basecamp_get_overdue_todos":
		return a.toolTodos(ctx, int64Arg(args, "project_id", 0), true, 200)
	case "basecamp_get_recent_messages":
		return a.toolMessages(ctx, int64Arg(args, "project_id", 0), intArg(args, "limit", 10))
	case "basecamp_get_recent_activity":
		return a.toolActivity(ctx, int64Arg(args, "project_id", 0), intArg(args, "days", 7), intArg(args, "limit", 50))
	case "basecamp_project_status":
		return a.toolStatus(ctx, int64Arg(args, "project_id", 0))
	default:
		return "", fmt.Errorf("unknown tool %q", name)
	}
}

func strArg(m map[string]interface{}, k, d string) string {
	if v, ok := m[k].(string); ok {
		return v
	}
	return d
}
func intArg(m map[string]interface{}, k string, d int) int {
	if v, ok := m[k].(float64); ok {
		return int(v)
	}
	if v, ok := m[k].(int); ok {
		return v
	}
	return d
}
func int64Arg(m map[string]interface{}, k string, d int64) int64 { return int64(intArg(m, k, int(d))) }

func (a *App) toolListProjects(ctx context.Context, status string, limit int) (string, error) {
	var ps []Project
	path := "/projects.json"
	if err := a.getAll(ctx, path, &ps, 5); err != nil {
		return "", err
	}

	if status != "" {
		filtered := make([]Project, 0, len(ps))
		for _, p := range ps {
			if p.Status == status {
				filtered = append(filtered, p)
			}
		}
		ps = filtered
	}

	if len(ps) > limit {
		ps = ps[:limit]
	}
	var b strings.Builder
	fmt.Fprintf(&b, "Projects (%d):\n", len(ps))
	for _, p := range ps {
		fmt.Fprintf(&b, "- %s [ID %d] — %s\n  %s\n", p.Name, p.ID, p.Status, p.AppURL)
	}
	return b.String(), nil
}
func (a *App) toolFindProject(ctx context.Context, q string) (string, error) {
	if strings.TrimSpace(q) == "" {
		return "", errors.New("query is required")
	}
	var ps []Project
	if err := a.getAll(ctx, "/projects.json", &ps, 5); err != nil {
		return "", err
	}
	q = strings.ToLower(q)
	var hits []Project
	for _, p := range ps {
		if strings.Contains(strings.ToLower(p.Name), q) {
			hits = append(hits, p)
		}
	}
	if len(hits) == 0 {
		return "No matching projects found.", nil
	}
	var b strings.Builder
	for _, p := range hits {
		fmt.Fprintf(&b, "- %s [ID %d]\n  %s\n", p.Name, p.ID, p.AppURL)
	}
	return b.String(), nil
}

func (a *App) getProject(ctx context.Context, id int64) (Project, error) {
	var p Project
	if id == 0 {
		return p, errors.New("project_id is required")
	}
	err := a.getJSON(ctx, fmt.Sprintf("/projects/%d.json", id), &p)
	return p, err
}
func dockURL(p Project, name string) string {
	for _, d := range p.Dock {
		if d.Name == name && d.Enabled {
			return d.URL
		}
	}
	return ""
}

func (a *App) collectTodos(ctx context.Context, p Project) ([]Todo, error) {
	u := dockURL(p, "todoset")
	if u == "" {
		return nil, nil
	}
	var set struct {
		TodolistsURL string `json:"todolists_url"`
	}
	if err := a.getAbsolute(ctx, u, &set); err != nil {
		return nil, err
	}
	listURL := set.TodolistsURL
	if listURL == "" {
		listURL = fmt.Sprintf("https://3.basecampapi.com/%d/buckets/%d/todosets/%d/todolists.json", a.accountID(), p.ID, findDockID(p, "todoset"))
	}
	var lists []TodoList
	if err := a.getAllAbsolute(ctx, listURL, &lists, 10); err != nil {
		return nil, err
	}
	var out []Todo
	for _, l := range lists {
		var todos []Todo
		path := l.TodosURL
                if path == "" {
                        path = fmt.Sprintf("https://3.basecampapi.com/%d/buckets/%d/todolists/%d/todos.json", a.accountID(), p.ID, l.ID)
                }
		if err := a.getAllAbsolute(ctx, path, &todos, 10); err != nil {
			continue
		}
		out = append(out, todos...)
	}
	return out, nil
}
func findDockID(p Project, name string) int64 {
	for _, d := range p.Dock {
		if d.Name == name {
			return d.ID
		}
	}
	return 0
}

func (a *App) toolTodos(ctx context.Context, pid int64, overdue bool, limit int) (string, error) {
	p, err := a.getProject(ctx, pid)
	if err != nil {
		return "", err
	}
	todos, err := a.collectTodos(ctx, p)
	if err != nil {
		return "", err
	}
	today := time.Now().Format("2006-01-02")
	var keep []Todo
	for _, t := range todos {
		if t.Completed {
			continue
		}
		if overdue {
			if t.DueOn == "" || t.DueOn >= today {
				continue
			}
		}
		keep = append(keep, t)
	}
	sort.Slice(keep, func(i, j int) bool { return keep[i].DueOn < keep[j].DueOn })
	if len(keep) > limit {
		keep = keep[:limit]
	}
	var b strings.Builder
	fmt.Fprintf(&b, "%s — %d %s to-dos\n", p.Name, len(keep), map[bool]string{true: "overdue", false: "open"}[overdue])
	for _, t := range keep {
		ass := []string{}
		for _, x := range t.Assignees {
			ass = append(ass, x.Name)
		}
		due := t.DueOn
		if due == "" {
			due = "no due date"
		}
		fmt.Fprintf(&b, "- %s — due %s", stripHTML(t.Content), due)
		if len(ass) > 0 {
			fmt.Fprintf(&b, " — %s", strings.Join(ass, ", "))
		}
		fmt.Fprintf(&b, "\n  %s\n", t.AppURL)
	}
	if len(keep) == 0 {
		b.WriteString("No matching to-dos found.\n")
	}
	return b.String(), nil
}

func (a *App) toolMessages(ctx context.Context, pid int64, limit int) (string, error) {
	p, err := a.getProject(ctx, pid)
	if err != nil {
		return "", err
	}
	u := dockURL(p, "message_board")
	if u == "" {
		return "No message board enabled for this project.", nil
	}
	var board struct {
		MessagesURL string `json:"messages_url"`
	}
	if err := a.getAbsolute(ctx, u, &board); err != nil {
		return "", err
	}
	mu := board.MessagesURL
	if mu == "" {
		mu = fmt.Sprintf("https://3.basecampapi.com/%d/buckets/%d/message_boards/%d/messages.json", a.accountID(), p.ID, findDockID(p, "message_board"))
	}
	var msgs []Message
	if err := a.getAllAbsolute(ctx, mu, &msgs, 5); err != nil {
		return "", err
	}
	sort.Slice(msgs, func(i, j int) bool { return msgs[i].CreatedAt > msgs[j].CreatedAt })
	if len(msgs) > limit {
		msgs = msgs[:limit]
	}
	var b strings.Builder
	fmt.Fprintf(&b, "%s — recent messages\n", p.Name)
	for _, m := range msgs {
		fmt.Fprintf(&b, "- %s — %s — %s\n  %s\n", m.Subject, m.Creator.Name, shortDate(m.CreatedAt), m.AppURL)
	}
	if len(msgs) == 0 {
		b.WriteString("No messages found.\n")
	}
	return b.String(), nil
}

func (a *App) toolActivity(ctx context.Context, pid int64, days, limit int) (string, error) {
	p, err := a.getProject(ctx, pid)
	if err != nil {
		return "", err
	}
	path := fmt.Sprintf("/projects/%d/timeline.json", pid)
	var ev []Event
	if err := a.getAll(ctx, path, &ev, 5); err != nil {
		return "", err
	}

        cutoff := time.Now().AddDate(0, 0, -days)
        filtered := make([]Event, 0, len(ev))
        for _, e := range ev {
                created, err := time.Parse(time.RFC3339, e.CreatedAt)
                if err == nil && created.Before(cutoff) {
                        continue
                }
                filtered = append(filtered, e)
        }
        ev = filtered

        if len(ev) > limit {
		ev = ev[:limit]
	}
	var b strings.Builder
	fmt.Fprintf(&b, "%s — activity from last %d days\n", p.Name, days)
	for _, e := range ev {
		title := e.Title
                if title == "" {
                        title = e.SummaryExcerpt
                }
                if title == "" {
                        title = e.Action
                }
                if title == "" {
                        title = e.Target
                }
                if title == "" {
                        title = e.Kind
                }
                if title == "" {
                        title = "Activity update"
                }
		fmt.Fprintf(&b, "- %s — %s — %s\n", stripHTML(title), e.Creator.Name, shortDate(e.CreatedAt))
                if e.SummaryExcerpt != "" && stripHTML(e.SummaryExcerpt) != stripHTML(title) {
                        fmt.Fprintf(&b, "  Comment/details: %s\n", stripHTML(e.SummaryExcerpt))
                }
                if e.AppURL != "" {
			fmt.Fprintf(&b, "  %s\n", e.AppURL)
		}
	}
	if len(ev) == 0 {
		b.WriteString("No recent activity found.\n")
	}
	return b.String(), nil
}

func (a *App) toolStatus(ctx context.Context, pid int64) (string, error) {
	p, err := a.getProject(ctx, pid)
	if err != nil {
		return "", err
	}
	open, _ := a.toolTodos(ctx, pid, false, 25)
	over, _ := a.toolTodos(ctx, pid, true, 25)
	msgs, _ := a.toolMessages(ctx, pid, 5)
	act, _ := a.toolActivity(ctx, pid, 7, 15)
	return fmt.Sprintf("LIVE BASECAMP STATUS\nProject: %s [ID %d]\n%s\n\n%s\n\n%s\n\n%s", p.Name, p.ID, open, over, msgs, act), nil
}

func shortDate(s string) string {
	if t, err := time.Parse(time.RFC3339, s); err == nil {
		return t.Format("2006-01-02")
	}
	return s
}
func stripHTML(s string) string {
	s = html.UnescapeString(s)
	var b strings.Builder
	in := false
	for _, r := range s {
		if r == '<' {
			in = true
			continue
		}
		if r == '>' {
			in = false
			continue
		}
		if !in {
			b.WriteRune(r)
		}
	}
	return strings.Join(strings.Fields(b.String()), " ")
}

func (a *App) accountID() int64 { a.mu.RLock(); defer a.mu.RUnlock(); return a.tok.AccountID }
func (a *App) getJSON(ctx context.Context, path string, out interface{}) error {
	return a.getAbsolute(ctx, fmt.Sprintf("https://3.basecampapi.com/%d%s", a.accountID(), path), out)
}
func (a *App) getAbsolute(ctx context.Context, u string, out interface{}) error {
	tok, err := a.validToken(ctx)
	if err != nil {
		return err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+tok)
	req.Header.Set("User-Agent", a.cfg.UserAgent)
	req.Header.Set("Accept", "application/json")
	resp, err := a.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
		return fmt.Errorf("Basecamp %s: %s", resp.Status, string(b))
	}
	return json.NewDecoder(resp.Body).Decode(out)
}
func (a *App) getAll(ctx context.Context, path string, out interface{}, max int) error {
	return a.getAllAbsolute(ctx, fmt.Sprintf("https://3.basecampapi.com/%d%s", a.accountID(), path), out, max)
}
func (a *App) getAllAbsolute(ctx context.Context, u string, out interface{}, max int) error {
	// Generic pagination by accumulating JSON arrays.
	var all []json.RawMessage
	for page := 0; u != "" && page < max; page++ {
		tok, err := a.validToken(ctx)
		if err != nil {
			return err
		}
		req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
		req.Header.Set("Authorization", "Bearer "+tok)
		req.Header.Set("User-Agent", a.cfg.UserAgent)
		req.Header.Set("Accept", "application/json")
		resp, err := a.http.Do(req)
		if err != nil {
			return err
		}
		body, er := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
		resp.Body.Close()
		if er != nil {
			return er
		}
		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
			return fmt.Errorf("Basecamp %s: %s", resp.Status, string(body))
		}
		var arr []json.RawMessage
		if err := json.Unmarshal(body, &arr); err != nil {
			return err
		}
		all = append(all, arr...)
		u = parseNext(resp.Header.Get("Link"))
	}
	data, _ := json.Marshal(all)
	return json.Unmarshal(data, out)
}
func parseNext(link string) string {
	for _, p := range strings.Split(link, ",") {
		if strings.Contains(p, "rel=\"next\"") {
			l := strings.Index(p, "<")
			r := strings.Index(p, ">")
			if l >= 0 && r > l {
				return strings.TrimSpace(p[l+1 : r])
			}
		}
	}
	return ""
}

func (a *App) validToken(ctx context.Context) (string, error) {
	a.mu.RLock()
	tok := a.tok
	a.mu.RUnlock()
	if tok.AccessToken == "" {
		return "", errors.New("Basecamp is not connected; visit /oauth/start")
	}
	if tok.ExpiresAt == 0 || time.Now().Unix() < tok.ExpiresAt-300 {
		return tok.AccessToken, nil
	}
	if tok.RefreshToken == "" {
		return "", errors.New("Basecamp token expired and no refresh token is available")
	}
	vals := url.Values{"type": {"refresh"}, "client_id": {a.cfg.BasecampClientID}, "client_secret": {a.cfg.BasecampSecret}, "refresh_token": {tok.RefreshToken}}
	req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://launchpad.37signals.com/authorization/token", strings.NewReader(vals.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := a.http.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		b, _ := io.ReadAll(resp.Body)
		return "", fmt.Errorf("refresh failed: %s", b)
	}
	var x struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		ExpiresIn    int64  `json:"expires_in"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&x); err != nil {
		return "", err
	}
	tok.AccessToken = x.AccessToken
	if x.RefreshToken != "" {
		tok.RefreshToken = x.RefreshToken
	}
	if x.ExpiresIn > 0 {
		tok.ExpiresAt = time.Now().Unix() + x.ExpiresIn
	}
	a.setToken(tok)
	return tok.AccessToken, nil
}

func (a *App) oauthStart(w http.ResponseWriter, r *http.Request) {
	if a.cfg.BasecampClientID == "" || a.cfg.BasecampSecret == "" {
		http.Error(w, "BASECAMP_CLIENT_ID and BASECAMP_CLIENT_SECRET are required", 500)
		return
	}
	st := randomHex(24)
	a.mu.Lock()
	a.oauthState = st
	a.mu.Unlock()
	q := url.Values{"type": {"web_server"}, "client_id": {a.cfg.BasecampClientID}, "redirect_uri": {a.cfg.BasecampRedirect}, "state": {st}}
	http.Redirect(w, r, "https://launchpad.37signals.com/authorization/new?"+q.Encode(), http.StatusFound)
}
func (a *App) oauthCallback(w http.ResponseWriter, r *http.Request) {
	a.mu.RLock()
	expected := a.oauthState
	a.mu.RUnlock()
	if expected != "" && r.URL.Query().Get("state") != expected {
		http.Error(w, "OAuth state mismatch", 400)
		return
	}
	code := r.URL.Query().Get("code")
	if code == "" {
		http.Error(w, "Missing OAuth code", 400)
		return
	}
	vals := url.Values{"type": {"web_server"}, "client_id": {a.cfg.BasecampClientID}, "client_secret": {a.cfg.BasecampSecret}, "redirect_uri": {a.cfg.BasecampRedirect}, "code": {code}}
	req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://launchpad.37signals.com/authorization/token", strings.NewReader(vals.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := a.http.Do(req)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		b, _ := io.ReadAll(resp.Body)
		http.Error(w, string(b), 500)
		return
	}
	var x struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		ExpiresIn    int64  `json:"expires_in"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&x); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	areq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://launchpad.37signals.com/authorization.json", nil)
	areq.Header.Set("Authorization", "Bearer "+x.AccessToken)
	areq.Header.Set("User-Agent", a.cfg.UserAgent)
	ar, err := a.http.Do(areq)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	defer ar.Body.Close()
	var auth struct {
		Accounts []struct {
			ID      int64  `json:"id"`
			Name    string `json:"name"`
			Product string `json:"product"`
			Href    string `json:"href"`
		} `json:"accounts"`
	}
	if err := json.NewDecoder(ar.Body).Decode(&auth); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	var acctID int64
	var acctName string
	for _, ac := range auth.Accounts {
		if strings.Contains(strings.ToLower(ac.Product), "bc3") || strings.Contains(strings.ToLower(ac.Href), "basecampapi") {
			acctID = ac.ID
			acctName = ac.Name
			break
		}
	}
	if acctID == 0 && len(auth.Accounts) > 0 {
		acctID = auth.Accounts[0].ID
		acctName = auth.Accounts[0].Name
	}
	if acctID == 0 {
		http.Error(w, "No Basecamp account found", 500)
		return
	}
	exp := int64(0)
	if x.ExpiresIn > 0 {
		exp = time.Now().Unix() + x.ExpiresIn
	}
	a.setToken(TokenState{AccessToken: x.AccessToken, RefreshToken: x.RefreshToken, ExpiresAt: exp, AccountID: acctID, AccountName: acctName})
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprintf(w, "<h1>Basecamp connected</h1><p>Account: %s (%d)</p><p>You can close this tab.</p>", html.EscapeString(acctName), acctID)
}
func randomHex(n int) string         { b := make([]byte, n); _, _ = rand.Read(b); return hex.EncodeToString(b) }
func (a *App) setToken(t TokenState) { a.mu.Lock(); a.tok = t; a.mu.Unlock(); _ = a.saveToken() }
func (a *App) loadToken() error {
	b, err := os.ReadFile(a.cfg.TokenFile)
	if err != nil {
		return err
	}
	a.mu.Lock()
	defer a.mu.Unlock()
	return json.Unmarshal(b, &a.tok)
}
func (a *App) saveToken() error {
	a.mu.RLock()
	b, err := json.MarshalIndent(a.tok, "", "  ")
	a.mu.RUnlock()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(a.cfg.TokenFile), 0700); err != nil {
		return err
	}
	tmp := a.cfg.TokenFile + ".tmp"
	if err := os.WriteFile(tmp, b, 0600); err != nil {
		return err
	}
	return os.Rename(tmp, a.cfg.TokenFile)
}

func writeJSON(w http.ResponseWriter, status int, v interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}
func writeRPC(w http.ResponseWriter, id interface{}, result interface{}) {
	writeJSON(w, 200, RPCResponse{JSONRPC: "2.0", ID: id, Result: result})
}
func writeRPCError(w http.ResponseWriter, id interface{}, code int, msg string, data interface{}) {
	writeJSON(w, 200, RPCResponse{JSONRPC: "2.0", ID: id, Error: &RPCError{Code: code, Message: msg, Data: data}})
}

// compile-time guard for strconv import in older Go toolchains
var _ = strconv.IntSize
