// Package brokerclient is cdrop's thin client for the Auth Broker's HTTP API
// (path A). cdrop delegates session minting, revocation, and refresh to the broker
// rather than self-signing; this package wraps the three calls cdrop makes.
//
// Every call goes DIRECTLY to the broker's internal network address (e.g.
// http://broker-internal-ip:8080), never through the public reverse proxy — the broker's
// /internal/* endpoints 404 any request carrying X-Forwarded-For, which a request
// relayed through the edge always has. The Go HTTP client never sets that header, so
// a direct dial passes the guard; the shared X-Internal-Key is the second factor.
package brokerclient

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"
)

// ErrNotFound is returned for a broker 404. Revocation maps it to success
// (idempotent: an unknown / already-gone / not-ours sid needs no action).
var ErrNotFound = errors.New("brokerclient: not found")

// Client talks to one Auth Broker instance on behalf of one app.
type Client struct {
	baseURL     string
	internalKey string
	app         string
	httpClient  *http.Client
}

// New builds a Client. baseURL is the broker's internal origin; internalKey is the
// shared secret for /internal/*; app is this app's key in the broker apps registry.
func New(baseURL, internalKey, app string) *Client {
	return &Client{
		baseURL:     strings.TrimRight(baseURL, "/"),
		internalKey: internalKey,
		app:         app,
		httpClient:  &http.Client{Timeout: 10 * time.Second},
	}
}

// MintParams is one delegated-session request. The app vouches for UserID (the
// subject the broker just verified for the approver). Tier is appended to the app
// scope (app:<app>:<tier>); Meta is opaque correlation data (cdrop's device_id) the
// broker echoes back on /verify as X-Auth-Meta.
type MintParams struct {
	UserID     string
	Tier       string
	AccessTTL  int
	RefreshTTL int
	Sliding    bool
	Label      string
	Meta       string
}

// Session is a freshly minted delegated session. SID is the broker's session id —
// cdrop stores it privately to revoke later; it never travels in a /verify header.
type Session struct {
	SID            string
	Access         string
	Refresh        string
	AccessExpires  int64
	RefreshExpires int64
}

type mintReqWire struct {
	UserID     string `json:"user_id"`
	App        string `json:"app"`
	Tier       string `json:"tier,omitempty"`
	AccessTTL  int    `json:"access_ttl,omitempty"`
	RefreshTTL int    `json:"refresh_ttl,omitempty"`
	Sliding    bool   `json:"sliding,omitempty"`
	Label      string `json:"label,omitempty"`
	Meta       string `json:"meta,omitempty"`
}

type sessionWire struct {
	ID             string `json:"id"`
	Access         string `json:"access"`
	Refresh        string `json:"refresh"`
	AccessExpires  int64  `json:"access_expires"`
	RefreshExpires int64  `json:"refresh_expires"`
}

// MintSession asks the broker to mint a session for the vouched user (POST
// /internal/sessions). The plaintext access + refresh come back exactly once.
func (c *Client) MintSession(ctx context.Context, p MintParams) (Session, error) {
	body := mintReqWire{
		UserID: p.UserID, App: c.app, Tier: p.Tier,
		AccessTTL: p.AccessTTL, RefreshTTL: p.RefreshTTL, Sliding: p.Sliding,
		Label: p.Label, Meta: p.Meta,
	}
	headers := map[string]string{"X-Internal-Key": c.internalKey}
	var out sessionWire
	if err := c.do(ctx, http.MethodPost, "/internal/sessions", headers, body, http.StatusOK, &out); err != nil {
		return Session{}, err
	}
	return Session{
		SID: out.ID, Access: out.Access, Refresh: out.Refresh,
		AccessExpires: out.AccessExpires, RefreshExpires: out.RefreshExpires,
	}, nil
}

// RevokeSession revokes a delegated session by sid (DELETE /internal/sessions/{sid}).
// X-Broker-App scopes the revoke to this app: the broker confirms the session belongs
// to cdrop before tearing it down (a mismatch 404s). A 404 is treated as success —
// revocation is idempotent.
func (c *Client) RevokeSession(ctx context.Context, sid string) error {
	headers := map[string]string{
		"X-Internal-Key": c.internalKey,
		"X-Broker-App":   c.app,
	}
	err := c.do(ctx, http.MethodDelete, "/internal/sessions/"+url.PathEscape(sid), headers, nil, http.StatusNoContent, nil)
	if errors.Is(err, ErrNotFound) {
		return nil
	}
	return err
}

// Refreshed is the result of rolling a session's access token. The broker rotates
// the refresh credential, so the old one is now dead and the new one must be stored.
type Refreshed struct {
	Access         string
	Refresh        string
	AccessExpires  int64
	RefreshExpires int64
}

// RefreshSession exchanges a refresh credential for a fresh access token and a
// rotated refresh credential (POST /refresh — a public broker endpoint, no internal
// key). ErrNotFound is not used here; an invalid/expired/rotated credential is a 401.
func (c *Client) RefreshSession(ctx context.Context, refresh string) (Refreshed, error) {
	body := map[string]string{"refresh": refresh}
	var out sessionWire
	if err := c.do(ctx, http.MethodPost, "/refresh", nil, body, http.StatusOK, &out); err != nil {
		return Refreshed{}, err
	}
	return Refreshed{
		Access: out.Access, Refresh: out.Refresh,
		AccessExpires: out.AccessExpires, RefreshExpires: out.RefreshExpires,
	}, nil
}

// SessionInfo is one of the user's delegated device sessions, as returned by the broker's
// R1 listing (GET /internal/sessions?user_id=&app=). After the unified-session-model
// rework this IS the authoritative device list: cdrop overlays type (local cache), online
// (hub presence), and current (meta == this request's X-Auth-Meta) on top of it, and never
// keeps a parallel authoritative session table. SID is the broker session id — the revoke
// handle cdrop stores privately; it is never exposed to clients. Meta is cdrop's device_id
// (the session<->device join key). The broker returns only kind==machine sessions for this
// app and never includes credentials.
type SessionInfo struct {
	SID        string
	Scope      string
	Label      string
	Meta       string
	CreatedAt  int64
	LastUsedAt int64
	ExpiresAt  int64
}

type sessionInfoWire struct {
	ID         string `json:"id"`
	Scope      string `json:"scope"`
	Label      string `json:"label"`
	Meta       string `json:"meta"`
	CreatedAt  int64  `json:"created_at"`
	LastUsedAt int64  `json:"last_used_at"`
	ExpiresAt  int64  `json:"expires_at"`
}

// ListSessions lists the user's delegated device sessions for this app (R1). It hits the
// same internal trust boundary as MintSession — X-Internal-Key over a direct internal dial,
// so the request carries no X-Forwarded-For and passes the broker's guard. cdrop vouches
// for userID (the subject the edge already verified). The broker returns only kind==machine
// sessions for app==c.app; cdrop treats the result as the authoritative device list.
func (c *Client) ListSessions(ctx context.Context, userID string) ([]SessionInfo, error) {
	q := url.Values{"user_id": {userID}, "app": {c.app}}
	headers := map[string]string{"X-Internal-Key": c.internalKey}
	var out struct {
		Sessions []sessionInfoWire `json:"sessions"`
	}
	if err := c.do(ctx, http.MethodGet, "/internal/sessions?"+q.Encode(), headers, nil, http.StatusOK, &out); err != nil {
		return nil, err
	}
	sessions := make([]SessionInfo, 0, len(out.Sessions))
	for _, s := range out.Sessions {
		sessions = append(sessions, SessionInfo{
			SID: s.ID, Scope: s.Scope, Label: s.Label, Meta: s.Meta,
			CreatedAt: s.CreatedAt, LastUsedAt: s.LastUsedAt, ExpiresAt: s.ExpiresAt,
		})
	}
	return sessions, nil
}

// do issues one request and decodes a successful response into out (when non-nil).
// A response whose status differs from wantStatus is an error; 404 maps to
// ErrNotFound so callers can special-case it.
func (c *Client) do(ctx context.Context, method, path string, headers map[string]string, body any, wantStatus int, out any) error {
	var reader io.Reader
	if body != nil {
		buf, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("brokerclient: marshal body: %w", err)
		}
		reader = bytes.NewReader(buf)
	}
	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
	if err != nil {
		return fmt.Errorf("brokerclient: build request: %w", err)
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	req.Header.Set("Accept", "application/json")
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("brokerclient: %s %s: %w", method, path, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusNotFound {
		return ErrNotFound
	}
	if resp.StatusCode != wantStatus {
		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return fmt.Errorf("brokerclient: %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet)))
	}
	if out != nil {
		if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
			return fmt.Errorf("brokerclient: decode response: %w", err)
		}
	}
	return nil
}
