package codegraph

import (
	"archive/tar"
	"archive/zip"
	"bytes"
	"compress/gzip"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"strings"
)

const (
	// Version is the pinned CodeGraph release fetched on first use. Keep in sync
	// with CODEGRAPH_VERSION in the Makefile and .github/workflows.
	Version = "v0.9.7"
	cgRepo  = "colbymchenry/codegraph"
)

// CacheDir is where the CodeGraph bundle is unpacked on first use:
// <user cache>/reasonix/codegraph/<Version>. Versioned so a bump installs cleanly
// beside the old one. REASONIX_CACHE_DIR overrides the base (relocate the cache,
// or isolate it in tests). Empty when no cache/config dir resolves.
func CacheDir() string {
	base := os.Getenv("REASONIX_CACHE_DIR")
	if base == "" {
		var err error
		if base, err = os.UserCacheDir(); err != nil {
			if base, err = os.UserConfigDir(); err != nil {
				return ""
			}
		}
		base = filepath.Join(base, "reasonix")
	}
	return filepath.Join(base, "codegraph", Version)
}

// cached returns the launcher path inside CacheDir when the bundle is present.
func cached() (string, bool) {
	dir := CacheDir()
	if dir == "" {
		return "", false
	}
	for _, rel := range launcherNames() {
		if p := filepath.Join(dir, rel); isExec(p) {
			return p, true
		}
	}
	return "", false
}

// assetName is CodeGraph's release asset for the running platform (it names them
// codegraph-<darwin|linux|win32>-<x64|arm64>.<tar.gz|zip>).
func assetName() string {
	osName := map[string]string{"darwin": "darwin", "linux": "linux", "windows": "win32"}[runtime.GOOS]
	if osName == "" {
		osName = runtime.GOOS
	}
	arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[runtime.GOARCH]
	if arch == "" {
		arch = runtime.GOARCH
	}
	ext := "tar.gz"
	if runtime.GOOS == "windows" {
		ext = "zip"
	}
	return fmt.Sprintf("codegraph-%s-%s.%s", osName, arch, ext)
}

// Install downloads and unpacks the CodeGraph bundle into CacheDir on first use,
// verifying it against the release's SHA256SUMS, then returns the launcher path.
// It is idempotent: a present cache is returned untouched. log, if non-nil,
// receives a couple of progress lines. The extraction is staged in a temp dir and
// atomically renamed into place, so a cancelled or failed run leaves no partial
// install behind.
func Install(ctx context.Context, log func(string)) (string, error) {
	if p, ok := cached(); ok {
		return p, nil
	}
	dir := CacheDir()
	if dir == "" {
		return "", fmt.Errorf("codegraph: no cache directory available")
	}
	asset := assetName()
	logf(log, "codegraph: downloading %s (%s, one-time)…", asset, Version)

	base := fmt.Sprintf("https://github.com/%s/releases/download/%s", cgRepo, Version)
	sums, err := httpGet(ctx, base+"/SHA256SUMS")
	if err != nil {
		return "", fmt.Errorf("codegraph: fetch checksums: %w", err)
	}
	want, err := sha256For(string(sums), asset)
	if err != nil {
		return "", err
	}
	data, err := httpGet(ctx, base+"/"+asset)
	if err != nil {
		return "", fmt.Errorf("codegraph: download %s: %w", asset, err)
	}
	if got := sha256.Sum256(data); hex.EncodeToString(got[:]) != want {
		return "", fmt.Errorf("codegraph: checksum mismatch for %s", asset)
	}

	parent := filepath.Dir(dir)
	if err := os.MkdirAll(parent, 0o755); err != nil {
		return "", err
	}
	tmp, err := os.MkdirTemp(parent, ".dl-")
	if err != nil {
		return "", err
	}
	defer os.RemoveAll(tmp)

	if strings.HasSuffix(asset, ".zip") {
		err = extractZip(data, tmp)
	} else {
		err = extractTarGz(data, tmp)
	}
	if err != nil {
		return "", fmt.Errorf("codegraph: extract: %w", err)
	}
	// The archive holds a single top-level dir (codegraph-<target>/). Promote it
	// to the versioned cache dir so the launcher lands at <dir>/bin/codegraph.
	root, err := singleChild(tmp)
	if err != nil {
		return "", err
	}
	if err := os.Rename(root, dir); err != nil {
		// A concurrent session may have won the race; accept its install.
		if p, ok := cached(); ok {
			return p, nil
		}
		return "", err
	}
	p, ok := cached()
	if !ok {
		return "", fmt.Errorf("codegraph: launcher not found after install (unexpected bundle layout)")
	}
	logf(log, "codegraph: installed to %s", dir)
	return p, nil
}

func httpGet(ctx context.Context, url string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
	}
	return io.ReadAll(resp.Body)
}

// sha256For returns the hex checksum recorded for name in a SHA256SUMS body
// (lines of "<hex>  <name>").
func sha256For(sums, name string) (string, error) {
	for _, line := range strings.Split(sums, "\n") {
		f := strings.Fields(line)
		if len(f) == 2 && f[1] == name {
			return f[0], nil
		}
	}
	return "", fmt.Errorf("codegraph: %s not listed in SHA256SUMS", name)
}

// safeJoin joins dir and a (possibly hostile) archive entry name, rejecting any
// path that would escape dir — the zip-slip / tar-slip guard.
func safeJoin(dir, name string) (string, error) {
	p := filepath.Join(dir, name)
	if p != dir && !strings.HasPrefix(p, dir+string(os.PathSeparator)) {
		return "", fmt.Errorf("unsafe path %q in archive", name)
	}
	return p, nil
}

func extractTarGz(data []byte, dir string) error {
	gz, err := gzip.NewReader(bytes.NewReader(data))
	if err != nil {
		return err
	}
	defer gz.Close()
	tr := tar.NewReader(gz)
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		target, err := safeJoin(dir, hdr.Name)
		if err != nil {
			return err
		}
		switch hdr.Typeflag {
		case tar.TypeDir:
			if err := os.MkdirAll(target, 0o755); err != nil {
				return err
			}
		case tar.TypeSymlink:
			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
				return err
			}
			_ = os.Remove(target)
			if err := os.Symlink(hdr.Linkname, target); err != nil {
				return err
			}
		case tar.TypeReg:
			if err := writeFileFromReader(target, tr, hdr.FileInfo().Mode()); err != nil {
				return err
			}
		}
	}
}

func extractZip(data []byte, dir string) error {
	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
	if err != nil {
		return err
	}
	for _, f := range zr.File {
		target, err := safeJoin(dir, f.Name)
		if err != nil {
			return err
		}
		if f.FileInfo().IsDir() {
			if err := os.MkdirAll(target, 0o755); err != nil {
				return err
			}
			continue
		}
		rc, err := f.Open()
		if err != nil {
			return err
		}
		err = writeFileFromReader(target, rc, f.Mode())
		rc.Close()
		if err != nil {
			return err
		}
	}
	return nil
}

func writeFileFromReader(target string, r io.Reader, mode os.FileMode) error {
	if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
		return err
	}
	f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
	if err != nil {
		return err
	}
	if _, err := io.Copy(f, r); err != nil {
		f.Close()
		return err
	}
	return f.Close()
}

// singleChild returns the sole entry in dir (the archive's top-level
// codegraph-<target>/ directory).
func singleChild(dir string) (string, error) {
	ents, err := os.ReadDir(dir)
	if err != nil {
		return "", err
	}
	if len(ents) != 1 || !ents[0].IsDir() {
		return "", fmt.Errorf("codegraph: expected one top-level dir in archive, got %d entries", len(ents))
	}
	return filepath.Join(dir, ents[0].Name()), nil
}

func logf(log func(string), format string, a ...any) {
	if log != nil {
		log(fmt.Sprintf(format, a...))
	}
}
