package stringn

import (
	"bytes"
	"compress/gzip"
	"context"
	"database/sql"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"golang.org/x/sync/errgroup"
	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/db"
	"tangled.org/core/appview/middleware"
	"tangled.org/core/appview/models"
	"tangled.org/core/appview/notify"
	"tangled.org/core/appview/oauth"
	"tangled.org/core/appview/pages"
	"tangled.org/core/appview/pages/markup"
	"tangled.org/core/blobstore"
	"tangled.org/core/orm"
	"tangled.org/core/tid"
	"tangled.org/core/xrpc"

	"github.com/bluesky-social/indigo/api/agnostic"
	"github.com/bluesky-social/indigo/api/atproto"
	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
	indigoxrpc "github.com/bluesky-social/indigo/xrpc"
	"github.com/go-chi/chi/v5"
	"github.com/ipfs/go-cid"

	comatproto "github.com/bluesky-social/indigo/api/atproto"
	lexutil "github.com/bluesky-social/indigo/lex/util"
)

const textPlain = "text/plain"

type Strings struct {
	Db        *db.DB
	OAuth     *oauth.OAuth
	Pages     *pages.Pages
	Dir       identity.Directory
	BlobStore blobstore.BlobStore
	Logger    *slog.Logger
	Notifier  notify.Notifier
}

func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
	r := chi.NewRouter()

	r.
		Get("/", s.timeline)
	r.
		Get("/fileEdit", s.FileEditFragment)

	r.
		Route("/{user}", func(r chi.Router) {
			r.Get("/", s.dashboard)

			r.Route("/{rkey}", func(r chi.Router) {
				r.Use(mw.ResolveIdent())
				r.Use(s.resolveString)

				r.Get("/", s.SingleString)
				r.Delete("/", s.delete)
				r.Get("/edit", s.edit)
				r.Post("/edit", s.edit)

				r.Get("/{cid}/{filename}", s.FileFragment)
				r.Get("/{cid}/{filename}/raw", s.FileRaw)

				// legacy endpoint
				r.Get("/raw", s.redirectToFirstFileRaw)
			})
		})

	r.
		With(middleware.AuthMiddleware(s.OAuth)).
		Route("/new", func(r chi.Router) {
			r.Get("/", s.create)
			r.Post("/", s.create)
		})

	return r
}

func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound)
}

func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) {
	l := s.Logger.With("handler", "timeline")

	strings, err := db.GetStrings(s.Db, 50)
	if err != nil {
		l.Error("failed to fetch string", "err", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	s.Pages.StringsTimeline(w, pages.StringTimelineParams{
		BaseParams: pages.BaseParamsFromContext(r.Context()),
		Strings:    strings,
	})
}

type stringCtxKey struct{}

func (s *Strings) resolveString(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		l := s.Logger.With("middleware", "resolveString")
		rkey := chi.URLParam(r, "rkey")

		id, ok := r.Context().Value("resolvedId").(identity.Identity)
		if !ok {
			l.Error("malformed middleware")
			w.WriteHeader(http.StatusInternalServerError)
			return
		}

		str, err := db.GetString(s.Db, orm.FilterEq("did", id.DID), orm.FilterEq("rkey", rkey))
		if errors.Is(err, sql.ErrNoRows) {
			s.Pages.Error404(w)
			return
		} else if err != nil {
			l.Error("failed to fetch string", "err", err)
			w.WriteHeader(http.StatusInternalServerError)
			return
		}

		ctx := context.WithValue(r.Context(), stringCtxKey{}, str)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func stringFromContext(ctx context.Context) (models.String, bool) {
	str, ok := ctx.Value(stringCtxKey{}).(models.String)
	return str, ok
}

// redirectToFirstFileRaw is a handle for legacy endpoint.
// It redirects /strings/{did}/{rkey}/raw to /strings/{did}/{rkey}/{cid}/{filename}/raw
func (s *Strings) redirectToFirstFileRaw(w http.ResponseWriter, r *http.Request) {
	str, ok := stringFromContext(r.Context())
	if !ok {
		s.Logger.Error("malformed middleware. string missing")
		s.Pages.Error404(w)
		return
	}
	var cid syntax.CID
	var filename string
	if str.Cid != nil {
		cid = *str.Cid
	} else {
		var err error
		cid, err = s.getRecordCid(r.Context(), str.AtUri())
		if err != nil {
			s.Pages.Error404(w)
			return
		}
	}
	if str.IsLegacySingleFile() {
		filename = str.FileName
	} else {
		filename = str.Files[0].Name
	}
	http.Redirect(w, r, fmt.Sprintf("/strings/%s/%s/%s/%s/raw", str.Did, str.Rkey, cid, filename), http.StatusFound)
}

func (s *Strings) SingleString(w http.ResponseWriter, r *http.Request) {
	l := s.Logger.With("handler", "SingleString")
	ctx := r.Context()

	str, ok := stringFromContext(ctx)
	if !ok {
		l.Error("malformed middleware. string missing")
		s.Pages.Error404(w)
		return
	}

	starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, str.AtUri().String())
	if err != nil {
		l.Error("failed to get star count", "err", err)
	}
	user := s.OAuth.GetMultiAccountUser(r)
	isStarred := false
	if user != nil {
		isStarred = db.GetStarStatus(s.Db, user.Did, str.AtUri().String())
	}

	comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", str.AtUri()))
	if err != nil {
		l.Error("failed to get comments", "err", err)
	}

	var entities []syntax.ATURI
	for _, c := range comments {
		entities = append(entities, c.AtUri())
	}
	reactions, err := db.ListReactionDisplayDataMap(s.Db, entities, 20)
	if err != nil {
		l.Error("failed to get reactions", "err", err)
	}

	var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
	if user != nil {
		userReactions, err = db.ListReactionStatusMap(s.Db, entities, syntax.DID(user.Did))
		if err != nil {
			l.Error("failed to get user reactions", "err", err)
		}
	}

	vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
	if user != nil {
		var participants []syntax.DID
		for _, c := range comments {
			participants = append(participants, c.Did)
		}
		vouchRelationships, err = db.GetVouchRelationshipsBatch(s.Db, syntax.DID(user.Did), participants)
		if err != nil {
			l.Error("failed to fetch vouch relationships", "err", err)
		}
	}

	var files []pages.StringFileFragmentParams

	if str.IsLegacySingleFile() {
		files = []pages.StringFileFragmentParams{
			s.makeFileFragmentParams(&str, str.FileName, str.FileContent, false),
		}
	} else {
		files = make([]pages.StringFileFragmentParams, len(str.Files))
		for i, file := range str.Files {
			blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref))
			if err != nil {
				l.Warn("failed to fetch blob", "err", err)
				http.NotFound(w, r)
				return
			}
			defer blob.Close()

			contentBytes, err := io.ReadAll(blob)
			if err != nil {
				l.Error("failed to read blob", "err", err)
			}

			files[i] = s.makeFileFragmentParams(&str, file.Name, string(contentBytes), false)
		}
	}

	err = s.Pages.SingleString(w, pages.SingleStringParams{
		BaseParams:   pages.BaseParamsFromContext(r.Context()),
		String:       &str,
		FileParams:   files,
		IsStarred:    isStarred,
		StarCount:    starCount,
		CommentList:  models.NewCommentList(comments),

		Reactions:          reactions,
		UserReacted:        userReactions,
		VouchRelationships: vouchRelationships,
	})
	if err != nil {
		l.Error("failed to render", "err", err)
	}
}

func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
	l := s.Logger.With("handler", "edit")
	ctx := r.Context()

	user := s.OAuth.GetMultiAccountUser(r)

	oldString, ok := stringFromContext(ctx)
	if !ok {
		l.Error("malformed middleware. string missing")
		s.Pages.Error404(w)
		return
	}

	// verify that the logged in user owns this string
	if user.Did != oldString.Did.String() {
		l.Error("unauthorized request", "expected", oldString.Did, "got", user.Did)
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	switch r.Method {
	case http.MethodGet:
		// return the form with prefilled fields
		var files []pages.StringFileEditFragmentParams
		if oldString.IsLegacySingleFile() {
			files = []pages.StringFileEditFragmentParams{
				{
					Name:    oldString.FileName,
					Content: oldString.FileContent,
					Size:    uint64(len(oldString.FileContent)),
				},
			}
		} else {
			files = make([]pages.StringFileEditFragmentParams, len(oldString.Files))
			for i, file := range oldString.Files {
				blob, err := s.BlobStore.GetBlob(r.Context(), oldString.Did, cid.Cid(file.Content.Ref))
				if err != nil {
					l.Warn("failed to fetch blob", "err", err)
					http.NotFound(w, r)
					return
				}
				defer blob.Close()

				contentBytes, err := io.ReadAll(blob)
				if err != nil {
					l.Error("failed to read blob", "err", err)
				}
				files[i] = pages.StringFileEditFragmentParams{
					Name:    file.Name,
					Content: string(contentBytes),
					Size:    uint64(file.Content.Size),
				}
			}
		}
		err := s.Pages.EditString(w, pages.EditStringParams{
			BaseParams: pages.BaseParamsFromContext(r.Context()),
			String:       oldString,
			FileParams:   files,
		})
		if err != nil {
			l.Error("failed to render", "err", err)
		}
	case http.MethodPost:
		fail := func(msg string, err error) {
			l.Error(msg, "err", err)
			s.Pages.Notice(w, "error", msg)
		}

		var title *string
		if val := r.FormValue("title"); val != "" {
			title = &val
		}

		var description *string
		if val := r.FormValue("description"); val != "" {
			description = &val
		}

		fileNames := r.Form["filename"]
		fileContents := r.Form["content"]
		if len(fileNames) != len(fileContents) {
			s.Pages.Notice(w, "error", "mismatched file name & content lengths")
			return
		}

		client, err := s.OAuth.AuthorizedClient(r)
		if err != nil {
			l.Error("failed to get authorized client", "err", err)
			s.Pages.Notice(w, "error", "Failed to create string.")
			return
		}

		files := make([]models.String_File, len(fileNames))
		var mu sync.Mutex

		g, gctx := errgroup.WithContext(ctx)
		g.SetLimit(10)
		for i := range len(files) {
			g.Go(func() error {
				blob, err := xrpc.RepoUploadBlob(gctx, client, strings.NewReader(fileContents[i]), textPlain)
				if err != nil {
					return err
				}
				if blob.Blob == nil {
					return fmt.Errorf("uploadBlob: blob is missing from response")
				}

				mu.Lock()
				files[i] = models.String_File{
					Name:    fileNames[i],
					Content: *blob.Blob,
				}
				mu.Unlock()
				return nil
			})
		}
		if err := g.Wait(); err != nil {
			l.Warn("failed to upload blobs", "err", err)
			s.Pages.Notice(w, "error", "Failed to upload blobs.")
			return
		}

		newString := oldString
		newString.Title = title
		newString.Description = description
		newString.Files = files

		// first replace the existing record in the PDS
		var exCid string
		if newString.Cid != nil {
			exCid = oldString.Cid.String()
		} else {
			cid, err := s.getRecordCid(ctx, oldString.AtUri())
			if err != nil {
				s.Pages.Error404(w)
				return
			}
			exCid = cid.String()
		}
		resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{
			Collection: tangled.StringNSID,
			Repo:       newString.Did.String(),
			Rkey:       newString.Rkey.String(),
			SwapRecord: &exCid,
			Record:     &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()},
		})
		if err != nil {
			fail("Failed to updated existing record.", err)
			return
		}
		l = l.With("aturi", resp.Uri)
		l.Info("edited string")

		newString.Cid = new(syntax.CID)
		*newString.Cid = syntax.CID(resp.Cid)

		// if that went okay, updated the db
		if err = db.AddString(s.Db, newString); err != nil {
			l.Error("db: failed to update string", "err", err)
		}

		s.Notifier.EditString(ctx, &newString)

		// if that went okay, redir to the string
		s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey))
	}

}

func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
	l := s.Logger.With("handler", "create")
	ctx := r.Context()
	user := s.OAuth.GetMultiAccountUser(r)

	switch r.Method {
	case http.MethodGet:
		err := s.Pages.NewString(w, pages.NewStringParams{
			BaseParams: pages.BaseParamsFromContext(r.Context()),
		})
		if err != nil {
			l.Error("failed to render", "err", err)
		}
	case http.MethodPost:
		if err := r.ParseForm(); err != nil {
			s.Pages.Notice(w, "error", "bad request")
			return
		}

		var title *string
		if val := r.FormValue("title"); val != "" {
			title = &val
		}

		var description *string
		if val := r.FormValue("description"); val != "" {
			description = &val
		}

		fileNames := r.Form["filename"]
		fileContents := r.Form["content"]
		if len(fileNames) != len(fileContents) {
			s.Pages.Notice(w, "error", "mismatched file name & content lengths")
			return
		}

		client, err := s.OAuth.AuthorizedClient(r)
		if err != nil {
			l.Error("failed to get authorized client", "err", err)
			s.Pages.Notice(w, "error", "Failed to create string.")
			return
		}

		files := make([]models.String_File, len(fileNames))
		var mu sync.Mutex

		g, gctx := errgroup.WithContext(ctx)
		g.SetLimit(10)
		for i := range len(files) {
			g.Go(func() error {
				blob, err := xrpc.RepoUploadBlob(gctx, client, strings.NewReader(fileContents[i]), textPlain)
				if err != nil {
					return err
				}
				if blob.Blob == nil {
					return fmt.Errorf("uploadBlob: blob is missing from response")
				}

				mu.Lock()
				files[i] = models.String_File{
					Name:    fileNames[i],
					Content: *blob.Blob,
				}
				mu.Unlock()
				return nil
			})
		}
		if err := g.Wait(); err != nil {
			l.Warn("failed to upload blobs", "err", err)
			s.Pages.Notice(w, "error", "Failed to upload blobs.")
			return
		}

		newString := models.String{
			Did:         syntax.DID(user.Did),
			Rkey:        syntax.RecordKey(tid.TID()),
			Title:       title,
			Description: description,
			Files:       files,
			Created:     time.Now(),
		}

		resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{
			Collection: tangled.StringNSID,
			Repo:       newString.Did.String(),
			Rkey:       newString.Rkey.String(),
			Record:     &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()},
		})
		if err != nil {
			l.Error("failed to create string", "err", err)
			s.Pages.Notice(w, "error", "Failed to create string.")
			return
		}
		l := l.With("aturi", resp.Uri)
		l.Info("created record", "files", len(newString.Files))

		// insert into DB
		if err = db.AddString(s.Db, newString); err != nil {
			l.Error("db: failed to insert string", "err", err)
		}

		s.Notifier.NewString(ctx, &newString)

		// successful
		s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey))
	}
}

func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
	l := s.Logger.With("handler", "delete")
	user := s.OAuth.GetMultiAccountUser(r)
	fail := func(msg string, err error) {
		l.Error(msg, "err", err)
		s.Pages.Notice(w, "error", msg)
	}

	id, ok := r.Context().Value("resolvedId").(identity.Identity)
	if !ok {
		l.Error("malformed middleware")
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	l = l.With("did", id.DID, "handle", id.Handle)

	rkey := chi.URLParam(r, "rkey")
	if rkey == "" {
		l.Error("malformed url, empty rkey")
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	if user.Did != id.DID.String() {
		fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
		return
	}

	client, err := s.OAuth.AuthorizedClient(r)
	if err != nil {
		fail("Failed to authorize client.", err)
		return
	}

	_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
		Collection: tangled.StringNSID,
		Repo:       user.Did,
		Rkey:       rkey,
	})
	if err != nil {
		fail("Failed to delete string record from PDS.", err)
		return
	}

	if err := db.DeleteString(
		s.Db,
		orm.FilterEq("did", user.Did),
		orm.FilterEq("rkey", rkey),
	); err != nil {
		fail("Failed to delete string.", err)
		return
	}

	s.Notifier.DeleteString(r.Context(), user.Did, rkey)

	s.Pages.HxRedirect(w, "/strings/"+user.Did)
}

// FileRaw renders raw file in that specific CID. (strong cache policy)
func (s *Strings) FileRaw(w http.ResponseWriter, r *http.Request) {
	l := s.Logger.With("handler", "FileRaw")
	ctx := r.Context()

	str, ok := stringFromContext(ctx)
	if !ok {
		l.Error("malformed middleware. string missing")
		s.Pages.Error404(w)
		return
	}
	filename := chi.URLParam(r, "filename")

	if str.IsLegacySingleFile() {
		if filename != str.FileName {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
		w.Header().Set("Content-Length", strconv.Itoa(len(str.FileContent)))
		_, err := w.Write([]byte(str.FileContent))
		if err != nil {
			l.Error("failed to write raw response", "err", err)
		}
	} else {
		file, ok := str.FileByName(filename)
		if !ok {
			http.NotFound(w, r)
			return
		}

		mimeType := file.Content.MimeType
		size := file.Content.Size

		blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref))
		if err != nil {
			l.Warn("failed to fetch blob", "err", err)
			http.NotFound(w, r)
			return
		}
		defer blob.Close()

		w.Header().Set("Content-Type", mimeType)
		w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
		w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
		if _, err := io.Copy(w, blob); err != nil {
			l.Error("failed to write raw response", "err", err)
		}
	}
}

func (s *Strings) makeFileFragmentParams(string *models.String, filename string, content string, forceCode bool) pages.StringFileFragmentParams {
	size := len(content)
	if size > 8*1024*1024 { // 8 MB
		// TODO: show "file too big" page
	}

	buf, _ := io.ReadAll(strings.NewReader(content))

	var lineCount int
	var hasNoTrailingEOL bool
	if size > 0 {
		hasNoTrailingEOL = !bytes.HasSuffix(buf, []byte{'\n'})
		lineCount = bytes.Count(buf, []byte{'\n'})
		if hasNoTrailingEOL {
			lineCount++
		}
	}

	format := markup.GetFormat(filename)
	isMarkup := format == markup.FormatMarkdown

	return pages.StringFileFragmentParams{
		String:  string,
		Name:    filename,
		Content: content,

		LineCount:         lineCount,
		Size:              uint64(size),
		HasNoTrailingEOL:  hasNoTrailingEOL,
		HasRenderedToggle: isMarkup,
		ShowingRendered:   isMarkup,
	}
}

// render each string "file" html fragment
func (s *Strings) FileFragment(w http.ResponseWriter, r *http.Request) {
	l := s.Logger.With("handler", "FileFragment")
	ctx := r.Context()

	str, ok := stringFromContext(ctx)
	if !ok {
		l.Error("malformed middleware. string missing")
		http.NotFound(w, r)
		return
	}
	filename := chi.URLParam(r, "filename")
	forceCode := r.URL.Query().Get("code") == "true"

	var params pages.StringFileFragmentParams
	if str.IsLegacySingleFile() {
		if filename != str.FileName {
			http.NotFound(w, r)
			return
		}
		params = s.makeFileFragmentParams(&str, str.FileName, str.FileContent, forceCode)
	} else {
		file, ok := str.FileByName(filename)
		if !ok {
			l.Error("malformed middleware. string missing")
			http.NotFound(w, r)
			return
		}

		blob, err := s.BlobStore.GetBlob(r.Context(), str.Did, cid.Cid(file.Content.Ref))
		if err != nil {
			l.Warn("failed to fetch blob", "err", err)
			http.NotFound(w, r)
			return
		}
		defer blob.Close()

		contentBytes, err := io.ReadAll(blob)
		if err != nil {
			l.Error("failed to read blob", "err", err)
		}

		params = s.makeFileFragmentParams(&str, file.Name, string(contentBytes), forceCode)
	}
	s.Pages.StringFileFragment(w, params)
}

func (s *Strings) FileEditFragment(w http.ResponseWriter, r *http.Request) {
	s.Pages.StringFileEditFragment(w)
}

func (s *Strings) getRecordCid(ctx context.Context, uri syntax.ATURI) (syntax.CID, error) {
	ident, err := s.Dir.Lookup(ctx, uri.Authority())
	if err != nil {
		return "", err
	}

	xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()}
	out, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String())
	if err != nil {
		return "", err
	}
	if out.Cid == nil {
		return "", fmt.Errorf("record CID is empty")
	}

	cid, err := syntax.ParseCID(*out.Cid)
	if err != nil {
		return "", err
	}

	return cid, nil
}

func gz(s string) io.Reader {
	var b bytes.Buffer
	w := gzip.NewWriter(&b)
	w.Write([]byte(s))
	w.Close()
	return &b
}
