package service

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/pingcap/agent-git-service/internal/db"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

// StageImportReleaseAssetInput describes an expected raw release-asset upload.
// The caller sends the bytes separately as an io.Reader, never inside JSON.
type StageImportReleaseAssetInput struct {
	ImportJobID uint
	SourceID    string
	Size        int64
	Checksum    string
}

// StageImportReleaseAsset streams a release asset into durable AGS import
// storage. The database stores only immutable metadata and a storage key; the
// binary file remains outside the JSON batch path until its release-asset batch
// is applied.
func (s *Service) StageImportReleaseAsset(ctx context.Context, in StageImportReleaseAssetInput, body io.Reader) (db.ImportStagedAsset, error) {
	in.SourceID = strings.TrimSpace(in.SourceID)
	checksum, err := normalizeImportAssetChecksum(in.Checksum)
	if err != nil {
		return db.ImportStagedAsset{}, err
	}
	if in.ImportJobID == 0 || in.SourceID == "" || len(in.SourceID) > 128 || in.Size < 0 || body == nil {
		return db.ImportStagedAsset{}, fmt.Errorf("%w: import_id, source_id, non-negative size, checksum, and asset body are required", ErrValidation)
	}

	var job db.ImportJob
	if err := s.DBForCtx(ctx).Take(&job, "id = ?", in.ImportJobID).Error; err != nil {
		return db.ImportStagedAsset{}, wrapErr(err)
	}
	maxBytes, err := importReleaseAssetLimit(job)
	if err != nil {
		return db.ImportStagedAsset{}, err
	}
	if in.Size > maxBytes {
		return db.ImportStagedAsset{}, fmt.Errorf("%w: release asset exceeds max_release_asset_bytes (%d > %d)", ErrValidation, in.Size, maxBytes)
	}

	root, err := s.importAssetStorageRoot(ctx)
	if err != nil {
		return db.ImportStagedAsset{}, err
	}
	storageKey := importAssetStorageKey(in.ImportJobID, in.SourceID)
	finalPath := importAssetStoragePath(root, in.ImportJobID, storageKey)
	if err := os.MkdirAll(filepath.Dir(finalPath), 0o750); err != nil {
		return db.ImportStagedAsset{}, err
	}
	tmp, err := os.CreateTemp(filepath.Dir(finalPath), ".upload-*")
	if err != nil {
		return db.ImportStagedAsset{}, err
	}
	tmpPath := tmp.Name()
	defer func() { _ = os.Remove(tmpPath) }()

	hash := sha256.New()
	written, copyErr := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(body, maxBytes+1))
	closeErr := tmp.Close()
	if copyErr != nil {
		return db.ImportStagedAsset{}, copyErr
	}
	if closeErr != nil {
		return db.ImportStagedAsset{}, closeErr
	}
	if written > maxBytes {
		return db.ImportStagedAsset{}, fmt.Errorf("%w: release asset exceeds max_release_asset_bytes", ErrValidation)
	}
	if written != in.Size {
		return db.ImportStagedAsset{}, fmt.Errorf("%w: release asset size mismatch (%d bytes received, expected %d)", ErrValidation, written, in.Size)
	}
	if got := "sha256:" + hex.EncodeToString(hash.Sum(nil)); !checksumEqual(got, checksum) {
		return db.ImportStagedAsset{}, fmt.Errorf("%w: release asset checksum mismatch", ErrValidation)
	}

	asset := db.ImportStagedAsset{
		ImportJobID: in.ImportJobID,
		SourceID:    in.SourceID,
		StorageKey:  storageKey,
		Size:        in.Size,
		Checksum:    checksum,
	}
	linkedFinal := false
	err = s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		var lockedJob db.ImportJob
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&lockedJob, "id = ?", in.ImportJobID).Error; err != nil {
			return err
		}
		if isTerminalImportStatus(lockedJob.Status) {
			return fmt.Errorf("%w: import job is already %s", ErrInvalidState, lockedJob.Status)
		}

		var existing db.ImportStagedAsset
		err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&existing, "import_job_id = ? AND source_id = ?", in.ImportJobID, in.SourceID).Error
		if err == nil {
			if existing.Size != in.Size || !checksumEqual(existing.Checksum, checksum) {
				return fmt.Errorf("%w: staged release asset %q already exists with different content", ErrConflict, in.SourceID)
			}
			existingPath := importAssetStoragePath(root, in.ImportJobID, existing.StorageKey)
			if err := verifyImportAssetFile(existingPath, existing.Size, existing.Checksum); err == nil {
				asset = existing
				return nil
			} else if !errors.Is(err, os.ErrNotExist) {
				return fmt.Errorf("verify existing staged release asset: %w", err)
			}
			asset.ID = existing.ID
		}
		if created, err := linkImportAssetFile(tmpPath, finalPath, in.Size, checksum); err != nil {
			return err
		} else {
			linkedFinal = created
		}
		if asset.ID == 0 {
			return tx.Create(&asset).Error
		}
		return tx.Model(&db.ImportStagedAsset{}).Where("id = ?", asset.ID).Updates(map[string]any{
			"storage_key": storageKey,
			"size":        in.Size,
			"checksum":    checksum,
		}).Error
	})
	if err != nil {
		if linkedFinal {
			_ = os.Remove(finalPath)
		}
		return db.ImportStagedAsset{}, wrapErr(err)
	}
	if asset.ID == 0 {
		if err := s.DBForCtx(ctx).Take(&asset, "import_job_id = ? AND source_id = ?", in.ImportJobID, in.SourceID).Error; err != nil {
			return db.ImportStagedAsset{}, wrapErr(err)
		}
	}
	return asset, nil
}

func importReleaseAssetLimit(job db.ImportJob) (int64, error) {
	var options GitHubImportOptions
	if err := json.Unmarshal([]byte(job.OptionsJSON), &options); err != nil {
		return 0, fmt.Errorf("%w: decode import options: %v", ErrValidation, err)
	}
	normalizeGitHubImportOptions(&options)
	if options.MaxReleaseAssetBytes > defaultMaxReleaseAssetBytes {
		return 0, fmt.Errorf("%w: max_release_asset_bytes exceeds server maximum %d", ErrValidation, defaultMaxReleaseAssetBytes)
	}
	return options.MaxReleaseAssetBytes, nil
}

func (s *Service) importAssetStorageRoot(ctx context.Context) (string, error) {
	git, err := s.GitForCtx(ctx)
	if err != nil {
		return "", fmt.Errorf("%w: Git storage is required for import release assets: %v", ErrInvalidState, err)
	}
	root, err := git.RepoRoot(ctx)
	if err != nil {
		return "", err
	}
	return filepath.Join(root, ".import-assets"), nil
}

func importAssetStorageKey(jobID uint, sourceID string) string {
	sum := sha256.Sum256([]byte(strconv.FormatUint(uint64(jobID), 10) + "\x00" + sourceID))
	return hex.EncodeToString(sum[:])
}

func importAssetStoragePath(root string, jobID uint, storageKey string) string {
	return filepath.Join(root, strconv.FormatUint(uint64(jobID), 10), storageKey)
}

func normalizeImportAssetChecksum(raw string) (string, error) {
	raw = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "sha256:"))
	if len(raw) != sha256.Size*2 {
		return "", fmt.Errorf("%w: release asset checksum must be a SHA-256 hex digest", ErrValidation)
	}
	if _, err := hex.DecodeString(raw); err != nil {
		return "", fmt.Errorf("%w: release asset checksum must be hexadecimal", ErrValidation)
	}
	return "sha256:" + raw, nil
}

func linkImportAssetFile(tmpPath, finalPath string, size int64, checksum string) (bool, error) {
	if err := os.Link(tmpPath, finalPath); err == nil {
		return true, nil
	} else if !errors.Is(err, os.ErrExist) {
		return false, err
	}
	if err := verifyImportAssetFile(finalPath, size, checksum); err != nil {
		return false, fmt.Errorf("%w: staged release asset path already exists with different content", ErrConflict)
	}
	return false, nil
}

func verifyImportAssetFile(path string, expectedSize int64, expectedChecksum string) error {
	file, err := os.Open(path)
	if err != nil {
		return err
	}
	defer file.Close()
	info, err := file.Stat()
	if err != nil {
		return err
	}
	if info.Size() != expectedSize {
		return fmt.Errorf("size %d does not match expected %d", info.Size(), expectedSize)
	}
	hash := sha256.New()
	if _, err := io.Copy(hash, file); err != nil {
		return err
	}
	if got := "sha256:" + hex.EncodeToString(hash.Sum(nil)); !checksumEqual(got, expectedChecksum) {
		return fmt.Errorf("checksum mismatch")
	}
	return nil
}

func (s *Service) readImportStagedAsset(ctx context.Context, asset db.ImportStagedAsset) ([]byte, error) {
	root, err := s.importAssetStorageRoot(ctx)
	if err != nil {
		return nil, err
	}
	path := importAssetStoragePath(root, asset.ImportJobID, asset.StorageKey)
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()
	content, err := io.ReadAll(io.LimitReader(file, asset.Size+1))
	if err != nil {
		return nil, err
	}
	if int64(len(content)) != asset.Size {
		return nil, fmt.Errorf("%w: staged release asset size mismatch", ErrValidation)
	}
	sum := sha256.Sum256(content)
	if !checksumEqual("sha256:"+hex.EncodeToString(sum[:]), asset.Checksum) {
		return nil, fmt.Errorf("%w: staged release asset checksum mismatch", ErrValidation)
	}
	return content, nil
}
