package packs

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

func ParsePackSourceRef(raw string) (PackSourceRef, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return PackSourceRef{}, fmt.Errorf("empty pack source")
	}
	kind, uri, ok := strings.Cut(raw, ":")
	if !ok {
		if looksLikeLocalPath(raw) {
			return PackSourceRef{Kind: PackSourceKindLocalPath, URI: raw}, nil
		}
		return PackSourceRef{}, fmt.Errorf("pack source %q must use kind:value syntax", raw)
	}
	kind = strings.TrimSpace(kind)
	uri = strings.TrimSpace(uri)
	if uri == "" {
		return PackSourceRef{}, fmt.Errorf("pack source %q has empty URI", raw)
	}
	switch kind {
	case PackSourceKindOfficialRepo, PackSourceKindLocalPath:
		return PackSourceRef{Kind: kind, URI: uri}, nil
	case PackSourceKindReleaseRepo:
		if err := validateReleaseRepoSourceURI(uri); err != nil {
			return PackSourceRef{}, err
		}
		return PackSourceRef{Kind: kind, URI: uri}, nil
	case packSourceKindInRepoLegacy:
		return PackSourceRef{Kind: PackSourceKindOfficialRepo, URI: uri}, nil
	default:
		return PackSourceRef{}, fmt.Errorf("unsupported pack source kind %q", kind)
	}
}

func looksLikeLocalPath(raw string) bool {
	return strings.HasPrefix(raw, ".") || strings.HasPrefix(raw, "/") ||
		strings.ContainsAny(raw, `/\`)
}

// ResolvePackSourceWithOptions resolves local_path sources directly and
// official_repo:<name> from the configured official pack root. It intentionally
// does not fall back to KnownPackByName or virtualize in-binary packs.
func ResolvePackSourceWithOptions(raw string, opts PackSourceOptions) (*ResolvedPackSource, []PackDiagnostic) {
	ref, err := ParsePackSourceRef(raw)
	if err != nil {
		return nil, []PackDiagnostic{diag("source.invalid", "", err.Error())}
	}

	switch ref.Kind {
	case PackSourceKindLocalPath:
		return resolveLocalPathSource(ref, ref.URI)
	case PackSourceKindReleaseRepo:
		registered, ok := releaseRepoSourceFor(ref.URI, opts)
		if !ok {
			return nil, []PackDiagnostic{diag("source.release_repo.authority_missing", "source", fmt.Sprintf("release_repo source %q has no exact operation authority", ref.URI))}
		}
		if err := ValidateReleaseRepoRevision(registered.Revision); err != nil {
			return nil, []PackDiagnostic{diag("source.release_repo.revision", "source", err.Error())}
		}
		return resolveLocalPathSourceAtRevision(ref, registered.Root, registered.Revision)
	case PackSourceKindOfficialRepo:
		root := strings.TrimSpace(opts.OfficialRepoRoot)
		if root == "" {
			return nil, []PackDiagnostic{diag("source.official_repo.root_missing", "source", officialRepoRootHelp())}
		}
		if err := validateOfficialRepoPackName(ref.URI); err != nil {
			return nil, []PackDiagnostic{diag("source.official_repo.invalid_name", "source", err.Error())}
		}
		return resolveOfficialRepoSource(ref, root, opts, "")
	default:
		return nil, []PackDiagnostic{diag("source.unsupported_kind", "source", fmt.Sprintf("unsupported source kind %q", ref.Kind))}
	}
}

func resolveStoredPackSourceWithOptions(sourceKind, sourceURI, sourceRevision string, opts PackSourceOptions) (*ResolvedPackSource, []PackDiagnostic) {
	sourceKind = strings.TrimSpace(sourceKind)
	sourceURI = strings.TrimSpace(sourceURI)
	sourceRevision = strings.TrimSpace(sourceRevision)

	if sourceKind == packSourceKindInRepoLegacy {
		sourceKind = PackSourceKindOfficialRepo
	}

	ref := PackSourceRef{Kind: sourceKind, URI: sourceURI}
	switch sourceKind {
	case PackSourceKindLocalPath:
		return resolveLocalPathSource(ref, sourceURI)
	case PackSourceKindReleaseRepo:
		registered, ok := releaseRepoSourceForStoredRevision(sourceURI, sourceRevision, opts)
		if !ok {
			return nil, []PackDiagnostic{diag("source.release_repo.authority_missing", "source", fmt.Sprintf("release_repo source %q has no exact operation authority", sourceURI))}
		}
		requestedRevision := strings.TrimSpace(sourceRevision)
		if requestedRevision == "" {
			requestedRevision = strings.TrimSpace(registered.Revision)
		}
		if requestedRevision != strings.TrimSpace(registered.Revision) {
			return nil, []PackDiagnostic{diag("source.release_repo.revision_mismatch", "source", fmt.Sprintf("release_repo source %q stored revision does not match its exact operation authority", sourceURI))}
		}
		if err := ValidateReleaseRepoRevision(requestedRevision); err != nil {
			return nil, []PackDiagnostic{diag("source.release_repo.revision", "source", err.Error())}
		}
		return resolveLocalPathSourceAtRevision(ref, registered.Root, requestedRevision)
	case PackSourceKindOfficialRepo:
		root := strings.TrimSpace(opts.OfficialRepoRoot)
		if root == "" {
			return nil, []PackDiagnostic{diag("source.official_repo.root_missing", "source", officialRepoRootHelp())}
		}
		if err := validateOfficialRepoPackName(sourceURI); err != nil {
			return nil, []PackDiagnostic{diag("source.official_repo.invalid_name", "source", err.Error())}
		}
		return resolveOfficialRepoSource(ref, root, opts, sourceRevision)
	default:
		return nil, []PackDiagnostic{diag("source.unsupported_kind", "source", fmt.Sprintf("unsupported source kind %q", sourceKind))}
	}
}

func releaseRepoSourceFor(uri string, opts PackSourceOptions) (ReleaseRepoSource, bool) {
	uri = strings.TrimSpace(uri)
	if uri == "" || len(opts.ReleaseRepoSources) == 0 {
		return ReleaseRepoSource{}, false
	}
	if source, ok := opts.ReleaseRepoSources[uri]; ok {
		return source, true
	}
	return ReleaseRepoSource{}, false
}

func releaseRepoSourceForStoredRevision(uri, revision string, opts PackSourceOptions) (ReleaseRepoSource, bool) {
	uri = strings.TrimSpace(uri)
	revision = strings.TrimSpace(revision)
	if source, ok := releaseRepoSourceFor(uri, opts); ok &&
		(revision == "" || revision == strings.TrimSpace(source.Revision)) {
		return source, true
	}
	if uri == "" || revision == "" || len(opts.ReleaseRepoStoredRevisionSources) == 0 {
		return ReleaseRepoSource{}, false
	}
	source, ok := opts.ReleaseRepoStoredRevisionSources[releaseRepoStoredRevisionAuthorityKey(uri, revision)]
	if !ok || strings.TrimSpace(source.Revision) != revision {
		return ReleaseRepoSource{}, false
	}
	return source, true
}

func releaseRepoStoredRevisionAuthorityKey(uri, revision string) string {
	return strings.TrimSpace(uri) + "\x00" + strings.TrimSpace(revision)
}

// ValidateReleaseRepoRevision admits only an exact immutable Git object ID;
// symbolic refs, abbreviated hashes, and mixed-case spellings are rejected.
func ValidateReleaseRepoRevision(revision string) error {
	revision = strings.TrimSpace(revision)
	if len(revision) != 40 && len(revision) != 64 {
		return fmt.Errorf("release_repo revision must be one exact 40- or 64-character lowercase hexadecimal commit")
	}
	for _, char := range revision {
		if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
			return fmt.Errorf("release_repo revision must be one exact 40- or 64-character lowercase hexadecimal commit")
		}
	}
	return nil
}

func validateReleaseRepoSourceURI(raw string) error {
	raw = strings.TrimSpace(raw)
	parsed, err := url.ParseRequestURI(raw)
	if err != nil || parsed.Scheme == "" || parsed.User != nil {
		return fmt.Errorf("release_repo source URI %q must be one absolute canonical URI without credentials", raw)
	}
	return nil
}

func resolveOfficialRepoSource(ref PackSourceRef, officialRepoRoot string, opts PackSourceOptions, explicitRevision string) (*ResolvedPackSource, []PackDiagnostic) {
	packRoot := filepath.Join(officialRepoRoot, ref.URI)
	revision := strings.TrimSpace(explicitRevision)
	if revision == "" {
		revision = officialRepoSourceRevisionFor(ref.URI, opts)
	}
	if revision != "" {
		return resolveLocalPathSourceAtRevision(ref, packRoot, revision)
	}
	return resolveLocalPathSource(ref, packRoot)
}

func officialRepoSourceRevisionFor(name string, opts PackSourceOptions) string {
	name = strings.ToLower(strings.TrimSpace(name))
	if name == "" || len(opts.OfficialRepoSourceRevisions) == 0 {
		return ""
	}
	if revision := strings.TrimSpace(opts.OfficialRepoSourceRevisions[name]); revision != "" {
		return revision
	}
	for rawName, revision := range opts.OfficialRepoSourceRevisions {
		if strings.EqualFold(strings.TrimSpace(rawName), name) {
			return strings.TrimSpace(revision)
		}
	}
	return ""
}

func resolveLocalPathSource(ref PackSourceRef, root string) (*ResolvedPackSource, []PackDiagnostic) {
	var diagnostics []PackDiagnostic
	rootAbs, err := filepath.Abs(root)
	if err != nil {
		return nil, []PackDiagnostic{diag("source.path", "source", fmt.Sprintf("resolve source path: %v", err))}
	}
	info, err := os.Stat(rootAbs)
	if err != nil {
		return nil, []PackDiagnostic{diag("source.not_found", "source", fmt.Sprintf("source root %q: %v", root, err))}
	}
	if !info.IsDir() {
		return nil, []PackDiagnostic{diag("source.not_directory", "source", fmt.Sprintf("source root %q is not a directory", root))}
	}

	rootEval, err := filepath.EvalSymlinks(rootAbs)
	if err != nil {
		return nil, []PackDiagnostic{diag("source.root", "source", fmt.Sprintf("resolve source root symlinks: %v", err))}
	}
	manifestPath := filepath.Join(rootAbs, "pack.json")
	resolvedManifestPath, err := filepath.EvalSymlinks(manifestPath)
	if err != nil {
		return nil, []PackDiagnostic{diag("manifest.read", "pack.json", fmt.Sprintf("resolve manifest: %v", err))}
	}
	if !isWithinDir(rootEval, resolvedManifestPath) {
		return nil, []PackDiagnostic{diag("path.symlink_escape", "pack.json", "manifest resolves outside pack root")}
	}
	manifestBytes, err := os.ReadFile(resolvedManifestPath)
	if err != nil {
		return nil, []PackDiagnostic{diag("manifest.read", "pack.json", fmt.Sprintf("read manifest: %v", err))}
	}
	manifest, parseDiagnostics := parsePackManifest(manifestBytes)
	diagnostics = append(diagnostics, parseDiagnostics...)
	resolved := &ResolvedPackSource{
		Kind:          ref.Kind,
		URI:           resolvedSourceURI(ref, rootEval),
		ManifestBytes: manifestBytes,
		Manifest:      manifest,
		Files:         map[string][]byte{},
	}
	if revision, ok := resolveGitBackedPackRevision(rootEval); ok {
		resolved.Revision = revision
	}
	if manifest == nil {
		return resolved, diagnostics
	}

	files, refs, fileDiagnostics := loadManifestPayloadFiles(rootAbs, manifest)
	diagnostics = append(diagnostics, fileDiagnostics...)
	resolved.Files = files
	resolved.FileRefs = refs
	return resolved, diagnostics
}

func resolveLocalPathSourceAtRevision(ref PackSourceRef, root string, revision string) (*ResolvedPackSource, []PackDiagnostic) {
	var diagnostics []PackDiagnostic
	rootAbs, err := filepath.Abs(root)
	if err != nil {
		return nil, []PackDiagnostic{diag("source.path", "source", fmt.Sprintf("resolve source path: %v", err))}
	}
	info, err := os.Stat(rootAbs)
	if err != nil {
		return nil, []PackDiagnostic{diag("source.not_found", "source", fmt.Sprintf("source root %q: %v", root, err))}
	}
	if !info.IsDir() {
		return nil, []PackDiagnostic{diag("source.not_directory", "source", fmt.Sprintf("source root %q is not a directory", root))}
	}

	rootEval, err := filepath.EvalSymlinks(rootAbs)
	if err != nil {
		return nil, []PackDiagnostic{diag("source.root", "source", fmt.Sprintf("resolve source root symlinks: %v", err))}
	}
	repoRoot, packDirRel, err := gitRepoRootAndRelativePath(rootEval)
	if err != nil {
		return nil, []PackDiagnostic{diag("source.revision.unavailable", "source", err.Error())}
	}
	objectType, err := exec.Command("git", "-C", repoRoot, "cat-file", "-t", revision).CombinedOutput()
	if err != nil || strings.TrimSpace(string(objectType)) != "commit" {
		return nil, []PackDiagnostic{diag(
			"source.revision.not_commit", "source",
			fmt.Sprintf("release source revision %q is not an exact Git commit", revision),
		)}
	}

	manifestBytes, err := gitShowFileAtRevision(repoRoot, revision, path.Join(packDirRel, "pack.json"))
	if err != nil {
		return nil, []PackDiagnostic{diag("manifest.read", "pack.json", err.Error())}
	}
	manifest, parseDiagnostics := parsePackManifest(manifestBytes)
	diagnostics = append(diagnostics, parseDiagnostics...)
	resolved := &ResolvedPackSource{
		Kind:          ref.Kind,
		URI:           resolvedSourceURI(ref, rootEval),
		Revision:      strings.TrimSpace(revision),
		ManifestBytes: manifestBytes,
		Manifest:      manifest,
		Files:         map[string][]byte{},
	}
	if manifest == nil {
		return resolved, diagnostics
	}

	files, refs, fileDiagnostics := loadManifestPayloadFilesAtRevision(repoRoot, packDirRel, manifest, revision)
	diagnostics = append(diagnostics, fileDiagnostics...)
	resolved.Files = files
	resolved.FileRefs = refs
	return resolved, diagnostics
}

func parsePackManifest(raw []byte) (*PackManifest, []PackDiagnostic) {
	var diagnostics []PackDiagnostic
	if err := rejectDuplicateJSONKeys(raw, "pack.json"); err != nil {
		diagnostics = append(diagnostics, diag("manifest.duplicate_key", "pack.json", err.Error()))
	}
	dec := json.NewDecoder(bytes.NewReader(raw))
	dec.DisallowUnknownFields()
	var manifest PackManifest
	if err := dec.Decode(&manifest); err != nil {
		diagnostics = append(diagnostics, diag("manifest.invalid_json", "pack.json", err.Error()))
		return nil, diagnostics
	}
	if err := ensureJSONEOF(dec); err != nil {
		diagnostics = append(diagnostics, diag("manifest.invalid_json", "pack.json", err.Error()))
		return nil, diagnostics
	}
	return &manifest, diagnostics
}

func loadManifestPayloadFiles(root string, manifest *PackManifest) (map[string][]byte, []string, []PackDiagnostic) {
	files := map[string][]byte{}
	rawPathByNormalized := map[string]string{}
	var refs []string
	var diagnostics []PackDiagnostic
	rootEval, err := filepath.EvalSymlinks(root)
	if err != nil {
		diagnostics = append(diagnostics, diag("source.root", "source", fmt.Sprintf("resolve source root symlinks: %v", err)))
		return files, refs, diagnostics
	}

	for i, component := range manifest.Components {
		if strings.TrimSpace(component.Path) == "" {
			continue
		}
		componentPath := fmt.Sprintf("components[%d].path", i)
		normalized, err := normalizeManifestPath(component.Path)
		if err != nil {
			diagnostics = append(diagnostics, diag("path.invalid", componentPath, err.Error()))
			continue
		}
		if prev, ok := rawPathByNormalized[normalized]; ok {
			diagnostics = append(diagnostics, diag("path.collision", componentPath, fmt.Sprintf("%q normalizes to %q, already referenced by %q", component.Path, normalized, prev)))
			continue
		}
		rawPathByNormalized[normalized] = component.Path

		payloadPath := filepath.Join(root, filepath.FromSlash(normalized))
		resolvedPayloadPath, err := filepath.EvalSymlinks(payloadPath)
		if err != nil {
			diagnostics = append(diagnostics, diag("payload.read", componentPath, fmt.Sprintf("resolve %q: %v", normalized, err)))
			continue
		}
		if !isWithinDir(rootEval, resolvedPayloadPath) {
			diagnostics = append(diagnostics, diag("path.symlink_escape", componentPath, fmt.Sprintf("%q resolves outside pack root", normalized)))
			continue
		}
		payload, err := os.ReadFile(resolvedPayloadPath)
		if err != nil {
			diagnostics = append(diagnostics, diag("payload.read", componentPath, fmt.Sprintf("read %q: %v", normalized, err)))
			continue
		}
		if shouldValidatePayloadAsJSON(component.Kind, normalized) {
			if err := rejectDuplicateJSONKeys(payload, normalized); err != nil {
				diagnostics = append(diagnostics, diag("payload.duplicate_key", normalized, err.Error()))
			}
			if !json.Valid(payload) {
				diagnostics = append(diagnostics, diag("payload.invalid_json", normalized, "invalid JSON payload"))
			}
		}
		files[normalized] = payload
		refs = append(refs, normalized)
	}
	sort.Strings(refs)
	return files, refs, diagnostics
}

func loadManifestPayloadFilesAtRevision(repoRoot, packDirRel string, manifest *PackManifest, revision string) (map[string][]byte, []string, []PackDiagnostic) {
	files := map[string][]byte{}
	rawPathByNormalized := map[string]string{}
	var refs []string
	var diagnostics []PackDiagnostic

	for i, component := range manifest.Components {
		if strings.TrimSpace(component.Path) == "" {
			continue
		}
		componentPath := fmt.Sprintf("components[%d].path", i)
		normalized, err := normalizeManifestPath(component.Path)
		if err != nil {
			diagnostics = append(diagnostics, diag("path.invalid", componentPath, err.Error()))
			continue
		}
		if prev, ok := rawPathByNormalized[normalized]; ok {
			diagnostics = append(diagnostics, diag("path.collision", componentPath, fmt.Sprintf("%q normalizes to %q, already referenced by %q", component.Path, normalized, prev)))
			continue
		}
		rawPathByNormalized[normalized] = component.Path

		payload, err := gitShowFileAtRevision(repoRoot, revision, path.Join(packDirRel, normalized))
		if err != nil {
			diagnostics = append(diagnostics, diag("payload.read", componentPath, err.Error()))
			continue
		}
		if shouldValidatePayloadAsJSON(component.Kind, normalized) {
			if err := rejectDuplicateJSONKeys(payload, normalized); err != nil {
				diagnostics = append(diagnostics, diag("payload.duplicate_key", normalized, err.Error()))
			}
			if !json.Valid(payload) {
				diagnostics = append(diagnostics, diag("payload.invalid_json", normalized, "invalid JSON payload"))
			}
		}
		files[normalized] = payload
		refs = append(refs, normalized)
	}
	sort.Strings(refs)
	return files, refs, diagnostics
}

func resolveGitBackedPackRevision(rootEval string) (string, bool) {
	out, err := gitCommandOutput(rootEval, "rev-parse", "HEAD")
	if err != nil {
		return "", false
	}
	revision := strings.TrimSpace(out)
	if revision == "" {
		return "", false
	}
	return revision, true
}

func gitRepoRootAndRelativePath(rootEval string) (string, string, error) {
	repoRoot, err := gitCommandOutput(rootEval, "rev-parse", "--show-toplevel")
	if err != nil {
		return "", "", fmt.Errorf("resolve git repo root for %q: %v", rootEval, err)
	}
	repoRoot = strings.TrimSpace(repoRoot)
	if repoRoot == "" {
		return "", "", fmt.Errorf("resolve git repo root for %q: empty output", rootEval)
	}
	rel, err := filepath.Rel(repoRoot, rootEval)
	if err != nil {
		return "", "", fmt.Errorf("resolve pack path within git repo: %v", err)
	}
	rel = filepath.ToSlash(rel)
	if rel == "." {
		rel = ""
	}
	return repoRoot, rel, nil
}

func gitShowFileAtRevision(repoRoot, revision, relPath string) ([]byte, error) {
	relPath = path.Clean(strings.TrimSpace(relPath))
	if relPath == "." || relPath == "" {
		return nil, fmt.Errorf("invalid git path for revision lookup")
	}
	out, err := gitCommandOutput(repoRoot, "show", fmt.Sprintf("%s:%s", strings.TrimSpace(revision), relPath))
	if err != nil {
		return nil, fmt.Errorf("read %q at revision %s: %v", relPath, strings.TrimSpace(revision), err)
	}
	return []byte(out), nil
}

func gitCommandOutput(dir string, args ...string) (string, error) {
	cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
	}
	return string(out), nil
}

func resolvedSourceURI(ref PackSourceRef, rootEval string) string {
	if ref.Kind == PackSourceKindLocalPath {
		return rootEval
	}
	return ref.URI
}
