package main

import (
	"archive/tar"
	"bufio"
	"compress/gzip"
	"crypto/md5"
	"encoding/hex"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"
)

const manifestFilename = ".md5-manifest.txt"

func main() {
	// Define command line flags
	sourceDir := flag.String("source", "", "Source directory to compress")
	outputFile := flag.String("output", "output.tar.gz", "Output tarball filename")
	prefixDir := flag.String("prefix", "myapp", "Directory prefix in tarball")
	extractMode := flag.Bool("extract", false, "Extract mode (instead of create)")
	extractDir := flag.String("extractdir", "", "Directory to extract to (default: current directory)")
	verifyOnly := flag.Bool("verify", false, "Only verify hash integrity without extraction")
	verboseMode := flag.Bool("verbose", false, "Enable verbose output")
	excludePatterns := flag.String("exclude", "", "Comma-separated list of patterns to exclude (e.g. \"*.log,*.tmp,temp/\")")
	flag.Parse()

	if *extractMode {
		if *outputFile == "output.tar.gz" && len(flag.Args()) > 0 {
			*outputFile = flag.Args()[0]
		}

		if *outputFile == "" {
			fmt.Println("Please specify a tarball to extract using -output or as a positional argument")
			flag.Usage()
			os.Exit(1)
		}

		// If extract directory is not specified, use current directory
		extractTo := *extractDir
		if extractTo == "" {
			extractTo = "."
		}

		err := extractTarball(*outputFile, extractTo, *verifyOnly, *verboseMode)
		if err != nil {
			fmt.Printf("Error extracting tarball: %v\n", err)
			os.Exit(1)
		}
	} else {
		if *sourceDir == "" {
			fmt.Println("Please specify a source directory using -source")
			flag.Usage()
			os.Exit(1)

		}

		// Process exclude patterns
		var excludeRegexps []*regexp.Regexp
		if *excludePatterns != "" {
			patterns := strings.Split(*excludePatterns, ",")
			for _, pattern := range patterns {
				// Trim spaces
				pattern = strings.TrimSpace(pattern)
				if pattern == "" {
					continue
				}

				// Convert glob pattern to regexp
				regexPattern := globToRegexp(pattern)
				re, err := regexp.Compile(regexPattern)
				if err != nil {
					fmt.Printf("Invalid exclude pattern %q: %v\n", pattern, err)
					os.Exit(1)
				}
				excludeRegexps = append(excludeRegexps, re)
			}
		}

		err := createTarball(*sourceDir, *outputFile, *prefixDir, excludeRegexps, *verboseMode)
		if err != nil {
			fmt.Printf("Error creating tarball: %v\n", err)
			os.Exit(1)
		}

		fmt.Printf("Successfully created %s with prefix %s\n", *outputFile, *prefixDir)
	}
}

// globToRegexp converts a glob pattern (*.log) to a regexp pattern (.*\.log$)
func globToRegexp(pattern string) string {
	// Escape special regexp chars that aren't special in glob
	pattern = regexp.QuoteMeta(pattern)

	// Convert glob * to regex .*
	pattern = strings.ReplaceAll(pattern, "\\*", ".*")

	// Convert glob ? to regex .
	pattern = strings.ReplaceAll(pattern, "\\?", ".")

	// Ensure pattern matches the entire filename
	if strings.HasSuffix(pattern, "/") {
		// For directory patterns, match any path containing this directory
		pattern = pattern + ".*"
	} else {
		// For file patterns, match at the end of the path
		pattern = pattern + "$"
	}

	return pattern
}

// calcFileMD5 calculates the MD5 hash of a file
func calcFileMD5(filePath string) (string, error) {
	file, err := os.Open(filePath)
	if err != nil {
		return "", err
	}
	defer file.Close()

	hash := md5.New()
	if _, err := io.Copy(hash, file); err != nil {
		return "", err
	}

	return hex.EncodeToString(hash.Sum(nil)), nil
}

func createTarball(sourceDir, outputFile, prefix string, excludePatterns []*regexp.Regexp, verboseMode bool) error {
	// Resolve absolute path of source directory
	absSourceDir, err := filepath.Abs(sourceDir)
	if err != nil {
		return err
	}

	if verboseMode {
		fmt.Printf("Creating tarball from directory: %s\n", absSourceDir)
		fmt.Printf("Output file: %s\n", outputFile)
		if prefix != "" {
			fmt.Printf("Using prefix: %s\n", prefix)
		}
		if len(excludePatterns) > 0 {
			fmt.Println("Using exclude patterns:")
			for i, pattern := range excludePatterns {
				fmt.Printf("  %d: %s\n", i+1, pattern)
			}
		}
	}

	// Create the output file
	out, err := os.Create(outputFile)
	if err != nil {
		return err
	}
	defer out.Close()

	// Create gzip writer
	if verboseMode {
		fmt.Println("Creating compressed archive...")
	}
	gw := gzip.NewWriter(out)
	defer gw.Close()

	// Create tar writer
	tw := tar.NewWriter(gw)
	defer tw.Close()

	// Create a map to store file hashes
	hashes := make(map[string]string)
	fileCount := 0
	skippedCount := 0

	// Walk through the source directory
	err = filepath.Walk(absSourceDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		fileCount++
		if verboseMode && fileCount%100 == 0 {
			fmt.Printf("Processed %d files...\n", fileCount)
		}

		// Get the relative path
		relPath, err := filepath.Rel(absSourceDir, path)
		if err != nil {
			return err
		}

		// Skip the root directory
		if relPath == "." {
			return nil
		}

		// Check if file matches any exclude patterns
		if len(excludePatterns) > 0 {
			relPathForward := filepath.ToSlash(relPath)
			for _, pattern := range excludePatterns {
				if pattern.MatchString(relPathForward) {
					if verboseMode {
						fmt.Printf("Excluding: %s (matched pattern)\n", relPathForward)
					}
					skippedCount++
					if info.IsDir() {
						return filepath.SkipDir
					}
					return nil
				}
			}
		}

		// Add prefix if specified
		if prefix != "" {
			relPath = filepath.Join(prefix, relPath)
		}

		// Create header
		header, err := tar.FileInfoHeader(info, "")
		if err != nil {
			return err
		}

		// Set the name to be the relative path (with prefix if specified)
		header.Name = filepath.ToSlash(relPath)

		// Handle symlinks
		if info.Mode()&os.ModeSymlink != 0 {
			linkTarget, err := os.Readlink(path)
			if err != nil {
				return err
			}
			header.Linkname = linkTarget
			header.Typeflag = tar.TypeSymlink

			if verboseMode {
				fmt.Printf("Adding symlink: %s -> %s\n", header.Name, linkTarget)
			}

			// Write the header to the tarball
			if err := tw.WriteHeader(header); err != nil {
				return err
			}
			return nil
		}

		// Skip directories in hash calculation, but include in tarball
		if info.IsDir() {
			if verboseMode {
				fmt.Printf("Adding directory: %s\n", header.Name)
			}
			// Write the header to the tarball
			if err := tw.WriteHeader(header); err != nil {
				return err
			}
			return nil
		}

		// Skip the manifest file if it already exists in the source dir
		if filepath.Base(path) == manifestFilename && filepath.Dir(path) == absSourceDir {
			if verboseMode {
				fmt.Printf("Skipping existing manifest file: %s\n", path)
			}
			return nil
		}

		if verboseMode {
			fmt.Printf("Adding file: %s\n", header.Name)
		}

		// Write the header to the tarball
		if err := tw.WriteHeader(header); err != nil {
			return err
		}

		// Open the file for reading
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		defer file.Close()

		// Create a hash writer to calculate MD5 while copying
		h := md5.New()
		multiWriter := io.MultiWriter(tw, h)

		// Copy file content to both the tarball and hash function
		if _, err := io.Copy(multiWriter, file); err != nil {
			return err
		}

		// Store the hash
		hashes[header.Name] = hex.EncodeToString(h.Sum(nil))

		return nil
	})

	if err != nil {
		return err
	}

	if verboseMode {
		fmt.Printf("Added %d files to tarball\n", fileCount-skippedCount)
		if skippedCount > 0 {
			fmt.Printf("Excluded %d files/directories\n", skippedCount)
		}
		fmt.Println("Creating MD5 manifest...")
	}

	// Create MD5 manifest
	var manifest strings.Builder
	for path, hash := range hashes {
		fmt.Fprintf(&manifest, "%s  %s\n", hash, path)
	}

	// Create header for the manifest
	header := &tar.Header{
		Name:    manifestFilename,
		Mode:    0644,
		Size:    int64(manifest.Len()),
		ModTime: time.Now(),
	}

	if verboseMode {
		fmt.Printf("Adding manifest with %d entries\n", len(hashes))
	}

	// Write the manifest header to the tarball
	if err := tw.WriteHeader(header); err != nil {
		return err
	}

	// Write the manifest content to the tarball
	if _, err := io.WriteString(tw, manifest.String()); err != nil {
		return err
	}

	// Close the writers
	if err := tw.Close(); err != nil {
		return err
	}
	if err := gw.Close(); err != nil {
		return err
	}

	if verboseMode {
		fmt.Printf("Tarball created successfully: %s\n", outputFile)
	} else {
		fmt.Println("Tarball created successfully!")
	}

	return nil
}

func extractTarball(tarballPath, extractDir string, verifyOnly, verboseMode bool) error {
	// Open the tarball
	if verboseMode {
		fmt.Printf("Opening tarball: %s\n", tarballPath)
	}

	file, err := os.Open(tarballPath)
	if err != nil {
		return err
	}
	defer file.Close()

	// Create gzip reader
	gr, err := gzip.NewReader(file)
	if err != nil {
		return err
	}
	defer gr.Close()

	// Create tar reader
	tr := tar.NewReader(gr)

	// Create a map to store expected MD5 hashes from the manifest
	expectedHashes := make(map[string]string)
	// First pass: Find and parse the manifest file
	tempDir := ""
	if !verifyOnly {
		// Create a temporary directory for extraction
		if verboseMode {
			fmt.Printf("Creating temporary extraction directory in: %s\n", extractDir)
		}
		tempDir, err = os.MkdirTemp(extractDir, "tarballer-extract-")
		if err != nil {
			return err
		}
	}

	// Extract files to get the manifest
	fileCount := 0

	if verboseMode {
		fmt.Println("First pass: Looking for manifest file...")
	}

	for {
		header, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			if tempDir != "" {
				os.RemoveAll(tempDir)
			}
			return err
		}

		// Skip directories in first pass, we only want to find the manifest
		if header.Typeflag == tar.TypeDir {
			continue
		}

		fileCount++
		if verboseMode && fileCount%100 == 0 {
			fmt.Printf("Processed %d files while searching for manifest...\n", fileCount)
		}

		// Check if this is the manifest file
		if filepath.Base(header.Name) == manifestFilename && filepath.Dir(header.Name) == "." {
			if verboseMode {
				fmt.Println("Found manifest file, parsing hashes...")
			}

			// Read the manifest content
			var content strings.Builder
			if _, err := io.Copy(&content, tr); err != nil {
				if tempDir != "" {
					os.RemoveAll(tempDir)
				}
				return err
			}

			// Parse the manifest to get expected hashes
			hashCount := 0
			scanner := bufio.NewScanner(strings.NewReader(content.String()))
			for scanner.Scan() {
				line := scanner.Text()
				parts := strings.SplitN(line, "  ", 2)
				if len(parts) == 2 {
					hash := parts[0]
					path := parts[1]
					expectedHashes[path] = hash
					hashCount++
				}
			}

			if verboseMode {
				fmt.Printf("Parsed %d hash entries from manifest\n", hashCount)
			}

			if err := scanner.Err(); err != nil {
				if tempDir != "" {
					os.RemoveAll(tempDir)
				}
				return fmt.Errorf("error parsing manifest: %v", err)
			}

			continue
		}

		// If we're only verifying, skip extraction
		if verifyOnly {
			continue
		}

		// Extract to temp dir to verify hashes
		target := filepath.Join(tempDir, header.Name)

		if verboseMode {
			fmt.Printf("Extracting (first pass): %s\n", header.Name)
		}

		// Create directory if needed
		if header.Typeflag == tar.TypeDir {
			if err := os.MkdirAll(target, 0755); err != nil {
				os.RemoveAll(tempDir)
				return err
			}
			continue
		}

		// Create parent directory if it doesn't exist
		if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
			os.RemoveAll(tempDir)
			return err
		}

		// Handle symlinks
		if header.Typeflag == tar.TypeSymlink {
			if verboseMode {
				fmt.Printf("Creating symlink: %s -> %s\n", target, header.Linkname)
			}
			if err := os.Symlink(header.Linkname, target); err != nil {
				os.RemoveAll(tempDir)
				return err
			}
			continue
		}

		// Create regular file
		f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
		if err != nil {
			os.RemoveAll(tempDir)
			return err
		}

		// Copy file content
		if _, err := io.Copy(f, tr); err != nil {
			f.Close()
			os.RemoveAll(tempDir)
			return err
		}
		f.Close()
	}

	// If no manifest was found
	if len(expectedHashes) == 0 {
		if tempDir != "" {
			os.RemoveAll(tempDir)
		}
		return fmt.Errorf("no MD5 manifest found in tarball")
	}

	// If we're only verifying, we need to reopen the tarball
	if verifyOnly {
		if verboseMode {
			fmt.Println("Reopening tarball for verification...")
		}
		file.Seek(0, 0)
		gr, err = gzip.NewReader(file)
		if err != nil {
			return err
		}
		defer gr.Close()
		tr = tar.NewReader(gr)
	}

	// Second pass: Verify hashes
	verificationFailed := false
	fileVerified := make(map[string]bool)
	missingFiles := []string{}

	if verboseMode {
		fmt.Println("Second pass: Verifying file integrity...")
	}

	if verifyOnly {
		// Extract to temp dir for verification
		if verboseMode {
			fmt.Println("Creating temporary directory for verification only...")
		}
		tempDir, err = os.MkdirTemp(extractDir, "tarballer-verify-")
		if err != nil {
			return err
		}
		defer os.RemoveAll(tempDir)
	}

	fileCount = 0
	for {
		header, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			if tempDir != "" && !verifyOnly {
				os.RemoveAll(tempDir)
			}
			return err
		}

		fileCount++
		if verboseMode && fileCount%100 == 0 {
			fmt.Printf("Verified %d files...\n", fileCount)
		}

		// Skip directories and the manifest file for verification
		if header.Typeflag == tar.TypeDir || (filepath.Base(header.Name) == manifestFilename && filepath.Dir(header.Name) == ".") {
			continue
		}

		// Skip symlinks for hash verification
		if header.Typeflag == tar.TypeSymlink {
			continue
		}

		// Check if this file has an expected hash
		expectedHash, exists := expectedHashes[header.Name]
		if !exists {
			if verboseMode {
				fmt.Printf("Warning: File %s not found in manifest\n", header.Name)
			}
			continue
		}

		// If verifyOnly, we need to extract the file to verify its hash
		if verifyOnly {
			target := filepath.Join(tempDir, header.Name)

			if verboseMode {
				fmt.Printf("Extracting for verification: %s\n", header.Name)
			}

			// Create parent directory if it doesn't exist
			if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
				return err
			}

			// Create file
			f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
			if err != nil {
				return err
			}

			// Copy content
			if _, err := io.Copy(f, tr); err != nil {
				f.Close()
				return err
			}
			f.Close()

			// Calculate hash
			actualHash, err := calcFileMD5(target)
			if err != nil {
				return err
			}

			// Verify hash
			if actualHash != expectedHash {
				fmt.Printf("Hash mismatch for %s: expected %s, got %s\n", header.Name, expectedHash, actualHash)
				verificationFailed = true
			} else {
				if verboseMode {
					fmt.Printf("Hash verified: %s\n", header.Name)
				}
				fileVerified[header.Name] = true
			}
		} else {
			// Calculate hash from extracted file
			target := filepath.Join(tempDir, header.Name)
			actualHash, err := calcFileMD5(target)
			if err != nil {
				os.RemoveAll(tempDir)
				return err
			}

			// Verify hash
			if actualHash != expectedHash {
				fmt.Printf("Hash mismatch for %s: expected %s, got %s\n", header.Name, expectedHash, actualHash)
				verificationFailed = true
			} else {
				if verboseMode {
					fmt.Printf("Hash verified: %s\n", header.Name)
				}
				fileVerified[header.Name] = true
			}
		}
	}

	// Check if all files in the manifest were verified
	for path := range expectedHashes {
		if !fileVerified[path] {
			missingFiles = append(missingFiles, path)
		}
	}

	// Only report missing files if verification is required
	if verifyOnly && len(missingFiles) > 0 {
		fmt.Printf("Warning: %d files in manifest were not found in the tarball\n", len(missingFiles))
		// Optionally, print first few missing files as examples
		if len(missingFiles) > 0 {
			maxExamples := 5
			if len(missingFiles) < maxExamples {
				maxExamples = len(missingFiles)
			}
			fmt.Println("Examples of missing files:")
			for i := 0; i < maxExamples; i++ {
				fmt.Printf("  - %s\n", missingFiles[i])
			}
			if len(missingFiles) > maxExamples {
				fmt.Printf("  - ...and %d more\n", len(missingFiles)-maxExamples)
			}
		}
	}

	// If verification failed or we're only verifying, we're done
	if verificationFailed {
		if !verifyOnly {
			os.RemoveAll(tempDir)
		}
		return fmt.Errorf("hash verification failed for one or more files")
	}

	if verifyOnly {
		fmt.Println("All files verified successfully!")
		return nil
	}

	// Move the extracted files to the final destination (excluding manifest if needed)
	if verboseMode {
		fmt.Println("Moving verified files to final destination...")
	}

	files, err := os.ReadDir(tempDir)
	if err != nil {
		os.RemoveAll(tempDir)
		return err
	}

	// Create the final extract directory if it doesn't exist
	if err := os.MkdirAll(extractDir, 0755); err != nil {
		os.RemoveAll(tempDir)
		return err
	}

	movedCount := 0
	// Move each top-level extracted item
	for _, f := range files {
		source := filepath.Join(tempDir, f.Name())
		dest := filepath.Join(extractDir, f.Name())

		// Skip the manifest file if needed
		if f.Name() == manifestFilename {
			if verboseMode {
				fmt.Println("Skipping manifest file in final destination")
			}
			continue
		}

		if verboseMode {
			fmt.Printf("Moving: %s -> %s\n", source, dest)
		}

		// If destination already exists, remove it
		if _, err := os.Stat(dest); err == nil {
			if err := os.RemoveAll(dest); err != nil {
				os.RemoveAll(tempDir)
				return err
			}
		}

		// Move the file
		if err := os.Rename(source, dest); err != nil {
			// If rename fails (e.g., across devices), try copying
			if verboseMode {
				fmt.Printf("Direct move failed, using recursive copy for: %s\n", f.Name())
			}

			err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
				if err != nil {
					return err
				}

				// Get relative path
				relPath, err := filepath.Rel(source, path)
				if err != nil {
					return err
				}

				targetPath := filepath.Join(dest, relPath)

				// Create directory
				if info.IsDir() {
					return os.MkdirAll(targetPath, info.Mode())
				}

				// Handle symlinks
				if info.Mode()&os.ModeSymlink != 0 {
					linkTarget, err := os.Readlink(path)
					if err != nil {
						return err
					}
					return os.Symlink(linkTarget, targetPath)
				}

				// Copy file
				srcFile, err := os.Open(path)
				if err != nil {
					return err
				}
				defer srcFile.Close()

				// Create destination file
				dstFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, info.Mode())
				if err != nil {
					return err
				}
				defer dstFile.Close()

				// Copy content
				_, err = io.Copy(dstFile, srcFile)
				return err
			})

			if err != nil {
				os.RemoveAll(tempDir)
				return err
			}
		}

		movedCount++
	}

	// Clean up temp directory
	os.RemoveAll(tempDir)

	if verboseMode {
		fmt.Printf("Extraction complete: %d files extracted and verified, %d files moved to final destination\n", fileCount, movedCount)
	} else {
		fmt.Println("Extraction completed and verified successfully!")
	}

	return nil
}
