package staticsite

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// GitHubProvider implements GitProvider for GitHub repositories.
type GitHubProvider struct {
	apiBase    string // "https://api.github.com" for github.com
	token      string
	httpClient *http.Client
}

// NewGitHubProvider creates a new GitHub provider.
// baseURL should be "https://github.com" or a GitHub Enterprise URL.
func NewGitHubProvider(baseURL, token string) *GitHubProvider {
	apiBase := "https://api.github.com"
	base := strings.TrimRight(baseURL, "/")
	if base != "https://github.com" && base != "http://github.com" {
		// GitHub Enterprise: API is at {base}/api/v3
		apiBase = base + "/api/v3"
	}

	return &GitHubProvider{
		apiBase:    apiBase,
		token:      token,
		httpClient: NewSafeHTTPClient(60 * time.Second),
	}
}

func (g *GitHubProvider) Name() string { return "github" }

func (g *GitHubProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) {
	var allRepos []RepoInfo

	if query != "" {
		// Use search API.
		url := fmt.Sprintf("%s/search/repositories?q=%s&per_page=50", g.apiBase, query)
		body, err := g.doGet(ctx, url)
		if err != nil {
			return nil, fmt.Errorf("search repos: %w", err)
		}

		var result struct {
			Items []struct {
				Owner struct {
					Login string `json:"login"`
				} `json:"owner"`
				Name        string `json:"name"`
				FullName    string `json:"full_name"`
				Description string `json:"description"`
				Private     bool   `json:"private"`
				HTMLURL     string `json:"html_url"`
			} `json:"items"`
		}
		if err := json.Unmarshal(body, &result); err != nil {
			return nil, fmt.Errorf("decode search: %w", err)
		}
		for _, r := range result.Items {
			allRepos = append(allRepos, RepoInfo{
				Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName,
				Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL,
			})
		}
		return allRepos, nil
	}

	// List authenticated user's repos.
	page := 1
	for {
		url := fmt.Sprintf("%s/user/repos?per_page=100&page=%d&sort=updated", g.apiBase, page)
		body, err := g.doGet(ctx, url)
		if err != nil {
			return nil, fmt.Errorf("list repos: %w", err)
		}

		var repos []struct {
			Owner struct {
				Login string `json:"login"`
			} `json:"owner"`
			Name        string `json:"name"`
			FullName    string `json:"full_name"`
			Description string `json:"description"`
			Private     bool   `json:"private"`
			HTMLURL     string `json:"html_url"`
		}
		if err := json.Unmarshal(body, &repos); err != nil {
			return nil, fmt.Errorf("decode repos: %w", err)
		}
		for _, r := range repos {
			allRepos = append(allRepos, RepoInfo{
				Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName,
				Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL,
			})
		}
		if len(repos) < 100 {
			break
		}
		page++
	}
	return allRepos, nil
}

func (g *GitHubProvider) TestConnection(ctx context.Context, owner, repo string) error {
	url := fmt.Sprintf("%s/repos/%s/%s", g.apiBase, owner, repo)
	_, err := g.doGet(ctx, url)
	return err
}

// SetCommitStatus reports a deploy status on a commit via GitHub's commit-
// status API (works for github.com and GitHub Enterprise — apiBase already
// carries the /api/v3 suffix for GHE). The "context" field is fixed to
// "tinyforge" so repeated deploys update one status row.
func (g *GitHubProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error {
	body, err := json.Marshal(map[string]string{
		"state":       githubState(status),
		"target_url":  targetURL,
		"description": truncateDescription(description),
		"context":     commitStatusContext,
	})
	if err != nil {
		return fmt.Errorf("marshal status: %w", err)
	}
	apiURL := fmt.Sprintf("%s/repos/%s/%s/statuses/%s",
		g.apiBase, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
	if err := postJSON(ctx, g.httpClient, apiURL, body, g.setAuth); err != nil {
		return fmt.Errorf("set commit status: %w", err)
	}
	return nil
}

// githubState maps a provider-agnostic CommitStatus onto GitHub's API
// vocabulary. GitHub accepts the same four words Tinyforge uses.
func githubState(status CommitStatus) string {
	switch status {
	case CommitStatusSuccess:
		return "success"
	case CommitStatusFailure:
		return "failure"
	case CommitStatusError:
		return "error"
	default:
		return "pending"
	}
}

func (g *GitHubProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
	var allBranches []string
	page := 1

	for {
		url := fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d",
			g.apiBase, owner, repo, page)

		body, err := g.doGet(ctx, url)
		if err != nil {
			return nil, fmt.Errorf("list branches: %w", err)
		}

		var branches []struct {
			Name string `json:"name"`
		}
		if err := json.Unmarshal(body, &branches); err != nil {
			return nil, fmt.Errorf("decode branches: %w", err)
		}

		for _, b := range branches {
			allBranches = append(allBranches, b.Name)
		}

		if len(branches) < 100 {
			break
		}
		page++
	}

	return allBranches, nil
}

func (g *GitHubProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) {
	url := fmt.Sprintf("%s/repos/%s/%s/branches/%s", g.apiBase, owner, repo, branch)

	body, err := g.doGet(ctx, url)
	if err != nil {
		return "", fmt.Errorf("get branch: %w", err)
	}

	var result struct {
		Commit struct {
			SHA string `json:"sha"`
		} `json:"commit"`
	}
	if err := json.Unmarshal(body, &result); err != nil {
		return "", fmt.Errorf("decode branch: %w", err)
	}

	return result.Commit.SHA, nil
}

func (g *GitHubProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) {
	url := fmt.Sprintf("%s/repos/%s/%s/git/trees/%s?recursive=1",
		g.apiBase, owner, repo, branch)

	body, err := g.doGet(ctx, url)
	if err != nil {
		return nil, fmt.Errorf("list tree: %w", err)
	}

	var tree struct {
		Tree []struct {
			Path string `json:"path"`
			Type string `json:"type"` // "blob" or "tree"
		} `json:"tree"`
	}
	if err := json.Unmarshal(body, &tree); err != nil {
		return nil, fmt.Errorf("decode tree: %w", err)
	}

	entries := make([]FolderEntry, 0, len(tree.Tree))
	for _, e := range tree.Tree {
		entries = append(entries, FolderEntry{
			Path:  e.Path,
			IsDir: e.Type == "tree",
		})
	}

	return entries, nil
}

func (g *GitHubProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error {
	// Get tree to find files in folder.
	entries, err := g.ListTree(ctx, owner, repo, branch)
	if err != nil {
		return fmt.Errorf("list tree: %w", err)
	}

	folderPath = strings.TrimPrefix(folderPath, "/")
	folderPath = strings.TrimSuffix(folderPath, "/")
	prefix := folderPath + "/"

	for _, entry := range entries {
		if entry.IsDir {
			continue
		}
		if !strings.HasPrefix(entry.Path, prefix) {
			continue
		}

		relativePath := strings.TrimPrefix(entry.Path, prefix)
		localPath := filepath.Join(destDir, filepath.FromSlash(relativePath))

		// Path-traversal defense: refuse tree entries whose resolved
		// path escapes destDir. A hostile/compromised GHE could
		// otherwise deliver `..`-laden entries.
		cleanDest := filepath.Clean(destDir)
		if cleanRel := filepath.Clean(localPath); cleanRel != cleanDest &&
			!strings.HasPrefix(cleanRel, cleanDest+string(os.PathSeparator)) {
			return fmt.Errorf("rejecting tree entry outside dest: %s", relativePath)
		}

		// GitHub raw content URL.
		// For github.com: https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}
		// For GHE: {baseURL}/{owner}/{repo}/raw/{branch}/{path}
		var fileURL string
		if g.apiBase == "https://api.github.com" {
			fileURL = fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s",
				owner, repo, branch, entry.Path)
		} else {
			// GHE: use API contents endpoint.
			fileURL = fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s",
				g.apiBase, owner, repo, entry.Path, branch)
		}

		if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil {
			return fmt.Errorf("download %s: %w", relativePath, err)
		}
	}

	return nil
}

// DownloadFile fetches a single file's raw bytes via the GitHub contents API
// using the raw media type (works for both github.com and GHE), capped at
// maxBytes. Returns ErrFileNotFound on a 404.
func (g *GitHubProvider) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
	p := strings.TrimPrefix(path, "/")
	fileURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", g.apiBase, owner, repo, p, ref)
	auth := func(r *http.Request) {
		g.setAuth(r)
		r.Header.Set("Accept", "application/vnd.github.raw+json")
	}
	return getFileBytes(ctx, g.httpClient, fileURL, maxBytes, auth)
}

func (g *GitHubProvider) doGet(ctx context.Context, url string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	g.setAuth(req)
	req.Header.Set("Accept", "application/vnd.github+json")

	resp, err := g.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("execute request: %w", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
	}

	return body, nil
}

func (g *GitHubProvider) setAuth(req *http.Request) {
	if g.token != "" {
		req.Header.Set("Authorization", "Bearer "+g.token)
	}
}
