package handlers

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"time"

	"golang.org/x/crypto/bcrypt"
	"xorm.io/xorm"

	"github.com/forgeo/forgebucket/internal/domain/oci"
	"github.com/forgeo/forgebucket/internal/models"
)

// OCIRegistryHandler serves the OCI Distribution API at /v2/.
type OCIRegistryHandler struct {
	db   *xorm.Engine
	reg  *oci.Registry
}

func NewOCIRegistryHandler(db *xorm.Engine, reg *oci.Registry) *OCIRegistryHandler {
	return &OCIRegistryHandler{db: db, reg: reg}
}

// ServeOCI is the catch-all handler mounted at /v2/.
func (h *OCIRegistryHandler) ServeOCI(w http.ResponseWriter, r *http.Request) {
	// GET /v2/ — API version check.
	if r.Method == http.MethodGet && (r.URL.Path == "/v2/" || r.URL.Path == "/v2") {
		w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
		json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
		return
	}

	name, kind, ref := oci.ParseOCIPath(r.URL.Path)
	if name == "" {
		h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "invalid OCI path")
		return
	}

	// Resolve ForgeBucket repository from image name (expected format: owner/repo).
	owner, repoName, found := strings.Cut(name, "/")
	if !found {
		h.ociError(w, http.StatusBadRequest, oci.ErrNameInvalid, "image name must be owner/repo-name")
		return
	}

	var repo models.Repository
	if ok, _ := h.db.Where("name = ?", repoName).Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner).Get(&repo); !ok {
		h.ociError(w, http.StatusNotFound, oci.ErrNameUnknown, "repository not found")
		return
	}

	// Authenticate.
	authedUser := h.basicAuthOCI(r)
	needsAuth := repo.IsPrivate || r.Method != http.MethodGet

	if needsAuth && authedUser == "" {
		w.Header().Set("Www-Authenticate", `Basic realm="ForgeBucket OCI Registry"`)
		h.ociError(w, http.StatusUnauthorized, oci.ErrUnauthorized, "authentication required")
		return
	}
	if authedUser != "" {
		hasWrite := HasPermission(h.db, &repo, authedUser, "write")
		hasRead := HasPermission(h.db, &repo, authedUser, "read")
		if !hasRead {
			h.ociError(w, http.StatusForbidden, oci.ErrDenied, "access denied")
			return
		}
		// Mutations require write access.
		isMut := r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete
		if isMut && !hasWrite {
			h.ociError(w, http.StatusForbidden, oci.ErrDenied, "write access required")
			return
		}
	}

	// Resolve or create OCIRepository row.
	ociRepo, err := h.getOrCreateOCIRepo(repo.ID, name)
	if err != nil {
		h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUnknown, "internal error")
		return
	}

	// Route to handler by (method, kind).
	switch r.Method {
	case http.MethodGet:
		switch kind {
		case "tags":
			h.listTags(w, r, ociRepo)
		case "manifest":
			h.getManifest(w, r, ociRepo, ref)
		case "blob":
			h.getBlob(w, r, repo, ociRepo, ref)
		case "upload":
			h.getUploadStatus(w, r, ociRepo, ref)
		default:
			h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
		}

	case http.MethodHead:
		if kind == "blob" {
			h.headBlob(w, r, ref)
		} else {
			h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
		}

	case http.MethodPost:
		if kind == "upload" && ref == "" {
			h.startUpload(w, r, ociRepo)
		} else {
			h.ociError(w, http.StatusMethodNotAllowed, oci.ErrUnsupported, "method not allowed")
		}

	case http.MethodPatch:
		if kind == "upload" && ref != "" {
			h.patchUpload(w, r, ociRepo, ref)
		} else {
			h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
		}

	case http.MethodPut:
		if kind == "upload" && ref != "" {
			h.finishUpload(w, r, ociRepo, ref)
		} else if kind == "manifest" {
			h.pushManifest(w, r, ociRepo, ref)
		} else {
			h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
		}

	case http.MethodDelete:
		if kind == "manifest" {
			h.deleteManifest(w, r, ociRepo, ref)
		} else if kind == "blob" {
			h.deleteBlob(w, r, ociRepo, ref)
		} else if kind == "upload" && ref != "" {
			h.cancelUpload(w, r, ref)
		} else {
			h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
		}

	default:
		h.ociError(w, http.StatusMethodNotAllowed, oci.ErrUnsupported, "method not allowed")
	}
}

// ─── GET /v2/{name}/tags/list ────────────────────────────────────────────────

func (h *OCIRegistryHandler) listTags(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository) {
	var tags []models.OCITag
	h.db.Where("oci_repo_id = ?", ociRepo.ID).Find(&tags)

	names := make([]string, 0, len(tags))
	for _, t := range tags {
		names = append(names, t.Name)
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]interface{}{
		"name": ociRepo.Name,
		"tags": names,
	})
}

// ─── GET /v2/{name}/manifests/{ref} ──────────────────────────────────────────

func (h *OCIRegistryHandler) getManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
	digest := ref
	if !oci.IsDigestRef(ref) {
		// ref is a tag — resolve to digest.
		var tag models.OCITag
		if found, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(&tag); !found {
			h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, fmt.Sprintf("tag %q not found", ref))
			return
		}
		digest = tag.Digest
	}

	var manifest models.OCIManifest
	if found, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, digest).Get(&manifest); !found {
		h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "manifest not found")
		return
	}

	w.Header().Set("Content-Type", manifest.MediaType)
	w.Header().Set("Docker-Content-Digest", manifest.Digest)
	w.Header().Set("Content-Length", fmt.Sprintf("%d", manifest.Size))
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(manifest.Content)) //nolint:errcheck
}

// ─── PUT /v2/{name}/manifests/{ref} ──────────────────────────────────────────

func (h *OCIRegistryHandler) pushManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		h.ociError(w, http.StatusBadRequest, oci.ErrManifestInvalid, "cannot read body")
		return
	}
	if len(body) == 0 {
		h.ociError(w, http.StatusBadRequest, oci.ErrManifestInvalid, "empty manifest body")
		return
	}

	mediaType := r.Header.Get("Content-Type")
	if mediaType == "" {
		mediaType = "application/vnd.docker.distribution.manifest.v2+json"
	}

	manifestDigest, manifestSize := oci.ManifestDescriptor(body)

	// Persist manifest.
	m := &models.OCIManifest{
		OCIRepoID: ociRepo.ID,
		Digest:    manifestDigest,
		MediaType: mediaType,
		Size:      manifestSize,
		Content:   string(body),
	}
	if _, err := h.db.Insert(m); err != nil {
		// Duplicate digest is fine — manifests are immutable.
		if has, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, manifestDigest).Get(&models.OCIManifest{}); !has {
			h.ociError(w, http.StatusInternalServerError, oci.ErrManifestInvalid, "store manifest failed")
			return
		}
	}

	// If ref is not a digest, treat it as a tag.
	if !oci.IsDigestRef(ref) {
		tag := &models.OCITag{
			OCIRepoID: ociRepo.ID,
			Name:      ref,
			Digest:    manifestDigest,
		}
		existing := &models.OCITag{}
		if has, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(existing); has {
			existing.Digest = manifestDigest
			existing.UpdatedAt = time.Now()
			h.db.ID(existing.ID).Cols("digest", "updated_at").Update(existing)
		} else {
			h.db.Insert(tag)
		}
	}

	// Track blobs referenced by this manifest so GC can work.
	h.trackBlobRefs(ociRepo, body)

	w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", ociRepo.Name, manifestDigest))
	w.Header().Set("Content-Type", mediaType)
	w.Header().Set("Docker-Content-Digest", manifestDigest)
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"digest": manifestDigest})
}

// trackBlobRefs parses the manifest and ensures referenced blob digests exist as OCIBlob rows.
func (h *OCIRegistryHandler) trackBlobRefs(ociRepo *models.OCIRepository, body []byte) {
	var manifest struct {
		Layers []struct {
			Digest string `json:"digest"`
		} `json:"layers"`
		Config struct {
			Digest string `json:"digest"`
		} `json:"config"`
	}
	if err := json.Unmarshal(body, &manifest); err != nil {
		return
	}

	digests := []string{}
	if manifest.Config.Digest != "" {
		digests = append(digests, manifest.Config.Digest)
	}
	for _, layer := range manifest.Layers {
		if layer.Digest != "" {
			digests = append(digests, layer.Digest)
		}
	}

		for _, d := range digests {
			if h.reg.BlobExists(d) {
				h.db.Insert(&models.OCIBlob{Digest: d, Size: h.reg.BlobSize(d)}) //nolint:errcheck,nestif
			}
		}
}

// ─── DELETE /v2/{name}/manifests/{ref} ───────────────────────────────────────

func (h *OCIRegistryHandler) deleteManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
	digest := ref
	if !oci.IsDigestRef(ref) {
		var tag models.OCITag
		if found, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(&tag); !found {
			h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "tag not found")
			return
		}
		digest = tag.Digest
		// Delete the tag.
		h.db.ID(tag.ID).Delete(&models.OCITag{})
	}

	affected, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, digest).Delete(&models.OCIManifest{})
	if affected == 0 {
		h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "manifest not found")
		return
	}
	w.WriteHeader(http.StatusAccepted)
}

// ─── HEAD /v2/{name}/blobs/{digest} ──────────────────────────────────────────

func (h *OCIRegistryHandler) headBlob(w http.ResponseWriter, r *http.Request, digest string) {
	if !h.reg.BlobExists(digest) {
		h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
		return
	}
	size := h.reg.BlobSize(digest)
	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
	w.Header().Set("Docker-Content-Digest", digest)
	w.WriteHeader(http.StatusOK)
}

// ─── GET /v2/{name}/blobs/{digest} ───────────────────────────────────────────

func (h *OCIRegistryHandler) getBlob(w http.ResponseWriter, r *http.Request, repo models.Repository, ociRepo *models.OCIRepository, digest string) {
	if !h.reg.BlobExists(digest) {
		h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
		return
	}
	size := h.reg.BlobSize(digest)

	f, err := h.reg.ReadBlob(digest)
	if err != nil {
		h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUnknown, "cannot read blob")
		return
	}
	defer f.Close()

	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
	w.Header().Set("Docker-Content-Digest", digest)
	http.ServeContent(w, r, "", time.Time{}, f)
}

// ─── DELETE /v2/{name}/blobs/{digest} ────────────────────────────────────────

func (h *OCIRegistryHandler) deleteBlob(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, digest string) {
	if !h.reg.BlobExists(digest) {
		h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
		return
	}
	_ = h.reg.DeleteBlob(digest)
	h.db.Where("digest = ?", digest).Delete(&models.OCIBlob{})

	w.WriteHeader(http.StatusAccepted)
}

// ─── POST /v2/{name}/blobs/uploads/ ──────────────────────────────────────────

func (h *OCIRegistryHandler) startUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository) {
	uploadID := newOCIUploadID()

	// Check for single-shot upload (body with ?digest param).
	clientDigest := r.URL.Query().Get("digest")
	contentLength := r.ContentLength

	if clientDigest != "" && contentLength > 0 {
		// Single-shot POST upload.
		digest, size, err := h.reg.WriteBlob(r.Body)
		if err != nil {
			h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUploadInvalid, "upload failed")
			return
		}

		h.upsertOCIName(ociRepo, digest, size)

		w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", ociRepo.Name, digest))
		w.Header().Set("Content-Range", fmt.Sprintf("0-%d", size-1))
		w.Header().Set("Docker-Content-Digest", digest)
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(map[string]string{"digest": digest})
		return
	}

	// Create upload session.
	h.db.Insert(&models.OCIUpload{ //nolint:errcheck
		UploadID:  uploadID,
		Name:      ociRepo.Name,
		ExpiresAt: time.Now().UTC().Add(30 * time.Minute),
	})

	w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, uploadID))
	w.Header().Set("Range", "0-0")
	w.WriteHeader(http.StatusAccepted)
}

// ─── PATCH /v2/{name}/blobs/uploads/{uuid} ───────────────────────────────────

func (h *OCIRegistryHandler) patchUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
	// Validate the upload session exists on disk.
	uploadPath := h.reg.UploadPath(ref)
	_, statErr := os.Stat(uploadPath)
	if h.reg.UploadOffset(ref) == 0 && os.IsNotExist(statErr) {
		h.ociError(w, http.StatusNotFound, oci.ErrBlobUploadUnknown, "upload not found")
		return
	}

	newOffset, err := h.reg.AppendUpload(ref, r.Body)
	if err != nil {
		h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUploadInvalid, "append failed")
		return
	}

	// Persist upload offset.
	h.db.Where("upload_id = ?", ref).Cols("offset").Update(&models.OCIUpload{Offset: newOffset})

	w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, ref))
	w.Header().Set("Range", fmt.Sprintf("0-%d", newOffset-1))
	w.Header().Set("Content-Length", "0")
	w.WriteHeader(http.StatusAccepted)
}

// ─── PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:... ───────────────────

func (h *OCIRegistryHandler) finishUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
	clientDigest := r.URL.Query().Get("digest")
	if clientDigest == "" {
		h.ociError(w, http.StatusBadRequest, oci.ErrDigestInvalid, "digest query parameter required")
		return
	}

	// If there's a body, append it before finalising.
	if r.ContentLength > 0 || r.Body != http.NoBody {
		h.reg.AppendUpload(ref, r.Body) //nolint:errcheck
	}

	digest, size, err := h.reg.FinishUpload(ref, clientDigest)
	if err != nil {
		if _, ok := err.(*oci.DigestMismatch); ok {
			h.ociError(w, http.StatusBadRequest, oci.ErrDigestInvalid, err.Error())
		} else {
			h.ociError(w, http.StatusNotFound, oci.ErrBlobUploadUnknown, "upload not found")
		}
		return
	}

	h.upsertOCIName(ociRepo, digest, size)

	// Remove upload session.
	h.db.Where("upload_id = ?", ref).Delete(&models.OCIUpload{})

	w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", ociRepo.Name, digest))
	w.Header().Set("Content-Range", fmt.Sprintf("0-%d", size-1))
	w.Header().Set("Docker-Content-Digest", digest)
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"digest": digest})
}

// ─── GET /v2/{name}/blobs/uploads/{uuid} ─────────────────────────────────────

func (h *OCIRegistryHandler) getUploadStatus(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
	offset := h.reg.UploadOffset(ref)
	w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, ref))
	w.Header().Set("Range", fmt.Sprintf("0-%d", offset))
	w.WriteHeader(http.StatusNoContent)
}

// ─── DELETE /v2/{name}/blobs/uploads/{uuid} ──────────────────────────────────

func (h *OCIRegistryHandler) cancelUpload(w http.ResponseWriter, r *http.Request, ref string) {
	h.reg.CancelUpload(ref)
	h.db.Where("upload_id = ?", ref).Delete(&models.OCIUpload{})
	w.WriteHeader(http.StatusNoContent)
}

// ─── helpers ──────────────────────────────────────────────────────────────────

func (h *OCIRegistryHandler) getOrCreateOCIRepo(repoID int64, name string) (*models.OCIRepository, error) {
	r := &models.OCIRepository{}
	if found, _ := h.db.Where("name = ?", name).Get(r); found {
		return r, nil
	}
	r.RepoID = repoID
	r.Name = name
	if _, err := h.db.Insert(r); err != nil {
		return nil, err
	}
	return r, nil
}

func (h *OCIRegistryHandler) upsertOCIName(ociRepo *models.OCIRepository, digest string, size int64) {
	// Track blob in DB if not already tracked.
	h.db.Insert(&models.OCIBlob{Digest: digest, Size: size}) //nolint:errcheck
}

func (h *OCIRegistryHandler) ociError(w http.ResponseWriter, status int, code oci.ErrorCode, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	w.Write(oci.NewError(code, msg)) //nolint:errcheck
}

// newOCIUploadID generates a random hex string used as the upload session ID.
func newOCIUploadID() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic("oci: crypto/rand failed: " + err.Error())
	}
	return hex.EncodeToString(b)
}

func (h *OCIRegistryHandler) basicAuthOCI(r *http.Request) string {
	u, pass, hasAuth := r.BasicAuth()
	if !hasAuth {
		return ""
	}
	var user models.User
	if found, _ := h.db.Where("username = ?", u).Get(&user); !found {
		return ""
	}
	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(pass)); err != nil {
		return ""
	}
	return u
}
