package changelog

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
)

type Config struct {
	RepoURL    string     `json:"repo_url"`
	Categories []Category `json:"categories"`
}

type Category struct {
	Title    string    `json:"title"`
	Sections []Section `json:"sections"`
}

type Section struct {
	Title string   `json:"title"`
	Paths []string `json:"paths"`
}

type Options struct {
	RepoRoot    string
	ConfigPath  string
	OutputPath  string
	CurrentTag  string
	PreviousTag string
	APIBaseURL  string
	Token       string
	Stdout      io.Writer
}

type pullInfo struct {
	AuthorName string
}

type generator struct {
	repoRoot    string
	config      Config
	currentTag  string
	previousTag string
	apiBaseURL  string
	repoURL     string
	owner       string
	name        string
	token       string
	httpClient  *http.Client
	pullCache   map[string]pullInfo
}

type changelogEntry struct {
	Title          string
	ReferenceLabel string
	ReferenceURL   string
	ReferenceSort  string
	AuthorName     string
}

var pullNumberPattern = regexp.MustCompile(`\(#([0-9]+)\)`)

func Generate(opts Options) error {
	repoRoot := strings.TrimSpace(opts.RepoRoot)
	if repoRoot == "" {
		detectedRepoRoot, err := gitOutput("", "rev-parse", "--show-toplevel")
		if err != nil {
			return fmt.Errorf("detect repo root: %w", err)
		}
		repoRoot = strings.TrimSpace(detectedRepoRoot)
	}

	configPath := opts.ConfigPath
	if configPath == "" {
		configPath = filepath.Join(repoRoot, "scripts", "changelog.json")
	}
	config, err := loadConfig(configPath)
	if err != nil {
		return err
	}

	currentTag := strings.TrimSpace(opts.CurrentTag)
	if currentTag == "" {
		currentTag, err = gitOutput(repoRoot, "describe", "--tags", "--exact-match")
		if err != nil {
			return fmt.Errorf("detect current tag: %w", err)
		}
		currentTag = strings.TrimSpace(currentTag)
	}
	if currentTag == "" {
		return fmt.Errorf("could not determine current tag")
	}

	previousTag := strings.TrimSpace(opts.PreviousTag)
	if previousTag == "" {
		previousTag, err = previousTagFor(repoRoot, currentTag)
		if err != nil {
			return err
		}
	}

	repoURL := strings.TrimSpace(config.RepoURL)
	if repoURL == "" {
		return fmt.Errorf("config %s is missing repo_url", configPath)
	}
	apiBaseURL := strings.TrimSpace(opts.APIBaseURL)
	if apiBaseURL == "" {
		if apiBaseURL = strings.TrimSpace(os.Getenv("GITEA_SERVER_URL")); apiBaseURL == "" {
			apiBaseURL = deriveAPIBaseURL(repoURL)
		}
	}
	apiBaseURL = normalizeAPIBaseURL(apiBaseURL)
	if apiBaseURL == "" {
		return fmt.Errorf("could not determine Gitea API base URL")
	}

	owner, name, err := ownerAndRepoFromURL(repoURL)
	if err != nil {
		return err
	}

	g := generator{
		repoRoot:    repoRoot,
		config:      config,
		currentTag:  currentTag,
		previousTag: previousTag,
		apiBaseURL:  strings.TrimRight(apiBaseURL, "/"),
		repoURL:     strings.TrimRight(repoURL, "/"),
		owner:       owner,
		name:        name,
		token:       firstNonEmpty(opts.Token, os.Getenv("GITEA_API_TOKEN"), os.Getenv("GITEA_TOKEN"), os.Getenv("SOW_TOOLS_TOKEN")),
		httpClient:  http.DefaultClient,
		pullCache:   map[string]pullInfo{},
	}

	rendered, err := g.render()
	if err != nil {
		return err
	}

	if opts.OutputPath != "" {
		if err := os.MkdirAll(filepath.Dir(opts.OutputPath), 0o755); err != nil {
			return fmt.Errorf("create output directory: %w", err)
		}
		if err := os.WriteFile(opts.OutputPath, rendered, 0o644); err != nil {
			return fmt.Errorf("write changelog: %w", err)
		}
		return nil
	}

	stdout := opts.Stdout
	if stdout == nil {
		stdout = os.Stdout
	}
	_, err = stdout.Write(rendered)
	return err
}

func loadConfig(path string) (Config, error) {
	var config Config
	data, err := os.ReadFile(path)
	if err != nil {
		return Config{}, fmt.Errorf("read changelog config %s: %w", path, err)
	}
	if err := json.Unmarshal(data, &config); err != nil {
		return Config{}, fmt.Errorf("parse changelog config %s: %w", path, err)
	}
	if len(config.Categories) == 0 {
		return Config{}, fmt.Errorf("changelog config %s does not define any categories", path)
	}
	return config, nil
}

func previousTagFor(repoRoot string, currentTag string) (string, error) {
	output, err := gitOutput(repoRoot, "for-each-ref", "--sort=-creatordate", "--format=%(refname:strip=2)", "refs/tags")
	if err != nil {
		return "", fmt.Errorf("list tags: %w", err)
	}

	tags := strings.Fields(output)
	for idx, tag := range tags {
		if tag != currentTag {
			continue
		}
		if idx+1 < len(tags) {
			return tags[idx+1], nil
		}
		return "", nil
	}

	return "", fmt.Errorf("current tag %s was not found in repository tag list", currentTag)
}

func deriveAPIBaseURL(repoURL string) string {
	parsed, err := url.Parse(repoURL)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" {
		return ""
	}
	return fmt.Sprintf("%s://%s/api/v1", parsed.Scheme, parsed.Host)
}

func normalizeAPIBaseURL(raw string) string {
	raw = strings.TrimRight(strings.TrimSpace(raw), "/")
	if raw == "" {
		return ""
	}
	if strings.HasSuffix(raw, "/api/v1") {
		return raw
	}
	return raw + "/api/v1"
}

func ownerAndRepoFromURL(repoURL string) (string, string, error) {
	parsed, err := url.Parse(repoURL)
	if err != nil {
		return "", "", fmt.Errorf("parse repo_url %s: %w", repoURL, err)
	}

	parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
	if len(parts) < 2 {
		return "", "", fmt.Errorf("repo_url %s does not include owner/repo", repoURL)
	}

	owner := strings.TrimSpace(parts[0])
	name := strings.TrimSuffix(strings.TrimSpace(parts[1]), ".git")
	if owner == "" || name == "" {
		return "", "", fmt.Errorf("repo_url %s does not include owner/repo", repoURL)
	}
	return owner, name, nil
}

func (g generator) render() ([]byte, error) {
	publishDate, err := gitOutput(g.repoRoot, "log", "-1", "--format=%cs", g.currentTag)
	if err != nil {
		return nil, fmt.Errorf("resolve publish date: %w", err)
	}
	publishDate = strings.TrimSpace(publishDate)

	var buf bytes.Buffer
	fmt.Fprintf(&buf, "# %s - %s\n\n", g.currentTag, publishDate)
	if g.previousTag != "" {
		fmt.Fprintf(&buf, "_Compared against %s_\n\n", g.previousTag)
	} else {
		buf.WriteString("_Initial tagged release_\n\n")
	}

	for _, category := range g.config.Categories {
		categoryText, err := g.renderCategory(category)
		if err != nil {
			return nil, err
		}
		if categoryText == "" {
			continue
		}
		fmt.Fprintf(&buf, "## %s\n\n", category.Title)
		buf.WriteString(categoryText)
	}

	return buf.Bytes(), nil
}

func (g generator) renderCategory(category Category) (string, error) {
	var buf bytes.Buffer
	for _, section := range category.Sections {
		entries, err := g.sectionEntries(section)
		if err != nil {
			return "", err
		}
		if len(entries) == 0 {
			continue
		}
		fmt.Fprintf(&buf, "### %s\n", section.Title)
		for _, entry := range entries {
			fmt.Fprintf(&buf, "- %s ([%s](%s)) - From %s\n", entry.Title, entry.ReferenceLabel, entry.ReferenceURL, entry.AuthorName)
		}
		buf.WriteString("\n")
	}
	return buf.String(), nil
}

func (g generator) sectionEntries(section Section) ([]changelogEntry, error) {
	if len(section.Paths) == 0 {
		return nil, nil
	}

	args := []string{"log", "--first-parent", "--pretty=format:%H%x1f%s%x1f%an"}
	if g.previousTag != "" {
		args = append(args, fmt.Sprintf("%s..%s", g.previousTag, g.currentTag))
	} else {
		args = append(args, g.currentTag)
	}
	args = append(args, "--")
	args = append(args, section.Paths...)

	output, err := gitOutput(g.repoRoot, args...)
	if err != nil {
		return nil, fmt.Errorf("list git log for section %s: %w", section.Title, err)
	}

	seen := map[string]struct{}{}
	var entries []changelogEntry
	for _, line := range strings.Split(output, "\n") {
		if strings.TrimSpace(line) == "" {
			continue
		}
		parts := strings.SplitN(line, "\x1f", 3)
		commitHash := strings.TrimSpace(parts[0])
		subject := ""
		if len(parts) > 1 {
			subject = strings.TrimSpace(parts[1])
		}
		fallbackAuthor := ""
		if len(parts) > 2 {
			fallbackAuthor = strings.TrimSpace(parts[2])
		}
		matches := pullNumberPattern.FindStringSubmatch(subject)
		entryKey := commitHash
		if len(matches) == 2 {
			entryKey = "pr:" + matches[1]
		}
		if _, exists := seen[entryKey]; exists {
			continue
		}
		seen[entryKey] = struct{}{}

		entry := changelogEntry{
			Title:      sanitizeSubject(subject),
			AuthorName: fallbackAuthor,
		}

		if len(matches) == 2 {
			pullNumber := matches[1]
			pullInfo, err := g.pullDetails(pullNumber)
			if err != nil {
				return nil, err
			}
			authorName := strings.TrimSpace(pullInfo.AuthorName)
			if authorName == "" {
				authorName = fallbackAuthor
			}
			entry.ReferenceLabel = "#" + pullNumber
			entry.ReferenceURL = fmt.Sprintf("%s/pulls/%s", g.repoURL, pullNumber)
			entry.ReferenceSort = "pr:" + pullNumber
			entry.AuthorName = authorName
		} else {
			entry.ReferenceLabel = shortCommitHash(commitHash)
			entry.ReferenceURL = fmt.Sprintf("%s/commit/%s", g.repoURL, commitHash)
			entry.ReferenceSort = "commit:" + commitHash
		}

		entries = append(entries, entry)
	}

	sort.Slice(entries, func(i, j int) bool {
		if entries[i].Title == entries[j].Title {
			return entries[i].ReferenceSort < entries[j].ReferenceSort
		}
		return entries[i].Title < entries[j].Title
	})

	return entries, nil
}

func sanitizeSubject(subject string) string {
	title := strings.TrimSpace(subject)
	title = pullNumberPattern.ReplaceAllString(title, "")
	title = strings.TrimSpace(title)
	title = strings.TrimPrefix(title, "Merge pull request")
	title = strings.TrimSpace(title)
	title = strings.Trim(title, `"'`)
	title = strings.TrimSpace(title)
	if title == "" {
		return strings.TrimSpace(subject)
	}
	return title
}

func shortCommitHash(commitHash string) string {
	commitHash = strings.TrimSpace(commitHash)
	if len(commitHash) <= 7 {
		return commitHash
	}
	return commitHash[:7]
}

func (g generator) pullDetails(pullNumber string) (pullInfo, error) {
	if cached, ok := g.pullCache[pullNumber]; ok {
		return cached, nil
	}

	endpoint := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.apiBaseURL, url.PathEscape(g.owner), url.PathEscape(g.name), url.PathEscape(pullNumber))
	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
	if err != nil {
		return pullInfo{}, fmt.Errorf("build Gitea request for pull #%s: %w", pullNumber, err)
	}
	req.Header.Set("Accept", "application/json")
	if g.token != "" {
		req.Header.Set("Authorization", "token "+g.token)
	}

	resp, err := g.httpClient.Do(req)
	if err != nil {
		return pullInfo{}, fmt.Errorf("request Gitea pull #%s: %w", pullNumber, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return pullInfo{}, fmt.Errorf("request Gitea pull #%s: unexpected HTTP %d: %s", pullNumber, resp.StatusCode, strings.TrimSpace(string(body)))
	}

	var payload struct {
		User struct {
			FullName string `json:"full_name"`
			Login    string `json:"login"`
		} `json:"user"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return pullInfo{}, fmt.Errorf("decode Gitea pull #%s: %w", pullNumber, err)
	}

	info := pullInfo{AuthorName: firstNonEmpty(strings.TrimSpace(payload.User.FullName), strings.TrimSpace(payload.User.Login))}
	g.pullCache[pullNumber] = info
	return info, nil
}

func gitOutput(repoRoot string, args ...string) (string, error) {
	cmd := exec.Command("git", args...)
	if repoRoot != "" {
		cmd.Dir = repoRoot
	}
	output, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("%s: %s", strings.Join(cmd.Args, " "), strings.TrimSpace(string(output)))
	}
	return string(output), nil
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return value
		}
	}
	return ""
}
