package githubapp

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

	"github.com/golang-jwt/jwt/v5"
)

const connectionPrefix = "github-app-installation:"

type Config struct {
	APIURL       string
	WebURL       string
	AppSlug      string
	AppID        int64
	PrivateKey   string
	ClientID     string
	ClientSecret string
	HTTPClient   *http.Client

	InstallationDiscoveryAttempts int
	InstallationDiscoveryInterval time.Duration
}

type InstallationToken struct {
	Value       string
	ExpiresAt   time.Time
	Permissions map[string]string
}

type Installation struct {
	ID                  int64
	AccountLogin        string
	AccountType         string
	AccountAvatarURL    string
	RepositorySelection string
	Permissions         map[string]string
	HTMLURL             string
}

type UserIdentity struct {
	ID        int64
	Login     string
	AvatarURL string
}

type Authorization struct {
	User          UserIdentity
	Installations []Installation
}

type Repository struct {
	NodeID     string
	FullName   string
	Private    bool
	Visibility string
	Archived   bool
}

type Resolver struct {
	config Config
	mu     sync.Mutex
	cache  map[int64]InstallationToken
}

func New(config Config) (*Resolver, error) {
	config.APIURL = strings.TrimRight(strings.TrimSpace(config.APIURL), "/")
	if config.APIURL == "" {
		config.APIURL = "https://api.github.com"
	}
	parsedAPI, err := url.Parse(config.APIURL)
	if err != nil || (parsedAPI.Scheme != "https" && parsedAPI.Scheme != "http") || parsedAPI.Host == "" {
		return nil, fmt.Errorf("GitHub API URL must be an absolute HTTP(S) URL")
	}
	config.WebURL = strings.TrimRight(strings.TrimSpace(config.WebURL), "/")
	if config.WebURL == "" {
		config.WebURL = "https://github.com"
	}
	parsedWeb, err := url.Parse(config.WebURL)
	if err != nil || (parsedWeb.Scheme != "https" && parsedWeb.Scheme != "http") || parsedWeb.Host == "" {
		return nil, fmt.Errorf("GitHub web URL must be an absolute HTTP(S) URL")
	}
	if config.AppID <= 0 || strings.TrimSpace(config.PrivateKey) == "" {
		return nil, fmt.Errorf("GitHub App ID and private key are required")
	}
	if _, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(config.PrivateKey)); err != nil {
		return nil, fmt.Errorf("parse GitHub App private key: %w", err)
	}
	if config.HTTPClient == nil {
		config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
	}
	if config.InstallationDiscoveryAttempts <= 0 {
		config.InstallationDiscoveryAttempts = 10
	}
	if config.InstallationDiscoveryInterval <= 0 {
		config.InstallationDiscoveryInterval = 500 * time.Millisecond
	}
	client := *config.HTTPClient
	previousRedirect := client.CheckRedirect
	client.CheckRedirect = func(next *http.Request, via []*http.Request) error {
		allowed := (strings.EqualFold(next.URL.Scheme, parsedAPI.Scheme) && strings.EqualFold(next.URL.Hostname(), parsedAPI.Hostname())) ||
			(strings.EqualFold(next.URL.Scheme, parsedWeb.Scheme) && strings.EqualFold(next.URL.Hostname(), parsedWeb.Hostname()))
		if len(via) >= 10 || !allowed {
			return fmt.Errorf("GitHub App token redirect target is not allowlisted")
		}
		if previousRedirect != nil {
			return previousRedirect(next, via)
		}
		return nil
	}
	config.HTTPClient = &client
	return &Resolver{config: config, cache: make(map[int64]InstallationToken)}, nil
}

// InstallURL returns GitHub's first-party GitHub App installation screen. The
// opaque state is generated and persisted by AGS so the public callback can be
// bound to the authenticated user who started the flow.
func (r *Resolver) InstallURL(state string) (string, error) {
	if strings.TrimSpace(r.config.AppSlug) == "" || strings.TrimSpace(r.config.ClientID) == "" || strings.TrimSpace(r.config.ClientSecret) == "" {
		return "", fmt.Errorf("GitHub App installation onboarding is not configured")
	}
	if strings.TrimSpace(state) == "" {
		return "", fmt.Errorf("GitHub App installation state is required")
	}
	endpoint := r.config.WebURL + "/apps/" + url.PathEscape(strings.TrimSpace(r.config.AppSlug)) + "/installations/new"
	values := url.Values{"state": []string{state}}
	return endpoint + "?" + values.Encode(), nil
}

// AuthorizeURL starts GitHub's user authorization flow without attempting to
// install the App again. It is used to recover an existing installation whose
// AGS connection record is missing.
func (r *Resolver) AuthorizeURL(state string) (string, error) {
	if strings.TrimSpace(r.config.ClientID) == "" || strings.TrimSpace(r.config.ClientSecret) == "" {
		return "", fmt.Errorf("GitHub App installation onboarding is not configured")
	}
	if strings.TrimSpace(state) == "" {
		return "", fmt.Errorf("GitHub App authorization state is required")
	}
	endpoint := r.config.WebURL + "/login/oauth/authorize"
	values := url.Values{
		"client_id": []string{strings.TrimSpace(r.config.ClientID)},
		"state":     []string{state},
	}
	return endpoint + "?" + values.Encode(), nil
}

// VerifyInstallation exchanges GitHub's short-lived callback code for a user
// token and confirms that the installation is visible to that GitHub user.
// The user token is intentionally kept in memory only and discarded after the
// check; workers continue to use short-lived installation tokens.
func (r *Resolver) VerifyInstallation(ctx context.Context, code string, installationID int64) (Authorization, error) {
	if installationID <= 0 {
		return Authorization{}, fmt.Errorf("GitHub App callback is incomplete")
	}
	return r.VerifyInstallationChange(ctx, code, installationID, nil)
}

// VerifyInstallationChange waits for the newly created installation to become
// visible to the authorizing GitHub user. GitHub may redirect immediately after
// installation while /user/installations is still eventually consistent. The
// baseline is captured before leaving AGS so callbacks that omit installation_id
// can still prove that a new installation appeared instead of accepting stale
// access as a successful grant.
func (r *Resolver) VerifyInstallationChange(ctx context.Context, code string, installationID int64, baseline []int64) (Authorization, error) {
	user, token, err := r.exchangeUserAuthorization(ctx, code)
	if err != nil {
		return Authorization{}, err
	}
	known := make(map[int64]bool, len(baseline))
	for _, id := range baseline {
		if id > 0 {
			known[id] = true
		}
	}
	attempts := r.config.InstallationDiscoveryAttempts
	if attempts <= 0 {
		attempts = 10
	}
	interval := r.config.InstallationDiscoveryInterval
	if interval <= 0 {
		interval = 500 * time.Millisecond
	}
	for attempt := 0; attempt < attempts; attempt++ {
		installations, listErr := r.listUserInstallations(ctx, token)
		if listErr != nil {
			return Authorization{}, listErr
		}
		for _, item := range installations {
			if (installationID > 0 && item.ID == installationID) || (installationID <= 0 && !known[item.ID]) {
				return Authorization{User: user, Installations: installations}, nil
			}
		}
		if attempt+1 < attempts {
			timer := time.NewTimer(interval)
			select {
			case <-ctx.Done():
				timer.Stop()
				return Authorization{}, ctx.Err()
			case <-timer.C:
			}
		}
	}
	if installationID > 0 {
		return Authorization{}, fmt.Errorf("GitHub App installation %d is not accessible to the authorizing GitHub user", installationID)
	}
	return Authorization{}, fmt.Errorf("new GitHub App installation did not become visible to the authorizing GitHub user")
}

// VerifyAuthorizations exchanges GitHub's short-lived callback code and
// returns every installation of this App visible to that GitHub user. The user
// token remains in memory only and is discarded before the method returns.
func (r *Resolver) VerifyAuthorizations(ctx context.Context, code string) (Authorization, error) {
	user, token, err := r.exchangeUserAuthorization(ctx, code)
	if err != nil {
		return Authorization{}, err
	}
	installations, err := r.listUserInstallations(ctx, token)
	if err != nil {
		return Authorization{}, err
	}
	return Authorization{User: user, Installations: installations}, nil
}

func (r *Resolver) exchangeUserAuthorization(ctx context.Context, code string) (UserIdentity, string, error) {
	if strings.TrimSpace(r.config.ClientID) == "" || strings.TrimSpace(r.config.ClientSecret) == "" {
		return UserIdentity{}, "", fmt.Errorf("GitHub App installation onboarding is not configured")
	}
	if strings.TrimSpace(code) == "" {
		return UserIdentity{}, "", fmt.Errorf("GitHub App callback is incomplete")
	}
	form := url.Values{
		"client_id":     []string{strings.TrimSpace(r.config.ClientID)},
		"client_secret": []string{strings.TrimSpace(r.config.ClientSecret)},
		"code":          []string{strings.TrimSpace(code)},
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.config.WebURL+"/login/oauth/access_token", strings.NewReader(form.Encode()))
	if err != nil {
		return UserIdentity{}, "", err
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := r.config.HTTPClient.Do(req)
	if err != nil {
		return UserIdentity{}, "", fmt.Errorf("exchange GitHub App user authorization code: %w", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return UserIdentity{}, "", err
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return UserIdentity{}, "", fmt.Errorf("exchange GitHub App user authorization code: HTTP %d", resp.StatusCode)
	}
	var tokenPayload struct {
		AccessToken      string `json:"access_token"`
		Error            string `json:"error"`
		ErrorDescription string `json:"error_description"`
	}
	if err := json.Unmarshal(body, &tokenPayload); err != nil {
		return UserIdentity{}, "", fmt.Errorf("decode GitHub App user authorization response: %w", err)
	}
	if tokenPayload.Error != "" || strings.TrimSpace(tokenPayload.AccessToken) == "" {
		message := strings.TrimSpace(tokenPayload.ErrorDescription)
		if message == "" {
			message = strings.TrimSpace(tokenPayload.Error)
		}
		return UserIdentity{}, "", fmt.Errorf("GitHub App user authorization failed: %s", message)
	}

	var userPayload struct {
		ID        int64  `json:"id"`
		Login     string `json:"login"`
		AvatarURL string `json:"avatar_url"`
	}
	if err := r.doJSON(ctx, r.config.APIURL+"/user", tokenPayload.AccessToken, &userPayload); err != nil {
		return UserIdentity{}, "", err
	}
	if userPayload.ID <= 0 || strings.TrimSpace(userPayload.Login) == "" {
		return UserIdentity{}, "", fmt.Errorf("GitHub App user authorization returned an incomplete user identity")
	}
	return UserIdentity{ID: userPayload.ID, Login: userPayload.Login, AvatarURL: userPayload.AvatarURL}, tokenPayload.AccessToken, nil
}

func (r *Resolver) listUserInstallations(ctx context.Context, token string) ([]Installation, error) {
	installations := make([]Installation, 0)
	for page := 1; ; page++ {
		endpoint := fmt.Sprintf("%s/user/installations?per_page=100&page=%d", r.config.APIURL, page)
		var payload struct {
			Installations []struct {
				ID                  int64             `json:"id"`
				RepositorySelection string            `json:"repository_selection"`
				Permissions         map[string]string `json:"permissions"`
				HTMLURL             string            `json:"html_url"`
				Account             struct {
					Login     string `json:"login"`
					Type      string `json:"type"`
					AvatarURL string `json:"avatar_url"`
				} `json:"account"`
			} `json:"installations"`
		}
		if err := r.doJSON(ctx, endpoint, token, &payload); err != nil {
			return nil, err
		}
		for _, item := range payload.Installations {
			installations = append(installations, Installation{ID: item.ID, AccountLogin: item.Account.Login, AccountType: item.Account.Type, AccountAvatarURL: item.Account.AvatarURL, RepositorySelection: item.RepositorySelection, Permissions: item.Permissions, HTMLURL: item.HTMLURL})
		}
		if len(payload.Installations) < 100 {
			break
		}
	}
	return installations, nil
}

func ParseConnectionID(connectionID string) (int64, error) {
	value := strings.TrimSpace(connectionID)
	if !strings.HasPrefix(value, connectionPrefix) {
		return 0, fmt.Errorf("GitHub connection must use %q", connectionPrefix+"<id>")
	}
	id, err := strconv.ParseInt(strings.TrimPrefix(value, connectionPrefix), 10, 64)
	if err != nil || id <= 0 {
		return 0, fmt.Errorf("invalid GitHub App installation connection %q", connectionID)
	}
	return id, nil
}

func (r *Resolver) Token(ctx context.Context, connectionID string) (InstallationToken, error) {
	installationID, err := ParseConnectionID(connectionID)
	if err != nil {
		return InstallationToken{}, err
	}
	r.mu.Lock()
	cached, ok := r.cache[installationID]
	r.mu.Unlock()
	if ok && time.Until(cached.ExpiresAt) > 5*time.Minute {
		return cached, nil
	}
	created, err := r.createToken(ctx, installationID)
	if err != nil {
		return InstallationToken{}, err
	}
	r.mu.Lock()
	r.cache[installationID] = created
	r.mu.Unlock()
	return created, nil
}

func (r *Resolver) createToken(ctx context.Context, installationID int64) (InstallationToken, error) {
	signed, err := r.appJWT()
	if err != nil {
		return InstallationToken{}, err
	}
	endpoint := fmt.Sprintf("%s/app/installations/%d/access_tokens", r.config.APIURL, installationID)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader([]byte("{}")))
	if err != nil {
		return InstallationToken{}, err
	}
	req.Header.Set("Authorization", "Bearer "+signed)
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
	req.Header.Set("Content-Type", "application/json")
	resp, err := r.config.HTTPClient.Do(req)
	if err != nil {
		return InstallationToken{}, fmt.Errorf("create GitHub installation token: %w", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return InstallationToken{}, err
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return InstallationToken{}, fmt.Errorf("create GitHub installation token: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
	}
	var payload struct {
		Token       string            `json:"token"`
		ExpiresAt   time.Time         `json:"expires_at"`
		Permissions map[string]string `json:"permissions"`
	}
	if err := json.Unmarshal(body, &payload); err != nil {
		return InstallationToken{}, err
	}
	if strings.TrimSpace(payload.Token) == "" || payload.ExpiresAt.IsZero() {
		return InstallationToken{}, fmt.Errorf("GitHub installation token response is incomplete")
	}
	return InstallationToken{Value: payload.Token, ExpiresAt: payload.ExpiresAt.UTC(), Permissions: payload.Permissions}, nil
}

func (r *Resolver) appJWT() (string, error) {
	privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(r.config.PrivateKey))
	if err != nil {
		return "", err
	}
	now := time.Now().UTC()
	appJWT := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{
		Issuer:    strconv.FormatInt(r.config.AppID, 10),
		IssuedAt:  jwt.NewNumericDate(now.Add(-30 * time.Second)),
		ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)),
	})
	signed, err := appJWT.SignedString(privateKey)
	if err != nil {
		return "", fmt.Errorf("sign GitHub App JWT: %w", err)
	}
	return signed, nil
}

func (r *Resolver) ListInstallations(ctx context.Context) ([]Installation, error) {
	signed, err := r.appJWT()
	if err != nil {
		return nil, err
	}
	var result []Installation
	for page := 1; ; page++ {
		endpoint := fmt.Sprintf("%s/app/installations?per_page=100&page=%d", r.config.APIURL, page)
		var payload []struct {
			ID                  int64             `json:"id"`
			RepositorySelection string            `json:"repository_selection"`
			Permissions         map[string]string `json:"permissions"`
			HTMLURL             string            `json:"html_url"`
			Account             struct {
				Login     string `json:"login"`
				Type      string `json:"type"`
				AvatarURL string `json:"avatar_url"`
			} `json:"account"`
		}
		if err := r.doJSON(ctx, endpoint, signed, &payload); err != nil {
			return nil, err
		}
		for _, item := range payload {
			result = append(result, Installation{ID: item.ID, AccountLogin: item.Account.Login, AccountType: item.Account.Type, AccountAvatarURL: item.Account.AvatarURL, RepositorySelection: item.RepositorySelection, Permissions: item.Permissions, HTMLURL: item.HTMLURL})
		}
		if len(payload) < 100 {
			return result, nil
		}
	}
}

func (r *Resolver) ListRepositories(ctx context.Context, connectionID string) ([]Repository, error) {
	token, err := r.Token(ctx, connectionID)
	if err != nil {
		return nil, err
	}
	var result []Repository
	for page := 1; ; page++ {
		endpoint := fmt.Sprintf("%s/installation/repositories?per_page=100&page=%d", r.config.APIURL, page)
		var payload struct {
			Repositories []struct {
				NodeID     string `json:"node_id"`
				FullName   string `json:"full_name"`
				Private    bool   `json:"private"`
				Visibility string `json:"visibility"`
				Archived   bool   `json:"archived"`
			} `json:"repositories"`
		}
		if err := r.doJSON(ctx, endpoint, token.Value, &payload); err != nil {
			return nil, err
		}
		for _, item := range payload.Repositories {
			result = append(result, Repository{NodeID: item.NodeID, FullName: item.FullName, Private: item.Private, Visibility: item.Visibility, Archived: item.Archived})
		}
		if len(payload.Repositories) < 100 {
			return result, nil
		}
	}
}

func (r *Resolver) doJSON(ctx context.Context, endpoint, bearer string, out any) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+bearer)
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
	resp, err := r.config.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("GitHub App discovery: %w", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
	if err != nil {
		return err
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("GitHub App discovery: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
	}
	if err := json.Unmarshal(body, out); err != nil {
		return fmt.Errorf("decode GitHub App discovery response: %w", err)
	}
	return nil
}

func (r *Resolver) APIURL() string { return r.config.APIURL }

// RedactedConfig returns safe diagnostic fields and never exposes the private
// key or an installation token.
func (r *Resolver) RedactedConfig() map[string]any {
	return map[string]any{"api_url": r.config.APIURL, "web_url": r.config.WebURL, "app_id": r.config.AppID, "app_slug": r.config.AppSlug}
}
