package staticsite

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

// giteaTreeEntry represents a single entry in a Gitea git tree response.
type giteaTreeEntry struct {
	Path string `json:"path"`
	Type string `json:"type"` // "blob" or "tree"
	SHA  string `json:"sha"`
	Size int64  `json:"size"`
}

// giteaTreeResponse represents the Gitea git tree API response.
type giteaTreeResponse struct {
	SHA       string           `json:"sha"`
	Entries   []giteaTreeEntry `json:"tree"`
	Truncated bool             `json:"truncated"`
}

// giteaBranch represents a branch from the Gitea API.
type giteaBranch struct {
	Name   string `json:"name"`
	Commit struct {
		ID string `json:"id"`
	} `json:"commit"`
}

// giteaRef represents a git reference from the Gitea API.
type giteaRef struct {
	Ref    string `json:"ref"`
	Object struct {
		SHA string `json:"sha"`
	} `json:"object"`
}

// GiteaContentFetcher downloads folder contents from a Gitea repository.
type GiteaContentFetcher struct {
	baseURL    string
	token      string
	httpClient *http.Client
}

// NewGiteaContentFetcher creates a new content fetcher.
// token may be empty for public repositories.
func NewGiteaContentFetcher(baseURL, token string) *GiteaContentFetcher {
	return &GiteaContentFetcher{
		baseURL:    strings.TrimRight(baseURL, "/"),
		token:      token,
		httpClient: NewSafeHTTPClient(60 * time.Second),
	}
}

// Name returns the provider identifier.
func (f *GiteaContentFetcher) Name() string { return "gitea" }

// ListRepos returns repositories accessible with the current token.
func (f *GiteaContentFetcher) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) {
	var allRepos []RepoInfo
	page := 1
	limit := 50

	for {
		url := fmt.Sprintf("%s/api/v1/repos/search?limit=%d&page=%d", f.baseURL, limit, page)
		if query != "" {
			url += "&q=" + query
		}
		if f.token != "" {
			// When authenticated, include private repos.
			url += "&private=true"
		}

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

		var result struct {
			Data []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:"data"`
		}
		if err := json.Unmarshal(body, &result); err != nil {
			// Gitea search wraps in {"data": [...]}, but some versions return a flat array.
			var flat []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 err2 := json.Unmarshal(body, &flat); err2 != nil {
				return nil, fmt.Errorf("decode repos: %w", err)
			}
			for _, r := range flat {
				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(flat) < limit {
				break
			}
			page++
			continue
		}

		for _, r := range result.Data {
			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(result.Data) < limit {
			break
		}
		page++
	}

	return allRepos, nil
}

// ListBranches returns all branches for a repository.
func (f *GiteaContentFetcher) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
	var allBranches []string
	page := 1
	limit := 50

	for {
		url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?page=%d&limit=%d",
			f.baseURL, owner, repo, page, limit)

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

		var branches []giteaBranch
		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) < limit {
			break
		}
		page++
	}

	return allBranches, nil
}

// GetLatestCommitSHA returns the latest commit SHA for a branch.
func (f *GiteaContentFetcher) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) {
	url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s",
		f.baseURL, owner, repo, branch)

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

	var b giteaBranch
	if err := json.Unmarshal(body, &b); err != nil {
		return "", fmt.Errorf("decode branch: %w", err)
	}

	return b.Commit.ID, nil
}

// FolderEntry represents a file or directory in the repo tree.
type FolderEntry struct {
	Path  string `json:"path"`
	IsDir bool   `json:"is_dir"`
}

// ListTree returns the full directory tree for a branch, useful for the folder picker.
func (f *GiteaContentFetcher) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) {
	url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
		f.baseURL, owner, repo, branch)

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

	var tree giteaTreeResponse
	if err := json.Unmarshal(body, &tree); err != nil {
		return nil, fmt.Errorf("decode tree: %w", err)
	}

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

	return entries, nil
}

// DownloadFolder downloads all files from a specific folder path in the repo
// to a local temporary directory. Returns the path to the temp directory.
func (f *GiteaContentFetcher) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error {
	// Get the full tree.
	url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
		f.baseURL, owner, repo, branch)

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

	var tree giteaTreeResponse
	if err := json.Unmarshal(body, &tree); err != nil {
		return fmt.Errorf("decode tree: %w", err)
	}

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

	// Download each file in the folder.
	for _, entry := range tree.Entries {
		if entry.Type != "blob" {
			continue
		}

		if !strings.HasPrefix(entry.Path, prefix) {
			continue
		}

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

		// Path-traversal defense: reject anything whose resolved
		// destination escapes destDir. A hostile (or compromised)
		// Gitea instance could return tree entries with `..` in
		// the path; filepath.Join cleans them and would otherwise
		// write outside the build context.
		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)
		}

		// Create parent directories.
		if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
			return fmt.Errorf("create directory for %s: %w", relativePath, err)
		}

		// Download the file.
		fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
			f.baseURL, owner, repo, entry.Path, branch)

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

	return nil
}

// DownloadFile fetches a single file's raw bytes via Gitea's raw endpoint
// (also serves Forgejo/Gogs), capped at maxBytes. Returns ErrFileNotFound on
// a 404 so an absent config file reads as a non-error state.
func (f *GiteaContentFetcher) DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) {
	p := strings.TrimPrefix(path, "/")
	fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", f.baseURL, owner, repo, p, ref)
	return getFileBytes(ctx, f.httpClient, fileURL, maxBytes, f.setAuth)
}

// TestConnection verifies that the repository is accessible.
func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo string) error {
	url := fmt.Sprintf("%s/api/v1/repos/%s/%s", f.baseURL, owner, repo)
	_, err := f.doGet(ctx, url)
	if err != nil {
		return fmt.Errorf("test connection: %w", err)
	}
	return nil
}

// SetCommitStatus reports a deploy status on a commit via Gitea's commit-
// status API (also serves Forgejo/Gogs). The "context" field is fixed to
// "tinyforge" so repeated deploys update one status row.
func (f *GiteaContentFetcher) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error {
	state := giteaState(status)
	body, err := json.Marshal(map[string]string{
		"state":       state,
		"target_url":  targetURL,
		"description": truncateDescription(description),
		"context":     commitStatusContext,
	})
	if err != nil {
		return fmt.Errorf("marshal status: %w", err)
	}
	// Path-escape each identifier so the URL shape matches the other
	// provider methods and a hostile owner/repo/sha can't break out of
	// the intended path. The SSRF-safe client guards the host.
	apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/statuses/%s",
		f.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
	if err := postJSON(ctx, f.httpClient, apiURL, body, f.setAuth); err != nil {
		return fmt.Errorf("set commit status: %w", err)
	}
	return nil
}

// setAuth applies the Gitea token header (no-op when the token is empty).
func (f *GiteaContentFetcher) setAuth(req *http.Request) {
	if f.token != "" {
		req.Header.Set("Authorization", "token "+f.token)
	}
}

// giteaState maps a provider-agnostic CommitStatus onto Gitea's API
// vocabulary. Gitea accepts the same four words Tinyforge uses, so this is
// a 1:1 mapping with a "pending" fallback for any unknown value.
func giteaState(status CommitStatus) string {
	switch status {
	case CommitStatusSuccess:
		return "success"
	case CommitStatusFailure:
		return "failure"
	case CommitStatusError:
		return "error"
	default:
		return "pending"
	}
}

// doGet performs an authenticated GET request and returns the response body.
func (f *GiteaContentFetcher) 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)
	}

	if f.token != "" {
		req.Header.Set("Authorization", "token "+f.token)
	}
	req.Header.Set("Accept", "application/json")

	resp, err := f.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
}

// downloadFile downloads a URL to a local file path.
func (f *GiteaContentFetcher) downloadFile(ctx context.Context, url, localPath string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}

	if f.token != "" {
		req.Header.Set("Authorization", "token "+f.token)
	}

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

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url)
	}

	file, err := os.Create(localPath)
	if err != nil {
		return fmt.Errorf("create file: %w", err)
	}
	defer file.Close()

	if _, err := io.Copy(file, resp.Body); err != nil {
		return fmt.Errorf("write file: %w", err)
	}

	return nil
}
