package provenance

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

// recentEditsCap is the maximum number of recent edits returned by
// [Store.History].
const recentEditsCap = 10

// gitTimeout is the default timeout for git operations that don't
// receive a caller-provided context (e.g., repository initialization).
const gitTimeout = 30 * time.Second

// defaultBirthGitignore is written by [Store.BootstrapBirthCommit] when
// the repository has no .gitignore yet. Kept minimal — broader patterns
// belong to the operator's own .gitignore.
const defaultBirthGitignore = `.DS_Store
*~
.tmp/
`

// BootstrapBirthCommit creates an initial signed commit on an empty
// repository so verification has a baseline to verify against. No-op
// when HEAD already exists. Stages only the bootstrap files
// (.gitignore and the repo-local .allowed_signers if present);
// existing user content stays untracked and must be added explicitly.
func (s *Store) BootstrapBirthCommit(ctx context.Context) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if err := s.git(ctx, nil, nil, "rev-parse", "--verify", "HEAD^{commit}"); err == nil {
		return nil
	}

	gitignorePath := filepath.Join(s.path, ".gitignore")
	if _, err := os.Stat(gitignorePath); err != nil {
		if !errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("stat .gitignore: %w", err)
		}
		if err := os.WriteFile(gitignorePath, []byte(defaultBirthGitignore), 0o644); err != nil {
			return fmt.Errorf("write default .gitignore: %w", err)
		}
	}

	bootstrapFiles := []string{".gitignore"}
	if _, err := os.Stat(filepath.Join(s.path, ".allowed_signers")); err == nil {
		bootstrapFiles = append(bootstrapFiles, ".allowed_signers")
	} else if !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("stat .allowed_signers: %w", err)
	}

	committed, err := s.commitFiles(ctx, bootstrapFiles, "bootstrap document root")
	if err != nil {
		return fmt.Errorf("birth commit: %w", err)
	}
	if committed {
		s.logger.Info("created document root birth commit",
			"path", s.path,
			"files", bootstrapFiles,
		)
	}
	return nil
}

// ensureRepo initializes the git repository if it doesn't already
// exist, configures the committer identity, and writes the
// .allowed_signers file.
func (s *Store) ensureRepo() error {
	ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
	defer cancel()

	if err := os.MkdirAll(s.path, 0o755); err != nil {
		return fmt.Errorf("create directory: %w", err)
	}

	gitDir := filepath.Join(s.path, ".git")
	if _, err := os.Stat(gitDir); os.IsNotExist(err) {
		if err := s.git(ctx, nil, nil, "init"); err != nil {
			return fmt.Errorf("git init: %w", err)
		}
		s.logger.Info("initialized provenance repository", "path", s.path)
	}

	// Configure committer identity. These are repo-local settings.
	for _, kv := range [][2]string{
		{"user.name", "Thane"},
		{"user.email", "thane@provenance.local"},
	} {
		if err := s.git(ctx, nil, nil, "config", kv[0], kv[1]); err != nil {
			return fmt.Errorf("git config %s: %w", kv[0], err)
		}
	}

	allowedPath := s.allowedSignersPath
	if allowedPath == "" {
		allowedPath = filepath.Join(s.path, ".allowed_signers")
		if err := validateAllowedSignersFile(allowedPath); err != nil {
			if !errors.Is(err, os.ErrNotExist) {
				return err
			}
			// Bootstrap .allowed_signers with the signer's public
			// key when this repository does not already define its
			// own trust surface.
			allowedSigners := fmt.Sprintf("thane@provenance.local %s\n", s.signer.PublicKey())
			if err := os.WriteFile(allowedPath, []byte(allowedSigners), 0o644); err != nil {
				return fmt.Errorf("write .allowed_signers: %w", err)
			}
		}
	} else if err := validateAllowedSignersFile(allowedPath); err != nil {
		return err
	}

	// Tell git where to find allowed signers for verification.
	if err := s.git(ctx, nil, nil,
		"config", "gpg.ssh.allowedSignersFile", allowedPath); err != nil {
		return fmt.Errorf("git config allowedSignersFile: %w", err)
	}

	return nil
}

// commitFile stages a file and creates a signed commit.
func (s *Store) commitFile(ctx context.Context, filename, message string) (bool, error) {
	return s.commitFiles(ctx, []string{filename}, message)
}

func (s *Store) fileTracked(ctx context.Context, filename string) (bool, error) {
	err := s.git(ctx, nil, nil, "ls-files", "--error-unmatch", "--", filename)
	if err == nil {
		return true, nil
	}
	var exitErr *exec.ExitError
	if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
		return false, nil
	}
	return false, err
}

// commitFiles stages files and creates one signed commit containing all
// staged changes. It reports whether a commit was created.
func (s *Store) commitFiles(ctx context.Context, filenames []string, message string) (bool, error) {
	if len(filenames) == 0 {
		return false, fmt.Errorf("no files to commit")
	}

	// Stage additions, modifications, and removals for the pathspecs.
	args := append([]string{"add", "-A", "--"}, filenames...)
	if err := s.git(ctx, nil, nil, args...); err != nil {
		return false, fmt.Errorf("git add: %w", err)
	}

	// Check if there are staged changes — skip commit if nothing changed.
	// git diff --cached --quiet exits 1 when there are differences, 0
	// when clean, and >1 on real errors (corruption, bad args, etc.).
	diffErr := s.git(ctx, nil, nil, "diff", "--cached", "--quiet")
	if diffErr == nil {
		// Exit code 0 means no differences — nothing to commit.
		s.logger.Debug("no changes to commit", "files", filenames)
		return false, nil
	}
	var exitErr *exec.ExitError
	if errors.As(diffErr, &exitErr) && exitErr.ExitCode() != 1 {
		return false, fmt.Errorf("git diff --cached: %w", diffErr)
	}

	// Get the tree hash.
	var treeBuf bytes.Buffer
	if err := s.git(ctx, nil, &treeBuf, "write-tree"); err != nil {
		return false, fmt.Errorf("git write-tree: %w", err)
	}
	tree := strings.TrimSpace(treeBuf.String())

	// Get parent commit (may not exist for first commit).
	var parentBuf bytes.Buffer
	parent := ""
	if err := s.git(ctx, nil, &parentBuf, "rev-parse", "HEAD"); err == nil {
		parent = strings.TrimSpace(parentBuf.String())
	}

	// Build the commit object.
	now := time.Now()
	unixTime := now.Unix()
	_, offset := now.Zone()
	tzSign := "+"
	if offset < 0 {
		tzSign = "-"
		offset = -offset
	}
	tz := fmt.Sprintf("%s%02d%02d", tzSign, offset/3600, (offset%3600)/60)
	timestamp := fmt.Sprintf("%d %s", unixTime, tz)

	identity := "Thane <thane@provenance.local>"

	var commitObj strings.Builder
	fmt.Fprintf(&commitObj, "tree %s\n", tree)
	if parent != "" {
		fmt.Fprintf(&commitObj, "parent %s\n", parent)
	}
	fmt.Fprintf(&commitObj, "author %s %s\n", identity, timestamp)
	fmt.Fprintf(&commitObj, "committer %s %s\n", identity, timestamp)

	// Sign the commit content (without the gpgsig header — git signs
	// the commit object as it would appear without the signature).
	commitForSigning := commitObj.String() + "\n" + message + "\n"
	armoredSig, err := s.signer.Sign([]byte(commitForSigning))
	if err != nil {
		return false, fmt.Errorf("sign commit: %w", err)
	}

	// Insert the gpgsig header between the last header line and the
	// blank line before the message. Each continuation line of the
	// signature is indented with a single space.
	sigLines := strings.Split(string(armoredSig), "\n")
	fmt.Fprintf(&commitObj, "gpgsig %s\n", sigLines[0])
	for _, line := range sigLines[1:] {
		fmt.Fprintf(&commitObj, " %s\n", line)
	}
	fmt.Fprintf(&commitObj, "\n%s\n", message)

	// Write the signed commit object.
	var hashBuf bytes.Buffer
	commitBytes := []byte(commitObj.String())
	if err := s.git(ctx, bytes.NewReader(commitBytes), &hashBuf,
		"hash-object", "-t", "commit", "-w", "--stdin"); err != nil {
		return false, fmt.Errorf("git hash-object: %w", err)
	}
	commitHash := strings.TrimSpace(hashBuf.String())

	// Update HEAD to point to the new commit.
	if err := s.git(ctx, nil, nil,
		"update-ref", "HEAD", commitHash); err != nil {
		return false, fmt.Errorf("git update-ref: %w", err)
	}

	// Reset the index to match HEAD so subsequent operations see a
	// clean working tree.
	if err := s.git(ctx, nil, nil, "reset", "--mixed", "HEAD"); err != nil {
		return false, fmt.Errorf("git reset: %w", err)
	}

	return true, nil
}

// fileHistory reads git log for a file and returns structured metadata.
func (s *Store) fileHistory(ctx context.Context, filename string) (*FileHistory, error) {

	// Check if the file has any commits.
	var countBuf bytes.Buffer
	if err := s.git(ctx, nil, &countBuf,
		"rev-list", "--count", "HEAD", "--", filename); err != nil {
		// No commits yet (empty repo or file not tracked).
		return &FileHistory{}, nil
	}
	count, err := strconv.Atoi(strings.TrimSpace(countBuf.String()))
	if err != nil || count == 0 {
		return &FileHistory{}, nil
	}

	// Get recent commits.
	limit := min(count, recentEditsCap)
	format := "%H%x00%s%x00%aI"
	var logBuf bytes.Buffer
	if err := s.git(ctx, nil, &logBuf,
		"log", fmt.Sprintf("--format=%s", format),
		fmt.Sprintf("-n%d", limit),
		"HEAD", "--", filename); err != nil {
		return &FileHistory{RevisionCount: count}, nil
	}

	var edits []EditEntry
	for line := range strings.SplitSeq(strings.TrimSpace(logBuf.String()), "\n") {
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, "\x00", 3)
		if len(parts) != 3 {
			continue
		}
		t, err := time.Parse(time.RFC3339, parts[2])
		if err != nil {
			s.logger.Warn("skipping commit with unparseable timestamp",
				"hash", parts[0], "raw", parts[2], "error", err)
			continue
		}
		edits = append(edits, EditEntry{
			Hash:      parts[0],
			Message:   parts[1],
			Timestamp: t,
		})
	}

	hist := &FileHistory{
		RevisionCount: count,
		RecentEdits:   edits,
	}

	if len(edits) > 0 {
		hist.LastModified = edits[0].Timestamp
		hist.LastMessage = edits[0].Message
	}

	return hist, nil
}

// git executes a git command in the store's repository. If stdin is
// non-nil, it is piped to the command. If stdout is non-nil, the
// command's stdout is written there; otherwise it is discarded.
func (s *Store) git(ctx context.Context, stdin *bytes.Reader, stdout *bytes.Buffer, args ...string) error {
	cmd := exec.CommandContext(ctx, "git", append([]string{"-C", s.path}, args...)...)

	if stdin != nil {
		cmd.Stdin = stdin
	}

	var stderr bytes.Buffer
	cmd.Stderr = &stderr

	if stdout != nil {
		cmd.Stdout = stdout
	}

	if err := cmd.Run(); err != nil {
		errMsg := strings.TrimSpace(stderr.String())
		if errMsg != "" {
			return fmt.Errorf("%w: %s", err, errMsg)
		}
		return err
	}

	return nil
}
