package state

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"tangled.org/core/api/tangled"
	"tangled.org/core/appview"
	"tangled.org/core/appview/bsky"
	"tangled.org/core/appview/cloudflare"
	"tangled.org/core/appview/config"
	"tangled.org/core/appview/db"
	"tangled.org/core/appview/indexer"
	"tangled.org/core/appview/mentions"
	"tangled.org/core/appview/models"
	"tangled.org/core/appview/notify"
	dbnotify "tangled.org/core/appview/notify/db"
	phnotify "tangled.org/core/appview/notify/posthog"
	"tangled.org/core/appview/oauth"
	"tangled.org/core/appview/pages"
	"tangled.org/core/appview/reporesolver"
	"tangled.org/core/appview/validator"
	xrpcclient "tangled.org/core/appview/xrpcclient"
	"tangled.org/core/consts"
	"tangled.org/core/eventconsumer"
	"tangled.org/core/idresolver"
	"tangled.org/core/jetstream"
	"tangled.org/core/log"
	tlog "tangled.org/core/log"
	"tangled.org/core/orm"
	"tangled.org/core/rbac"
	"tangled.org/core/tid"

	comatproto "github.com/bluesky-social/indigo/api/atproto"
	atpclient "github.com/bluesky-social/indigo/atproto/client"
	"github.com/bluesky-social/indigo/atproto/syntax"
	lexutil "github.com/bluesky-social/indigo/lex/util"
	"github.com/bluesky-social/indigo/xrpc"
	securejoin "github.com/cyphar/filepath-securejoin"
	"github.com/go-chi/chi/v5"
	"github.com/posthog/posthog-go"
)

type State struct {
	db               *db.DB
	notifier         notify.Notifier
	indexer          *indexer.Indexer
	oauth            *oauth.OAuth
	enforcer         *rbac.Enforcer
	pages            *pages.Pages
	idResolver       *idresolver.Resolver
	mentionsResolver *mentions.Resolver
	posthog          posthog.Client
	jc               *jetstream.JetstreamClient
	config           *config.Config
	repoResolver     *reporesolver.RepoResolver
	knotstream       *eventconsumer.Consumer
	spindlestream    *eventconsumer.Consumer
	logger           *slog.Logger
	validator        *validator.Validator
	cfClient         *cloudflare.Client
}

func Make(ctx context.Context, config *config.Config) (*State, error) {
	logger := tlog.FromContext(ctx)

	d, err := db.Make(ctx, config.Core.DbPath)
	if err != nil {
		return nil, fmt.Errorf("failed to create db: %w", err)
	}

	indexer := indexer.New(log.SubLogger(logger, "indexer"))
	err = indexer.Init(ctx, d)
	if err != nil {
		return nil, fmt.Errorf("failed to create indexer: %w", err)
	}

	enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
	if err != nil {
		return nil, fmt.Errorf("failed to create enforcer: %w", err)
	}

	res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
	if err != nil {
		logger.Error("failed to create redis resolver", "err", err)
		res = idresolver.DefaultResolver(config.Plc.PLCURL)
	}

	posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
	if err != nil {
		return nil, fmt.Errorf("failed to create posthog client: %w", err)
	}

	pages := pages.NewPages(config, res, d, log.SubLogger(logger, "pages"))
	oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth"))
	if err != nil {
		return nil, fmt.Errorf("failed to start oauth handler: %w", err)
	}
	validator := validator.New(d, res, enforcer)

	repoResolver := reporesolver.New(config, enforcer, d)

	mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver"))

	wrapper := db.DbWrapper{Execer: d}
	jc, err := jetstream.NewJetstreamClient(
		config.Jetstream.Endpoint,
		"appview",
		[]string{
			tangled.GraphFollowNSID,
			tangled.FeedStarNSID,
			tangled.PublicKeyNSID,
			tangled.RepoArtifactNSID,
			tangled.ActorProfileNSID,
			tangled.KnotMemberNSID,
			tangled.SpindleMemberNSID,
			tangled.SpindleNSID,
			tangled.StringNSID,
			tangled.RepoIssueNSID,
			tangled.RepoIssueCommentNSID,
			tangled.LabelDefinitionNSID,
			tangled.LabelOpNSID,
		},
		nil,
		tlog.SubLogger(logger, "jetstream"),
		wrapper,
		false,

		// in-memory filter is inapplicable to appview so
		// we'll never log dids anyway.
		false,
	)
	if err != nil {
		return nil, fmt.Errorf("failed to create jetstream client: %w", err)
	}

	if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
		return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
	}

	ingester := appview.Ingester{
		Db:         wrapper,
		Enforcer:   enforcer,
		IdResolver: res,
		Config:     config,
		Logger:     log.SubLogger(logger, "ingester"),
		Validator:  validator,
	}
	err = jc.StartJetstream(ctx, ingester.Ingest())
	if err != nil {
		return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
	}

	var notifiers []notify.Notifier

	// Always add the database notifier
	notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))

	// Add other notifiers in production only
	if !config.Core.Dev {
		notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
	}
	notifiers = append(notifiers, indexer)

	// Add webhook notifier
	notifiers = append(notifiers, notify.NewWebhookNotifier(d))

	notifier := notify.NewMergedNotifier(notifiers)
	notifier = notify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify"))

	var cfClient *cloudflare.Client
	if config.Cloudflare.ApiToken != "" {
		cfClient, err = cloudflare.New(config)
		if err != nil {
			logger.Warn("failed to create cloudflare client, sites upload will be disabled", "err", err)
			cfClient = nil
		}
	}

	knotstream, err := Knotstream(ctx, config, d, enforcer, posthog, notifier, cfClient)
	if err != nil {
		return nil, fmt.Errorf("failed to start knotstream consumer: %w", err)
	}
	knotstream.Start(ctx)

	spindlestream, err := Spindlestream(ctx, config, d, enforcer)
	if err != nil {
		return nil, fmt.Errorf("failed to start spindlestream consumer: %w", err)
	}
	spindlestream.Start(ctx)

	state := &State{
		db:               d,
		notifier:         notifier,
		indexer:          indexer,
		oauth:            oauth,
		enforcer:         enforcer,
		pages:            pages,
		idResolver:       res,
		mentionsResolver: mentionsResolver,
		posthog:          posthog,
		jc:               jc,
		config:           config,
		repoResolver:     repoResolver,
		knotstream:       knotstream,
		spindlestream:    spindlestream,
		logger:           logger,
		validator:        validator,
		cfClient:         cfClient,
	}

	// fetch initial bluesky posts if configured
	go fetchBskyPosts(ctx, res, config, d, logger)

	return state, nil
}

func (s *State) Close() error {
	// other close up logic goes here
	return s.db.Close()
}

func (s *State) SecurityTxt(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	w.Header().Set("Cache-Control", "public, max-age=86400") // one day

	securityTxt := `Contact: mailto:security@tangled.org
Preferred-Languages: en
Canonical: https://tangled.org/.well-known/security.txt
Expires: 2030-01-01T21:59:00.000Z
`
	w.Write([]byte(securityTxt))
}

func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	w.Header().Set("Cache-Control", "public, max-age=86400") // one day

	robotsTxt := `# Hello, Tanglers!
User-agent: *
Allow: /
Disallow: /*/*/settings
Disallow: /settings
Disallow: /*/*/compare
Disallow: /*/*/fork

Crawl-delay: 1
`
	w.Write([]byte(robotsTxt))
}

func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
	user := s.oauth.GetMultiAccountUser(r)
	s.pages.TermsOfService(w, pages.TermsOfServiceParams{
		LoggedInUser: user,
	})
}

func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) {
	user := s.oauth.GetMultiAccountUser(r)
	s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
		LoggedInUser: user,
	})
}

func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
	user := s.oauth.GetMultiAccountUser(r)
	s.pages.Brand(w, pages.BrandParams{
		LoggedInUser: user,
	})
}

func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
	user := s.oauth.GetMultiAccountUser(r)
	if user == nil {
		return
	}

	l := s.logger.With("handler", "UpgradeBanner")
	l = l.With("did", user.Active.Did)

	regs, err := db.GetRegistrations(
		s.db,
		orm.FilterEq("did", user.Active.Did),
		orm.FilterEq("needs_upgrade", 1),
	)
	if err != nil {
		l.Error("non-fatal: failed to get registrations", "err", err)
	}

	spindles, err := db.GetSpindles(
		s.db,
		orm.FilterEq("owner", user.Active.Did),
		orm.FilterEq("needs_upgrade", 1),
	)
	if err != nil {
		l.Error("non-fatal: failed to get spindles", "err", err)
	}

	if regs == nil && spindles == nil {
		return
	}

	s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{
		Registrations: regs,
		Spindles:      spindles,
	})
}

func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
	user := chi.URLParam(r, "user")
	user = strings.TrimPrefix(user, "@")

	if user == "" {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	id, err := s.idResolver.ResolveIdent(r.Context(), user)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
	if err != nil {
		s.logger.Error("failed to get public keys", "err", err)
		http.Error(w, "failed to get public keys", http.StatusInternalServerError)
		return
	}

	if len(pubKeys) == 0 {
		w.WriteHeader(http.StatusNoContent)
		return
	}

	for _, k := range pubKeys {
		key := strings.TrimRight(k.Key, "\n")
		fmt.Fprintln(w, key)
	}
}

func validateRepoName(name string) error {
	// check for path traversal attempts
	if name == "." || name == ".." ||
		strings.Contains(name, "/") || strings.Contains(name, "\\") {
		return fmt.Errorf("Repository name contains invalid path characters")
	}

	// check for sequences that could be used for traversal when normalized
	if strings.Contains(name, "./") || strings.Contains(name, "../") ||
		strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
		return fmt.Errorf("Repository name contains invalid path sequence")
	}

	// then continue with character validation
	for _, char := range name {
		if !((char >= 'a' && char <= 'z') ||
			(char >= 'A' && char <= 'Z') ||
			(char >= '0' && char <= '9') ||
			char == '-' || char == '_' || char == '.') {
			return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
		}
	}

	// additional check to prevent multiple sequential dots
	if strings.Contains(name, "..") {
		return fmt.Errorf("Repository name cannot contain sequential dots")
	}

	// if all checks pass
	return nil
}

func stripGitExt(name string) string {
	return strings.TrimSuffix(name, ".git")
}

func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		user := s.oauth.GetMultiAccountUser(r)
		knots, err := s.enforcer.GetKnotsForUser(user.Active.Did)
		if err != nil {
			s.pages.Notice(w, "repo", "Invalid user account.")
			return
		}

		s.pages.NewRepo(w, pages.NewRepoParams{
			LoggedInUser: user,
			Knots:        knots,
		})

	case http.MethodPost:
		l := s.logger.With("handler", "NewRepo")

		user := s.oauth.GetMultiAccountUser(r)
		l = l.With("did", user.Active.Did)

		// form validation
		domain := r.FormValue("domain")
		if domain == "" {
			s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.")
			return
		}
		l = l.With("knot", domain)

		repoName := r.FormValue("name")
		if repoName == "" {
			s.pages.Notice(w, "repo", "Repository name cannot be empty.")
			return
		}

		if err := validateRepoName(repoName); err != nil {
			s.pages.Notice(w, "repo", err.Error())
			return
		}
		repoName = stripGitExt(repoName)
		l = l.With("repoName", repoName)

		defaultBranch := r.FormValue("branch")
		if defaultBranch == "" {
			defaultBranch = "main"
		}
		l = l.With("defaultBranch", defaultBranch)

		description := r.FormValue("description")
		if len([]rune(description)) > 140 {
			s.pages.Notice(w, "repo", "Description must be 140 characters or fewer.")
			return
		}

		// ACL validation
		ok, err := s.enforcer.E.Enforce(user.Active.Did, domain, domain, "repo:create")
		if err != nil || !ok {
			l.Info("unauthorized")
			s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
			return
		}

		// Check for existing repos
		existingRepo, err := db.GetRepo(
			s.db,
			orm.FilterEq("did", user.Active.Did),
			orm.FilterEq("name", repoName),
		)
		if err == nil && existingRepo != nil {
			l.Info("repo exists")
			s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
			return
		}

		// create atproto record for this repo
		rkey := tid.TID()
		repo := &models.Repo{
			Did:         user.Active.Did,
			Name:        repoName,
			Knot:        domain,
			Rkey:        rkey,
			Description: description,
			Created:     time.Now(),
			Labels:      s.config.Label.DefaultLabelDefs,
		}
		record := repo.AsRecord()

		atpClient, err := s.oauth.AuthorizedClient(r)
		if err != nil {
			l.Info("PDS write failed", "err", err)
			s.pages.Notice(w, "repo", "Failed to write record to PDS.")
			return
		}

		atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
			Collection: tangled.RepoNSID,
			Repo:       user.Active.Did,
			Rkey:       rkey,
			Record: &lexutil.LexiconTypeDecoder{
				Val: &record,
			},
		})
		if err != nil {
			l.Info("PDS write failed", "err", err)
			s.pages.Notice(w, "repo", "Failed to announce repository creation.")
			return
		}

		aturi := atresp.Uri
		l = l.With("aturi", aturi)
		l.Info("wrote to PDS")

		tx, err := s.db.BeginTx(r.Context(), nil)
		if err != nil {
			l.Info("txn failed", "err", err)
			s.pages.Notice(w, "repo", "Failed to save repository information.")
			return
		}

		// The rollback function reverts a few things on failure:
		// - the pending txn
		// - the ACLs
		// - the atproto record created
		rollback := func() {
			err1 := tx.Rollback()
			err2 := s.enforcer.E.LoadPolicy()
			err3 := rollbackRecord(context.Background(), aturi, atpClient)

			// ignore txn complete errors, this is okay
			if errors.Is(err1, sql.ErrTxDone) {
				err1 = nil
			}

			if errs := errors.Join(err1, err2, err3); errs != nil {
				l.Error("failed to rollback changes", "errs", errs)
				return
			}
		}
		defer rollback()

		client, err := s.oauth.ServiceClient(
			r,
			oauth.WithService(domain),
			oauth.WithLxm(tangled.RepoCreateNSID),
			oauth.WithDev(s.config.Core.Dev),
		)
		if err != nil {
			l.Error("service auth failed", "err", err)
			s.pages.Notice(w, "repo", "Failed to reach PDS.")
			return
		}

		xe := tangled.RepoCreate(
			r.Context(),
			client,
			&tangled.RepoCreate_Input{
				Rkey: rkey,
			},
		)
		if err := xrpcclient.HandleXrpcErr(xe); err != nil {
			l.Error("xrpc error", "xe", xe)
			s.pages.Notice(w, "repo", err.Error())
			return
		}

		err = db.AddRepo(tx, repo)
		if err != nil {
			l.Error("db write failed", "err", err)
			s.pages.Notice(w, "repo", "Failed to save repository information.")
			return
		}

		// acls
		p, _ := securejoin.SecureJoin(user.Active.Did, repoName)
		err = s.enforcer.AddRepo(user.Active.Did, domain, p)
		if err != nil {
			l.Error("acl setup failed", "err", err)
			s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
			return
		}

		err = tx.Commit()
		if err != nil {
			l.Error("txn commit failed", "err", err)
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		err = s.enforcer.E.SavePolicy()
		if err != nil {
			l.Error("acl save failed", "err", err)
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		// reset the ATURI because the transaction completed successfully
		aturi = ""

		s.notifier.NewRepo(r.Context(), repo)
		s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Active.Did, repoName))
	}
}

// this is used to rollback changes made to the PDS
//
// it is a no-op if the provided ATURI is empty
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
	if aturi == "" {
		return nil
	}

	parsed := syntax.ATURI(aturi)

	collection := parsed.Collection().String()
	repo := parsed.Authority().String()
	rkey := parsed.RecordKey().String()

	_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
		Collection: collection,
		Repo:       repo,
		Rkey:       rkey,
	})
	return err
}

func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
	defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults))
	if err != nil {
		return err
	}
	// already present
	if len(defaultLabels) == len(defaults) {
		return nil
	}

	labelDefs, err := models.FetchLabelDefs(r, defaults)
	if err != nil {
		return err
	}

	// Insert each label definition to the database
	for _, labelDef := range labelDefs {
		_, err = db.AddLabelDefinition(e, &labelDef)
		if err != nil {
			return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err)
		}
	}

	return nil
}

func fetchBskyPosts(ctx context.Context, res *idresolver.Resolver, config *config.Config, d *db.DB, logger *slog.Logger) {
	resolved, err := res.ResolveIdent(context.Background(), consts.TangledDid)
	if err != nil {
		logger.Error("failed to resolve tangled.org DID", "err", err)
		return
	}

	pdsEndpoint := resolved.PDSEndpoint()
	if pdsEndpoint == "" {
		logger.Error("no PDS endpoint found for tangled.sh DID")
		return
	}

	session, err := oauth.CreateAppPasswordSession(res, config.Core.AppPassword, consts.TangledDid, logger)
	if err != nil {
		logger.Error("failed to create appassword session... skipping fetch", "err", err)
		return
	}

	client := xrpc.Client{
		Auth: &xrpc.AuthInfo{
			AccessJwt: session.AccessJwt,
			Did:       session.Did,
		},
		Host: session.PdsEndpoint,
	}

	l := log.SubLogger(logger, "bluesky")

	ticker := time.NewTicker(config.Bluesky.UpdateInterval)
	defer ticker.Stop()

	for {
		posts, _, err := bsky.FetchPosts(ctx, &client, 20, "")
		if err != nil {
			l.Error("failed to fetch bluesky posts", "err", err)
		} else if err := db.InsertBlueskyPosts(d, posts); err != nil {
			l.Error("failed to insert bluesky posts", "err", err)
		} else {
			l.Info("inserted bluesky posts", "count", len(posts))
		}

		select {
		case <-ticker.C:
		case <-ctx.Done():
			l.Info("stopping bluesky updater")
			return
		}
	}
}
