package sfcrmappservice

import (
	"context"
	"errors"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/url"
	"path/filepath"
	"strconv"
	"strings"

	crmwire "github.com/pingcap-inc/nexuscrm-app-packs/sfcrm/wire"
	commonsemantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/commonv1"
	filessemantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/filesv1"
)

const (
	fileUploadFieldParentID            = "parentId"
	fileUploadFieldParentObjectAPIName = "parentObjectApiName"
	fileUploadFieldAttachmentKind      = "attachmentKind"
	defaultRecordAttachmentKind        = "record"
	maxRecordFileUploadBytes           = 25 * 1024 * 1024
	minRecordFileRequestBytes          = maxRecordFileUploadBytes + 1024*1024
)

var allowedRecordFileExtensions = map[string]struct{}{
	".pdf": {}, ".doc": {}, ".docx": {},
	".xls": {}, ".xlsx": {}, ".ppt": {}, ".pptx": {},
	".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {},
	".csv": {}, ".txt": {}, ".json": {}, ".xml": {},
	".zip": {},
}

var blockedRecordFileMIMEs = map[string]struct{}{
	"text/html":              {},
	"application/javascript": {},
	"application/xhtml+xml":  {},
	"image/svg+xml":          {},
}

// RuntimeFileUpload and RuntimeFileDownload preserve explicit stream
// cancellation at the L1/L0 boundary. The production implementations are the
// api-host Platform client streams; tests use the same narrow semantic ports.
type RuntimeFileUpload interface {
	Send([]byte) error
	CloseAndRecv() (filessemantic.CreateResponse, error)
	Cancel()
}

type RuntimeFileDownload interface {
	Recv() (filessemantic.OpenChunk, error)
	Cancel()
}

type RuntimeBeginFileCreateFunc func(context.Context, string, filessemantic.CreateHeader) (RuntimeFileUpload, error)
type RuntimeOpenFileFunc func(context.Context, string, filessemantic.OpenRequest) (RuntimeFileDownload, error)
type RuntimeDeleteFileFunc func(context.Context, string, filessemantic.DeleteRequest) (filessemantic.DeleteResponse, error)

// RuntimeFilesClient is an L1 projection adapter only. Runtime remains the
// owner of authorization, metadata revision, persistence, receipts and blob
// lifecycle.
type RuntimeFilesClient struct {
	beginCreate RuntimeBeginFileCreateFunc
	open        RuntimeOpenFileFunc
	delete      RuntimeDeleteFileFunc
}

func NewRuntimeFilesClient(
	beginCreate RuntimeBeginFileCreateFunc,
	open RuntimeOpenFileFunc,
	deleteFile RuntimeDeleteFileFunc,
) (*RuntimeFilesClient, error) {
	if beginCreate == nil || open == nil || deleteFile == nil {
		return nil, errors.New("sfcrm AppService: Runtime Files ports are required")
	}
	return &RuntimeFilesClient{beginCreate: beginCreate, open: open, delete: deleteFile}, nil
}

type recordFilesRouteKind uint8

const (
	recordFilesRouteUpload recordFilesRouteKind = iota + 1
	recordFilesRouteDownload
	recordFilesRouteDelete
)

type recordFilesRoute struct {
	version string
	fileID  string
	kind    recordFilesRouteKind
}

func matchRecordFilesRoute(escapedPath, method string) (recordFilesRoute, []string, bool) {
	trimmed := strings.Trim(escapedPath, "/")
	parts := strings.Split(trimmed, "/")
	if len(parts) < 4 || parts[0] != "services" || parts[1] != "data" {
		return recordFilesRoute{}, nil, false
	}
	if _, ok := recordViewVersions[parts[2]]; !ok {
		return recordFilesRoute{}, nil, false
	}
	decode := func(value string) (string, bool) {
		decoded, err := url.PathUnescape(value)
		return decoded, err == nil && decoded != "" && strings.TrimSpace(decoded) == decoded
	}
	route := recordFilesRoute{version: parts[2]}
	var allowed string
	switch {
	case len(parts) == 4 && (parts[3] == "files" || parts[3] == "record-files"):
		route.kind, allowed = recordFilesRouteUpload, http.MethodPost
	case len(parts) == 6 && parts[3] == "files" && parts[5] == "download":
		var ok bool
		route.fileID, ok = decode(parts[4])
		if !ok {
			return recordFilesRoute{}, nil, false
		}
		route.kind, allowed = recordFilesRouteDownload, http.MethodGet
	case len(parts) == 5 && parts[3] == "files":
		var ok bool
		route.fileID, ok = decode(parts[4])
		if !ok {
			return recordFilesRoute{}, nil, false
		}
		route.kind, allowed = recordFilesRouteDelete, http.MethodDelete
	case len(parts) == 7 && parts[3] == "sobjects" && parts[4] == "_System_File" && parts[6] == "download":
		var ok bool
		route.fileID, ok = decode(parts[5])
		if !ok {
			return recordFilesRoute{}, nil, false
		}
		route.kind, allowed = recordFilesRouteDownload, http.MethodGet
	case len(parts) == 6 && parts[3] == "sobjects" && parts[4] == "_System_File":
		var ok bool
		route.fileID, ok = decode(parts[5])
		if !ok || method != http.MethodDelete {
			return recordFilesRoute{}, nil, false
		}
		route.kind, allowed = recordFilesRouteDelete, http.MethodDelete
	default:
		return recordFilesRoute{}, nil, false
	}
	if method != allowed {
		route.kind = 0
	}
	return route, []string{allowed}, true
}

func (s *Service) serveRecordFiles(writer http.ResponseWriter, request *http.Request) bool {
	route, allowed, recognized := matchRecordFilesRoute(request.URL.EscapedPath(), request.Method)
	if !recognized {
		return false
	}
	if route.kind == 0 {
		writer.Header().Set("Allow", strings.Join(allowed, ", "))
		writeCRMErrorWith(writer, http.StatusMethodNotAllowed, crmwire.SFCodeInvalidRequest, "Method not allowed", writeLegacyJSON)
		return true
	}

	s.requests.Add(1)
	if s.draining.Load() {
		s.drainRejected.Add(1)
		s.failures.Add(1)
		writeCRMErrorWith(writer, http.StatusServiceUnavailable, crmwire.SFCodeUnknownException, "Service is draining", writeLegacyJSON)
		return true
	}
	s.inFlight.Add(1)
	defer s.inFlight.Add(-1)
	ctx, cancel := context.WithTimeout(request.Context(), s.requestTimeout)
	defer cancel()

	bearer, ok := endUserBearer(request.Header.Values("Authorization"))
	if !ok {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeCRMErrorWith(writer, http.StatusUnauthorized, crmwire.SFCodeInvalidSessionID, "Session expired or invalid", writeLegacyJSON)
		return true
	}

	switch route.kind {
	case recordFilesRouteUpload:
		s.serveRecordFileUpload(ctx, writer, request, bearer, route)
	case recordFilesRouteDownload:
		s.serveRecordFileDownload(ctx, writer, bearer, route)
	case recordFilesRouteDelete:
		s.serveRecordFileDelete(ctx, writer, bearer, route)
	}
	return true
}

func (s *Service) serveRecordFileUpload(
	ctx context.Context,
	writer http.ResponseWriter,
	request *http.Request,
	bearer string,
	route recordFilesRoute,
) {
	upload, err := readRecordFileMultipart(writer, request, s.maxRequestBytes)
	if err != nil {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeRecordFileInputError(writer, err)
		return
	}
	defer upload.Close()

	if upload.Size > maxRecordFileUploadBytes {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeRecordFileTooLarge(writer)
		return
	}
	extension := strings.ToLower(filepath.Ext(upload.FileName))
	if _, ok := allowedRecordFileExtensions[extension]; !ok {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeCRMErrorWith(
			writer,
			http.StatusBadRequest,
			crmwire.SFCodeInvalidRequest,
			fmt.Sprintf("File type '%s' not allowed", extension),
			writeLegacyJSON,
		)
		return
	}
	contentType, err := sniffRecordFile(upload.File)
	if err != nil {
		s.failures.Add(1)
		writeCRMErrorWith(writer, http.StatusInternalServerError, crmwire.SFCodeInternalError, "Failed to process file", writeLegacyJSON)
		return
	}
	if _, blocked := blockedRecordFileMIMEs[normalizeRecordFileMediaType(contentType)]; blocked {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeCRMErrorWith(writer, http.StatusBadRequest, crmwire.SFCodeInvalidRequest, "File content type not allowed", writeLegacyJSON)
		return
	}
	parentID := strings.TrimSpace(upload.FormValue(fileUploadFieldParentID))
	parentObjectID := strings.TrimSpace(upload.FormValue(fileUploadFieldParentObjectAPIName))
	if parentID == "" || parentObjectID == "" {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeCRMErrorWith(writer, http.StatusBadRequest, crmwire.SFCodeInvalidRequest, "parentId and parentObjectApiName are required", writeLegacyJSON)
		return
	}
	attachmentKind := strings.TrimSpace(upload.FormValue(fileUploadFieldAttachmentKind))
	if attachmentKind == "" {
		attachmentKind = defaultRecordAttachmentKind
	}
	idempotencyKey, err := mutationCommandID(request.Header.Values(crmMutationIdempotencyHeader))
	if err != nil {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeCRMErrorWith(writer, http.StatusBadRequest, crmwire.SFCodeInvalidRequest, err.Error(), writeLegacyJSON)
		return
	}
	if idempotencyKey == "" {
		idempotencyKey, err = newMutationCommandID()
		if err != nil {
			s.failures.Add(1)
			writeCRMErrorWith(writer, http.StatusInternalServerError, crmwire.SFCodeInternalError, crmwire.SFGenericInternalErrorMessage, writeLegacyJSON)
			return
		}
	}
	header := filessemantic.CreateHeader{
		IdempotencyKey: idempotencyKey, ParentObjectID: parentObjectID, ParentRecordID: parentID,
		FileName: upload.FileName, ContentType: contentType, ContentLength: uint64(upload.Size),
		AttachmentKind: attachmentKind,
	}
	if err := filessemantic.ValidateCreateHeader(header); err != nil {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeRuntimeFailureWith(writer, err, writeLegacyJSON)
		return
	}
	stream, err := s.files.beginCreate(ctx, bearer, header)
	if err != nil {
		s.failures.Add(1)
		writeRecordFileRuntimeFailure(writer, err)
		return
	}
	defer stream.Cancel()
	buffer := make([]byte, filessemantic.MaxChunkBytes)
	for {
		read, readErr := upload.File.Read(buffer)
		if read > 0 {
			if err := stream.Send(buffer[:read]); err != nil {
				s.failures.Add(1)
				writeRecordFileRuntimeFailure(writer, err)
				return
			}
		}
		if errors.Is(readErr, io.EOF) {
			break
		}
		if readErr != nil {
			s.failures.Add(1)
			writeCRMErrorWith(writer, http.StatusInternalServerError, crmwire.SFCodeInternalError, "Failed to read file", writeLegacyJSON)
			return
		}
	}
	response, err := stream.CloseAndRecv()
	if err != nil {
		s.failures.Add(1)
		writeRecordFileRuntimeFailure(writer, err)
		return
	}
	if err := filessemantic.ValidateCreateResponse(response); err != nil {
		s.failures.Add(1)
		writeRecordFileRuntimeFailure(writer, err)
		return
	}
	s.successes.Add(1)
	writeLegacyJSON(writer, http.StatusCreated, map[string]any{"data": map[string]any{
		"Id": response.FileID, "Name": response.FileName, "Size": response.Size, "Mime": response.ContentType,
		"DownloadUrl": fmt.Sprintf("/services/data/%s/files/%s/download", route.version, url.PathEscape(response.FileID)),
	}})
}

func (s *Service) serveRecordFileDownload(
	ctx context.Context,
	writer http.ResponseWriter,
	bearer string,
	route recordFilesRoute,
) {
	request := filessemantic.OpenRequest{FileID: route.fileID}
	if err := filessemantic.ValidateOpenRequest(ctx, request); err != nil {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeRuntimeFailureWith(writer, err, writeLegacyJSON)
		return
	}
	stream, err := s.files.open(ctx, bearer, request)
	if err != nil {
		s.failures.Add(1)
		writeRecordFileRuntimeFailure(writer, err)
		return
	}
	defer stream.Cancel()
	streamState := &filessemantic.OpenStreamState{}
	first, err := stream.Recv()
	if err != nil {
		s.failures.Add(1)
		writeRecordFileRuntimeFailure(writer, err)
		return
	}
	if err := streamState.Observe(first); err != nil {
		s.failures.Add(1)
		writeRecordFileRuntimeFailure(writer, err)
		return
	}
	writer.Header().Set("Content-Type", safeRecordFileDownloadContentType(first.ContentType))
	writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", safeRecordFileDownloadName(first.FileName)))
	writer.Header().Set("Content-Length", strconv.FormatUint(first.TotalSize, 10))
	writer.WriteHeader(http.StatusOK)
	if _, err := writer.Write(first.Data); err != nil {
		s.failures.Add(1)
		return
	}
	for {
		chunk, err := stream.Recv()
		if errors.Is(err, io.EOF) {
			if err := streamState.Complete(); err != nil {
				s.failures.Add(1)
				return
			}
			s.successes.Add(1)
			return
		}
		if err != nil {
			s.failures.Add(1)
			return
		}
		if err := streamState.Observe(chunk); err != nil {
			s.failures.Add(1)
			return
		}
		if _, err := writer.Write(chunk.Data); err != nil {
			s.failures.Add(1)
			return
		}
	}
}

func (s *Service) serveRecordFileDelete(
	ctx context.Context,
	writer http.ResponseWriter,
	bearer string,
	route recordFilesRoute,
) {
	request := filessemantic.DeleteRequest{FileID: route.fileID}
	if err := filessemantic.ValidateDeleteRequest(ctx, request); err != nil {
		s.localRejected.Add(1)
		s.failures.Add(1)
		writeRuntimeFailureWith(writer, err, writeLegacyJSON)
		return
	}
	response, err := s.files.delete(ctx, bearer, request)
	if err != nil {
		s.failures.Add(1)
		writeRecordFileRuntimeFailure(writer, err)
		return
	}
	if err := filessemantic.ValidateDeleteResponse(response); err != nil {
		s.failures.Add(1)
		writeRecordFileRuntimeFailure(writer, err)
		return
	}
	s.successes.Add(1)
	writer.WriteHeader(http.StatusNoContent)
}

type recordFileMultipart struct {
	form     *multipart.Form
	File     multipart.File
	FileName string
	Size     int64
}

func (upload *recordFileMultipart) Close() {
	if upload == nil {
		return
	}
	if upload.File != nil {
		_ = upload.File.Close()
	}
	if upload.form != nil {
		_ = upload.form.RemoveAll()
	}
}

func (upload *recordFileMultipart) FormValue(name string) string {
	if upload == nil || upload.form == nil || len(upload.form.Value[name]) == 0 {
		return ""
	}
	return upload.form.Value[name][0]
}

func readRecordFileMultipart(writer http.ResponseWriter, request *http.Request, maxRequestBytes int64) (*recordFileMultipart, error) {
	if request == nil || request.Body == nil {
		return nil, errNoRecordFile
	}
	request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBytes)
	mediaType, params, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
	if err != nil || !strings.HasPrefix(strings.ToLower(mediaType), "multipart/") || strings.TrimSpace(params["boundary"]) == "" {
		return nil, errNoRecordFile
	}
	reader := multipart.NewReader(request.Body, params["boundary"])
	form, err := reader.ReadForm(1024 * 1024)
	if err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			return nil, errRecordFileTooLarge
		}
		return nil, errNoRecordFile
	}
	fileHeaders := form.File["file"]
	if len(fileHeaders) == 0 || fileHeaders[0] == nil {
		_ = form.RemoveAll()
		return nil, errNoRecordFile
	}
	file, err := fileHeaders[0].Open()
	if err != nil {
		_ = form.RemoveAll()
		return nil, errReadRecordFile
	}
	return &recordFileMultipart{
		form: form, File: file, FileName: fileHeaders[0].Filename, Size: fileHeaders[0].Size,
	}, nil
}

var (
	errNoRecordFile       = errors.New("no file provided")
	errReadRecordFile     = errors.New("failed to read file")
	errRecordFileTooLarge = errors.New("record file request is too large")
)

func writeRecordFileInputError(writer http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, errRecordFileTooLarge):
		writeRecordFileTooLarge(writer)
	case errors.Is(err, errReadRecordFile):
		writeCRMErrorWith(writer, http.StatusInternalServerError, crmwire.SFCodeInternalError, "Failed to read file", writeLegacyJSON)
	default:
		writeCRMErrorWith(writer, http.StatusBadRequest, crmwire.SFCodeInvalidRequest, "No file provided", writeLegacyJSON)
	}
}

func writeRecordFileTooLarge(writer http.ResponseWriter) {
	writeCRMErrorWith(
		writer,
		http.StatusRequestEntityTooLarge,
		crmwire.SFCodeInvalidRequest,
		fmt.Sprintf("File exceeds %dMB limit", maxRecordFileUploadBytes/(1024*1024)),
		writeLegacyJSON,
	)
}

func sniffRecordFile(file multipart.File) (string, error) {
	buffer := make([]byte, 512)
	read, _ := file.Read(buffer)
	contentType := http.DetectContentType(buffer[:read])
	if _, err := file.Seek(0, io.SeekStart); err != nil {
		return "", err
	}
	return contentType, nil
}

func normalizeRecordFileMediaType(contentType string) string {
	mediaType, _, err := mime.ParseMediaType(contentType)
	if err != nil {
		return strings.ToLower(strings.TrimSpace(contentType))
	}
	return strings.ToLower(strings.TrimSpace(mediaType))
}

func safeRecordFileDownloadContentType(contentType string) string {
	contentType = strings.TrimSpace(contentType)
	if contentType == "" {
		return "application/octet-stream"
	}
	if _, blocked := blockedRecordFileMIMEs[normalizeRecordFileMediaType(contentType)]; blocked {
		return "application/octet-stream"
	}
	return contentType
}

func safeRecordFileDownloadName(fileName string) string {
	name := strings.ReplaceAll(filepath.Base(fileName), "\"", "_")
	name = strings.NewReplacer("\r", "_", "\n", "_").Replace(name)
	if strings.TrimSpace(name) == "" || name == "." || name == "/" {
		return "file"
	}
	return name
}

func writeRecordFileRuntimeFailure(writer http.ResponseWriter, err error) {
	var failure *commonsemantic.Failure
	if errors.As(err, &failure) && failure.Category == commonsemantic.FailureMissing {
		writeCRMErrorWith(writer, http.StatusNotFound, crmwire.SFCodeNotFound, "File not found", writeLegacyJSON)
		return
	}
	writeRuntimeFailureWith(writer, err, writeLegacyJSON)
}
