package dodoapp

import (
	"bytes"
	"context"
	"embed"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"net/http"
	"slices"
	"sort"
	"strconv"
	"strings"
	"sync"
	ttemplate "text/template"
	"time"

	"golang.org/x/crypto/bcrypt"
	"golang.org/x/exp/rand"

	"github.com/giolekva/pcloud/core/installer"
	"github.com/giolekva/pcloud/core/installer/server"
	"github.com/giolekva/pcloud/core/installer/soft"
	"github.com/giolekva/pcloud/core/installer/tasks"

	"cuelang.org/go/cue"
	"github.com/gorilla/mux"
	"github.com/gorilla/securecookie"
)

//go:embed templates/*
var templates embed.FS

//go:embed all:app-templates
var appTmplsFS embed.FS

//go:embed static/*
var staticAssets embed.FS

//go:embed schemas/app.schema.json
var dodoAppJsonSchema string

const (
	ConfigRepoName = "config"
	appConfigsFile = "/apps.json"
	loginPath      = "/login"
	logoutPath     = "/logout"
	staticPath     = "/static/"
	schemasPath    = "/schemas/"
	apiPublicData  = "/api/public-data"
	apiCreateApp   = "/api/apps"
	sessionCookie  = "dodo-app-session"
	userCtx        = "user"
	initCommitMsg  = "init"
)

type tmplts struct {
	index        *template.Template
	appStatus    *template.Template
	commitStatus *template.Template
	logs         *template.Template
}

func parseTemplates(fs embed.FS) (tmplts, error) {
	base, err := template.ParseFS(fs, "templates/base.html")
	if err != nil {
		return tmplts{}, err
	}
	parse := func(path string) (*template.Template, error) {
		if b, err := base.Clone(); err != nil {
			return nil, err
		} else {
			return b.ParseFS(fs, path)
		}
	}
	index, err := parse("templates/index.html")
	if err != nil {
		return tmplts{}, err
	}
	appStatus, err := parse("templates/app_status.html")
	if err != nil {
		return tmplts{}, err
	}
	commitStatus, err := parse("templates/commit_status.html")
	if err != nil {
		return tmplts{}, err
	}
	logs, err := parse("templates/logs.html")
	if err != nil {
		return tmplts{}, err
	}
	return tmplts{index, appStatus, commitStatus, logs}, nil
}

type Server struct {
	l                 sync.Locker
	st                Store
	nf                NetworkFilter
	ug                UserGetter
	port              int
	apiPort           int
	self              string
	selfPublic        string
	repoPublicAddr    string
	sshKey            string
	gitRepoPublicKey  string
	client            soft.Client
	namespace         string
	envAppManagerAddr string
	env               installer.EnvConfig
	nsc               installer.NamespaceCreator
	jc                installer.JobCreator
	vpnKeyGen         installer.VPNAPIClient
	cnc               installer.ClusterNetworkConfigurator
	workers           map[string]map[string]struct{}
	appConfigs        map[string]appConfig
	tmplts            tmplts
	appTmpls          AppTmplStore
	external          bool
	fetchUsersAddr    string
	reconciler        tasks.Reconciler
	logs              map[string]string
	schemaTmpl        *ttemplate.Template
}

type appConfig struct {
	Namespace string `json:"namespace"`
	Network   string `json:"network"`
}

// TODO(gio): Initialize appNs on startup
func NewServer(
	st Store,
	nf NetworkFilter,
	ug UserGetter,
	port int,
	apiPort int,
	self string,
	selfPublic string,
	repoPublicAddr string,
	sshKey string,
	gitRepoPublicKey string,
	client soft.Client,
	namespace string,
	envAppManagerAddr string,
	nsc installer.NamespaceCreator,
	jc installer.JobCreator,
	vpnKeyGen installer.VPNAPIClient,
	cnc installer.ClusterNetworkConfigurator,
	env installer.EnvConfig,
	external bool,
	fetchUsersAddr string,
	reconciler tasks.Reconciler,
) (*Server, error) {
	tmplts, err := parseTemplates(templates)
	if err != nil {
		return nil, err
	}
	apps, err := fs.Sub(appTmplsFS, "app-templates")
	if err != nil {
		return nil, err
	}
	appTmpls, err := NewAppTmplStoreFS(apps)
	if err != nil {
		return nil, err
	}
	schemaTmpl, err := ttemplate.New("app.schema.json").Parse(dodoAppJsonSchema)
	if err != nil {
		return nil, err
	}
	s := &Server{
		&sync.Mutex{},
		st,
		nf,
		ug,
		port,
		apiPort,
		self,
		selfPublic,
		repoPublicAddr,
		sshKey,
		gitRepoPublicKey,
		client,
		namespace,
		envAppManagerAddr,
		env,
		nsc,
		jc,
		vpnKeyGen,
		cnc,
		map[string]map[string]struct{}{},
		map[string]appConfig{},
		tmplts,
		appTmpls,
		external,
		fetchUsersAddr,
		reconciler,
		map[string]string{},
		schemaTmpl,
	}
	config, err := client.GetRepo(ConfigRepoName)
	if err != nil {
		return nil, err
	}
	r, err := config.Reader(appConfigsFile)
	if err == nil {
		defer r.Close()
		if err := json.NewDecoder(r).Decode(&s.appConfigs); err != nil {
			return nil, err
		}
	} else if !errors.Is(err, fs.ErrNotExist) {
		return nil, err
	}
	return s, nil
}

func (s *Server) getAppConfig(app, branch string) appConfig {
	return s.appConfigs[fmt.Sprintf("%s-%s", app, branch)]
}

func (s *Server) setAppConfig(app, branch string, cfg appConfig) {
	s.appConfigs[fmt.Sprintf("%s-%s", app, branch)] = cfg
}

func (s *Server) Start() error {
	// if err := s.client.DisableKeyless(); err != nil {
	// 	return err
	// }
	// if err := s.client.DisableAnonAccess(); err != nil {
	// 	return err
	// }
	e := make(chan error)
	go func() {
		r := mux.NewRouter()
		r.Use(s.mwAuth)
		r.HandleFunc(schemasPath+"{user}/app.schema.json", s.handleSchema).Methods(http.MethodGet)
		r.PathPrefix(staticPath).Handler(server.NewCachingHandler(http.FileServer(http.FS(staticAssets))))
		r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
		r.HandleFunc(apiPublicData, s.handleAPIPublicData)
		r.HandleFunc(apiCreateApp, s.handleAPICreateApp).Methods(http.MethodPost)
		r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
		r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
		r.HandleFunc("/{app-name}/logs", s.handleAppLogs).Methods(http.MethodGet)
		r.HandleFunc("/{app-name}/{hash}", s.handleAppCommit).Methods(http.MethodGet)
		r.HandleFunc("/{app-name}/dev-branch/create", s.handleCreateDevBranch).Methods(http.MethodPost)
		r.HandleFunc("/{app-name}/branch/{branch}", s.handleAppStatus).Methods(http.MethodGet)
		r.HandleFunc("/{app-name}/branch/{branch}/delete", s.handleBranchDelete).Methods(http.MethodPost)
		r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
		r.HandleFunc("/{app-name}/delete", s.handleAppDelete).Methods(http.MethodPost)
		r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
		r.HandleFunc("/", s.handleCreateApp).Methods(http.MethodPost)
		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
	}()
	go func() {
		r := mux.NewRouter()
		r.HandleFunc("/update", s.handleAPIUpdate)
		r.HandleFunc("/api/apps/{app-name}/workers", s.handleAPIRegisterWorker).Methods(http.MethodPost)
		r.HandleFunc("/api/add-public-key", s.handleAPIAddPublicKey).Methods(http.MethodPost)
		r.HandleFunc("/api/apps/{app-name}/branch/{branch}/env-profile", s.handleBranchEnvProfile).Methods(http.MethodGet)
		if !s.external {
			r.HandleFunc("/api/sync-users", s.handleAPISyncUsers).Methods(http.MethodGet)
		}
		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
	}()
	if !s.external {
		go func() {
			s.syncUsers()
			for {
				delay := time.Duration(rand.Intn(60)+60) * time.Second
				time.Sleep(delay)
				s.syncUsers()
			}
		}()
	}
	return <-e
}

type UserGetter interface {
	Get(r *http.Request) string
	Encode(w http.ResponseWriter, user string) error
}

type externalUserGetter struct {
	sc *securecookie.SecureCookie
}

func NewExternalUserGetter() UserGetter {
	return &externalUserGetter{securecookie.New(
		securecookie.GenerateRandomKey(64),
		securecookie.GenerateRandomKey(32),
	)}
}

func (ug *externalUserGetter) Get(r *http.Request) string {
	cookie, err := r.Cookie(sessionCookie)
	if err != nil {
		return ""
	}
	var user string
	if err := ug.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
		return ""
	}
	return user
}

func (ug *externalUserGetter) Encode(w http.ResponseWriter, user string) error {
	if encoded, err := ug.sc.Encode(sessionCookie, user); err == nil {
		cookie := &http.Cookie{
			Name:     sessionCookie,
			Value:    encoded,
			Path:     "/",
			Secure:   true,
			HttpOnly: true,
		}
		http.SetCookie(w, cookie)
		return nil
	} else {
		return err
	}
}

type internalUserGetter struct{}

func NewInternalUserGetter() UserGetter {
	return internalUserGetter{}
}

func (ug internalUserGetter) Get(r *http.Request) string {
	return r.Header.Get("X-Forwarded-User")
}

func (ug internalUserGetter) Encode(w http.ResponseWriter, user string) error {
	return nil
}

func (s *Server) mwAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasSuffix(r.URL.Path, loginPath) ||
			strings.HasPrefix(r.URL.Path, logoutPath) ||
			strings.HasPrefix(r.URL.Path, staticPath) ||
			strings.HasPrefix(r.URL.Path, schemasPath) ||
			strings.HasPrefix(r.URL.Path, apiPublicData) ||
			strings.HasPrefix(r.URL.Path, apiCreateApp) {
			next.ServeHTTP(w, r)
			return
		}
		user := s.ug.Get(r)
		if user == "" {
			vars := mux.Vars(r)
			appName, ok := vars["app-name"]
			if !ok || appName == "" {
				http.Error(w, "missing app-name", http.StatusBadRequest)
				return
			}
			http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
	})
}

type schemaNetwork struct {
	Value string `json:"const"`
}

func (s *Server) handleSchema(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/schema+json")
	vars := mux.Vars(r)
	user, ok := vars["user"]
	if !ok {
		http.Error(w, "no user", http.StatusBadRequest)
		return
	}
	networks, err := s.getNetworks(user)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var names []schemaNetwork
	for _, n := range networks {
		names = append(names, schemaNetwork{n.Name})
	}
	var tmp strings.Builder
	if err := json.NewEncoder(&tmp).Encode(names); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := s.schemaTmpl.Execute(w, map[string]any{
		"Networks": tmp.String(),
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
	// TODO(gio): move to UserGetter
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookie,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
	})
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	fmt.Fprint(w, `
<!DOCTYPE html>
<html lang='en'>
	<head>
		<title>dodo: app - login</title>
		<meta charset='utf-8'>
	</head>
	<body>
        <form action="" method="POST">
          <input type="password" placeholder="Password" name="password" required />
          <button type="submit">Login</button>
        </form>
	</body>
</html>
`)
}

func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	password := r.FormValue("password")
	if password == "" {
		http.Error(w, "missing password", http.StatusBadRequest)
		return
	}
	user, err := s.st.GetAppOwner(appName)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	hashed, err := s.st.GetUserPassword(user)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
		http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
		return
	}
	if err := s.ug.Encode(w, user); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
}

type navItem struct {
	Name    string
	Address string
}

type statusData struct {
	Navigation []navItem
	Apps       []string
	Networks   []installer.Network
	Types      []string
}

func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
	user := r.Context().Value(userCtx)
	if user == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	var apps []string
	var err error
	if s.external {
		apps, err = s.st.GetUserApps(user.(string))
	} else {
		apps, err = s.st.GetApps()
	}
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	networks, err := s.getNetworks(user.(string))
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var types []string
	for _, t := range s.appTmpls.Types() {
		types = append(types, strings.Replace(t, "-", ":", 1))
	}
	n := []navItem{navItem{"Home", "/"}}
	data := statusData{n, apps, networks, types}
	if err := s.tmplts.index.Execute(w, data); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

type appStatusData struct {
	Navigation      []navItem
	Name            string
	Branch          string
	GitCloneCommand string
	Commits         []CommitMeta
	LastCommit      resourceData
	Branches        []string
}

func (s *Server) handleAppStatus(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	branch, ok := vars["branch"]
	if !ok || branch == "" {
		branch = "master"
	}
	u := r.Context().Value(userCtx)
	if u == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	user, ok := u.(string)
	if !ok {
		http.Error(w, "could not get user", http.StatusInternalServerError)
		return
	}
	owner, err := s.st.GetAppOwner(appName)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if s.external && owner != user {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	commits, err := s.st.GetCommitHistory(appName, branch)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var lastCommitResources resourceData
	if len(commits) > 0 {
		lastCommit, err := s.st.GetCommit(commits[len(commits)-1].Hash)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		r, err := extractResourceData(lastCommit.Resources)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		lastCommitResources = r
	}
	branches, err := s.st.GetBranches(appName)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	data := appStatusData{
		Navigation: []navItem{
			navItem{"Home", "/"},
			navItem{appName, "/" + appName},
		},
		Name:            appName,
		Branch:          branch,
		GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
		Commits:         commits,
		LastCommit:      lastCommitResources,
		Branches:        branches,
	}
	if branch != "master" {
		data.Navigation = append(data.Navigation, navItem{branch, fmt.Sprintf("/%s/branch/%s", appName, branch)})
	}
	if err := s.tmplts.appStatus.Execute(w, data); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

type appEnv struct {
	Profile string `json:"envProfile"`
}

func (s *Server) handleBranchEnvProfile(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	branch, ok := vars["branch"]
	if !ok || branch == "" {
		branch = "master"
	}
	info, err := s.st.GetLastCommitInfo(appName, branch)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var e appEnv
	if err := json.NewDecoder(bytes.NewReader(info.Resources.RenderedRaw)).Decode(&e); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	fmt.Fprintln(w, e.Profile)
}

type volume struct {
	Name string
	Size string
}

type postgresql struct {
	Name    string
	Version string
	Volume  string
}

type mongodb struct {
	Name    string
	Version string
	Volume  string
}

type ingress struct {
	Name string
	Host string
	Home string
}

type vm struct {
	Name     string
	User     string
	CPUCores int
	Memory   string
}

type resourceData struct {
	Volume         []volume
	PostgreSQL     []postgresql
	MongoDB        []mongodb
	Ingress        []ingress
	VirtualMachine []vm
	Env            []string
}

type commitStatusData struct {
	Navigation []navItem
	AppName    string
	Commit     Commit
	Resources  resourceData
}

func (s *Server) handleAppCommit(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	hash, ok := vars["hash"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	u := r.Context().Value(userCtx)
	if u == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	user, ok := u.(string)
	if !ok {
		http.Error(w, "could not get user", http.StatusInternalServerError)
		return
	}
	owner, err := s.st.GetAppOwner(appName)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if s.external && owner != user {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	commit, err := s.st.GetCommit(hash)
	if err != nil {
		// TODO(gio): not-found ?
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var res strings.Builder
	if err := json.NewEncoder(&res).Encode(commit.Resources.Helm); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	resData, err := extractResourceData(commit.Resources)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	data := commitStatusData{
		Navigation: []navItem{
			navItem{"Home", "/"},
			navItem{appName, "/" + appName},
			navItem{hash, "/" + appName + "/" + hash},
		},
		AppName:   appName,
		Commit:    commit,
		Resources: resData,
	}
	if err := s.tmplts.commitStatus.Execute(w, data); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

type logData struct {
	Navigation []navItem
	AppName    string
	Logs       template.HTML
}

func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	u := r.Context().Value(userCtx)
	if u == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	user, ok := u.(string)
	if !ok {
		http.Error(w, "could not get user", http.StatusInternalServerError)
		return
	}
	owner, err := s.st.GetAppOwner(appName)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if s.external && owner != user {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	data := logData{
		Navigation: []navItem{
			navItem{"Home", "/"},
			navItem{appName, "/" + appName},
			navItem{"Logs", "/" + appName + "/logs"},
		},
		AppName: appName,
		Logs:    template.HTML(strings.ReplaceAll(s.logs[appName], "\n", "<br/>")),
	}
	if err := s.tmplts.logs.Execute(w, data); err != nil {
		fmt.Println(err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

type apiUpdateReq struct {
	Ref        string `json:"ref"`
	Repository struct {
		Name string `json:"name"`
	} `json:"repository"`
	After   string `json:"after"`
	Commits []struct {
		Id      string `json:"id"`
		Message string `json:"message"`
	} `json:"commits"`
}

func (s *Server) handleAPIUpdate(w http.ResponseWriter, r *http.Request) {
	fmt.Println("update")
	var req apiUpdateReq
	var contents strings.Builder
	io.Copy(&contents, r.Body)
	c := contents.String()
	fmt.Println(c)
	if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if strings.HasPrefix(req.Ref, "refs/heads/dodo_") || req.Repository.Name == ConfigRepoName {
		return
	}
	branch, ok := strings.CutPrefix(req.Ref, "refs/heads/")
	if !ok {
		http.Error(w, "invalid branch", http.StatusBadRequest)
		return
	}
	// TODO(gio): Create commit record on app init as well
	go func() {
		owner, err := s.st.GetAppOwner(req.Repository.Name)
		if err != nil {
			return
		}
		networks, err := s.getNetworks(owner)
		if err != nil {
			return
		}
		// TODO(gio): get only available ones by owner
		clusters, err := s.getClusters()
		if err != nil {
			return
		}
		apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
		instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
		if err != nil {
			return
		}
		found := false
		commitMsg := ""
		for _, c := range req.Commits {
			if c.Id == req.After {
				found = true
				commitMsg = c.Message
				break
			}
		}
		if !found {
			fmt.Printf("Error: could not find commit message")
			return
		}
		s.l.Lock()
		defer s.l.Unlock()
		resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, branch, s.getAppConfig(req.Repository.Name, branch).Namespace, networks, clusters, owner)
		if err = s.createCommit(req.Repository.Name, branch, req.After, commitMsg, err, resources); err != nil {
			fmt.Printf("Error: %s\n", err.Error())
			return
		}
		for addr, _ := range s.workers[req.Repository.Name] {
			go func() {
				// TODO(gio): make port configurable
				http.Get(fmt.Sprintf("http://%s/update", addr))
			}()
		}
	}()
}

type apiRegisterWorkerReq struct {
	Address string `json:"address"`
	Logs    string `json:"logs"`
}

func (s *Server) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
	// TODO(gio): lock
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	var req apiRegisterWorkerReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if _, ok := s.workers[appName]; !ok {
		s.workers[appName] = map[string]struct{}{}
	}
	s.workers[appName][req.Address] = struct{}{}
	s.logs[appName] = req.Logs
}

func (s *Server) handleCreateApp(w http.ResponseWriter, r *http.Request) {
	u := r.Context().Value(userCtx)
	if u == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	user, ok := u.(string)
	if !ok {
		http.Error(w, "could not get user", http.StatusInternalServerError)
		return
	}
	network := r.FormValue("network")
	if network == "" {
		http.Error(w, "missing network", http.StatusBadRequest)
		return
	}
	subdomain := r.FormValue("subdomain")
	if subdomain == "" {
		http.Error(w, "missing subdomain", http.StatusBadRequest)
		return
	}
	appType := r.FormValue("type")
	if appType == "" {
		http.Error(w, "missing type", http.StatusBadRequest)
		return
	}
	appName := r.FormValue("name")
	var err error
	if appName == "" {
		g := installer.NewFixedLengthRandomNameGenerator(3)
		appName, err = g.Generate()
	}
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if ok, err := s.client.UserExists(user); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	} else if !ok {
		http.Error(w, "user sync has not finished, please try again in few minutes", http.StatusFailedDependency)
		return
	}
	if err := s.st.CreateUser(user, nil, network); err != nil && !errors.Is(err, ErrorAlreadyExists) {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := s.st.CreateApp(appName, user); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := s.createApp(user, appName, appType, network, subdomain); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
}

func (s *Server) handleCreateDevBranch(w http.ResponseWriter, r *http.Request) {
	u := r.Context().Value(userCtx)
	if u == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	user, ok := u.(string)
	if !ok {
		http.Error(w, "could not get user", http.StatusInternalServerError)
		return
	}
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	branch := r.FormValue("branch")
	if branch == "" {
		http.Error(w, "missing branch", http.StatusBadRequest)
		return
	}
	if err := s.createDevBranch(appName, "master", branch, user); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/%s/branch/%s", appName, branch), http.StatusSeeOther)
}

func (s *Server) handleBranchDelete(w http.ResponseWriter, r *http.Request) {
	u := r.Context().Value(userCtx)
	if u == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	branch, ok := vars["branch"]
	if !ok || branch == "" {
		http.Error(w, "missing branch", http.StatusBadRequest)
		return
	}
	if err := s.deleteBranch(appName, branch); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
}

func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
	u := r.Context().Value(userCtx)
	if u == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	vars := mux.Vars(r)
	appName, ok := vars["app-name"]
	if !ok || appName == "" {
		http.Error(w, "missing app-name", http.StatusBadRequest)
		return
	}
	if err := s.deleteApp(appName); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

type apiCreateAppReq struct {
	AppType        string `json:"type"`
	AdminPublicKey string `json:"adminPublicKey"`
	Network        string `json:"network"`
	Subdomain      string `json:"subdomain"`
}

type apiCreateAppResp struct {
	AppName  string `json:"appName"`
	Password string `json:"password"`
}

func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Access-Control-Allow-Origin", "*")
	var req apiCreateAppReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	g := installer.NewFixedLengthRandomNameGenerator(3)
	appName, err := g.Generate()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	user, err := s.client.FindUser(req.AdminPublicKey)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if user != "" {
		http.Error(w, "public key already registered", http.StatusBadRequest)
		return
	}
	user = appName
	if err := s.client.AddUser(user, req.AdminPublicKey); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	password := generatePassword()
	hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := s.st.CreateUser(user, hashed, req.Network); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := s.st.CreateApp(appName, user); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := s.createApp(user, appName, req.AppType, req.Network, req.Subdomain); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	resp := apiCreateAppResp{
		AppName:  appName,
		Password: password,
	}
	if err := json.NewEncoder(w).Encode(resp); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

func (s *Server) isNetworkUseAllowed(network string) bool {
	if !s.external {
		return true
	}
	for _, cfg := range s.appConfigs {
		if strings.ToLower(cfg.Network) == network {
			return false
		}
	}
	return true
}

func (s *Server) createApp(user, appName, appType, network, subdomain string) error {
	s.l.Lock()
	defer s.l.Unlock()
	fmt.Printf("Creating app: %s\n", appName)
	network = strings.ToLower(network)
	if !s.isNetworkUseAllowed(network) {
		return fmt.Errorf("network already used: %s", network)
	}
	if ok, err := s.client.RepoExists(appName); err != nil {
		return err
	} else if ok {
		return nil
	}
	networks, err := s.getNetworks(user)
	if err != nil {
		return err
	}
	n, ok := installer.NetworkMap(networks)[network]
	if !ok {
		return fmt.Errorf("network not found: %s\n", network)
	}
	if err := s.client.AddRepository(appName); err != nil {
		return err
	}
	appRepo, err := s.client.GetRepo(appName)
	if err != nil {
		return err
	}
	files, err := s.renderAppConfigTemplate(user, appType, n, subdomain)
	if err != nil {
		return err
	}
	return s.createAppForBranch(appRepo, appName, "master", user, network, files)
}

func (s *Server) createDevBranch(appName, fromBranch, toBranch, user string) error {
	s.l.Lock()
	defer s.l.Unlock()
	fmt.Printf("Creating dev branch app: %s %s %s\n", appName, fromBranch, toBranch)
	appRepo, err := s.client.GetRepoBranch(appName, fromBranch)
	if err != nil {
		return err
	}
	appCfg, err := soft.ReadFile(appRepo, "app.json")
	if err != nil {
		return err
	}
	network, branchCfg, err := createDevBranchAppConfig(appCfg, toBranch, user, s.selfPublic)
	if err != nil {
		return err
	}
	return s.createAppForBranch(appRepo, appName, toBranch, user, network, map[string][]byte{"app.json": branchCfg})
}

func (s *Server) deleteBranch(appName string, branch string) error {
	appBranch := fmt.Sprintf("dodo_%s", branch)
	hf := installer.NewGitHelmFetcher()
	if err := func() error {
		repo, err := s.client.GetRepoBranch(appName, appBranch)
		if err != nil {
			return err
		}
		m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/.dodo")
		if err != nil {
			return err
		}
		return m.Remove("app")
	}(); err != nil {
		return err
	}
	configRepo, err := s.client.GetRepo(ConfigRepoName)
	if err != nil {
		return err
	}
	m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/")
	if err != nil {
		return err
	}
	appPath := fmt.Sprintf("%s/%s", appName, branch)
	if err := m.Remove(appPath); err != nil {
		return err
	}
	if err := s.client.DeleteRepoBranch(appName, appBranch); err != nil {
		return err
	}
	if branch != "master" {
		if err := s.client.DeleteRepoBranch(appName, branch); err != nil {
			return err
		}
	}
	return s.st.DeleteBranch(appName, branch)
}

func (s *Server) deleteApp(appName string) error {
	configRepo, err := s.client.GetRepo(ConfigRepoName)
	if err != nil {
		return err
	}
	branches, err := configRepo.ListDir(fmt.Sprintf("/%s", appName))
	if err != nil {
		return err
	}
	for _, b := range branches {
		if !b.IsDir() || strings.HasPrefix(b.Name(), "dodo_") {
			continue
		}
		if err := s.deleteBranch(appName, b.Name()); err != nil {
			return err
		}
	}
	if err := s.client.DeleteRepo(appName); err != nil {
		return err
	}
	return s.st.DeleteApp(appName)
}

func (s *Server) createAppForBranch(
	repo soft.RepoIO,
	appName string,
	branch string,
	user string,
	network string,
	files map[string][]byte,
) error {
	commit, err := repo.Do(func(fs soft.RepoFS) (string, error) {
		for path, contents := range files {
			if err := soft.WriteFile(fs, path, string(contents)); err != nil {
				return "", err
			}
		}
		return "init", nil
	}, soft.WithCommitToBranch(branch))
	if err != nil {
		return err
	}
	networks, err := s.getNetworks(user)
	if err != nil {
		return err
	}
	// TODO(gio): get only available ones by owner
	clusters, err := s.getClusters()
	if err != nil {
		return err
	}
	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
	instanceApp, err := installer.FindEnvApp(apps, "dodo-app-instance")
	if err != nil {
		return err
	}
	instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
	if err != nil {
		return err
	}
	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
	suffix, err := suffixGen.Generate()
	if err != nil {
		return err
	}
	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
	s.setAppConfig(appName, branch, appConfig{namespace, network})
	resources, err := s.updateDodoApp(instanceAppStatus, appName, branch, namespace, networks, clusters, user)
	if err != nil {
		fmt.Printf("Error: %s\n", err.Error())
		return err
	}
	if err = s.createCommit(appName, branch, commit, initCommitMsg, err, resources); err != nil {
		fmt.Printf("Error: %s\n", err.Error())
		return err
	}
	configRepo, err := s.client.GetRepo(ConfigRepoName)
	if err != nil {
		return err
	}
	hf := installer.NewGitHelmFetcher()
	m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/")
	if err != nil {
		return err
	}
	appPath := fmt.Sprintf("/%s/%s", appName, branch)
	_, err = configRepo.Do(func(fs soft.RepoFS) (string, error) {
		w, err := fs.Writer(appConfigsFile)
		if err != nil {
			return "", err
		}
		defer w.Close()
		if err := json.NewEncoder(w).Encode(s.appConfigs); err != nil {
			return "", err
		}
		if _, err := m.Install(
			instanceApp,
			appName,
			appPath,
			namespace,
			map[string]any{
				"repoAddr":         s.client.GetRepoAddress(appName),
				"repoHost":         strings.Split(s.client.Address(), ":")[0],
				"branch":           fmt.Sprintf("dodo_%s", branch),
				"gitRepoPublicKey": s.gitRepoPublicKey,
			},
			installer.WithConfig(&s.env),
			installer.WithNoNetworks(),
			installer.WithNoPublish(),
			installer.WithNoLock(),
		); err != nil {
			return "", err
		}
		return fmt.Sprintf("Installed app: %s", appName), nil
	})
	if err != nil {
		return err
	}
	return s.initAppACLs(m, appPath, appName, branch, user)
}

func (s *Server) initAppACLs(m *installer.AppManager, path, appName, branch, user string) error {
	cfg, err := m.GetInstance(path)
	if err != nil {
		return err
	}
	fluxKeys, ok := cfg.Input["fluxKeys"]
	if !ok {
		return fmt.Errorf("Fluxcd keys not found")
	}
	fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
	if !ok {
		return fmt.Errorf("Fluxcd keys not found")
	}
	if ok, err := s.client.UserExists("fluxcd"); err != nil {
		return err
	} else if ok {
		if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
			return err
		}
	} else {
		if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
			return err
		}
	}
	if branch != "master" {
		return nil
	}
	if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
		return err
	}
	if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
		return err
	}
	if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
		return err
	}
	if !s.external {
		go func() {
			users, err := s.client.GetAllUsers()
			if err != nil {
				fmt.Println(err)
				return
			}
			for _, user := range users {
				// TODO(gio): fluxcd should have only read access
				if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
					fmt.Println(err)
				}
			}
		}()
	}
	ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
	go s.reconciler.Reconcile(ctx, s.namespace, "config")
	return nil
}

type apiAddAdminKeyReq struct {
	User      string `json:"user"`
	PublicKey string `json:"publicKey"`
}

func (s *Server) handleAPIAddPublicKey(w http.ResponseWriter, r *http.Request) {
	var req apiAddAdminKeyReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if req.User == "" {
		http.Error(w, "invalid user", http.StatusBadRequest)
		return
	}
	if req.PublicKey == "" {
		http.Error(w, "invalid public key", http.StatusBadRequest)
		return
	}
	if err := s.client.AddPublicKey(req.User, req.PublicKey); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

type dodoAppRendered struct {
	App struct {
		Ingress struct {
			Network   string `json:"network"`
			Subdomain string `json:"subdomain"`
		} `json:"ingress"`
	} `json:"app"`
	Input struct {
		AppId string `json:"appId"`
	} `json:"input"`
}

// TODO(gio): must not require owner, now we need it to bootstrap dev vm.
func (s *Server) updateDodoApp(
	appStatus installer.EnvApp,
	name string,
	branch string,
	namespace string,
	networks []installer.Network,
	clusters []installer.Cluster,
	owner string,
) (installer.ReleaseResources, error) {
	repo, err := s.client.GetRepoBranch(name, branch)
	if err != nil {
		return installer.ReleaseResources{}, err
	}
	hf := installer.NewGitHelmFetcher()
	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/.dodo")
	if err != nil {
		return installer.ReleaseResources{}, err
	}
	appCfg, err := soft.ReadFile(repo, "app.json")
	if err != nil {
		return installer.ReleaseResources{}, err
	}
	app, err := installer.NewDodoApp(appCfg)
	if err != nil {
		return installer.ReleaseResources{}, err
	}
	lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
	var ret installer.ReleaseResources
	if _, err := repo.Do(func(r soft.RepoFS) (string, error) {
		ret, err = m.Install(
			app,
			"app",
			"/.dodo/app",
			namespace,
			map[string]any{
				"repoPublicAddr": s.repoPublicAddr,
				"managerAddr":    fmt.Sprintf("http://%s", s.self),
				"appId":          name,
				"branch":         branch,
				"sshPrivateKey":  s.sshKey,
				"username":       owner,
			},
			installer.WithNoPull(),
			installer.WithNoPublish(),
			installer.WithConfig(&s.env),
			installer.WithNetworks(networks),
			installer.WithClusters(clusters),
			installer.WithLocalChartGenerator(lg),
			installer.WithNoLock(),
		)
		if err != nil {
			return "", err
		}
		var rendered dodoAppRendered
		if err := json.NewDecoder(bytes.NewReader(ret.RenderedRaw)).Decode(&rendered); err != nil {
			return "", nil
		}
		if _, err := m.Install(
			appStatus,
			"status",
			"/.dodo/status",
			s.namespace,
			map[string]any{
				"appName":      rendered.Input.AppId,
				"network":      rendered.App.Ingress.Network,
				"appSubdomain": rendered.App.Ingress.Subdomain,
			},
			installer.WithNoPull(),
			installer.WithNoPublish(),
			installer.WithConfig(&s.env),
			installer.WithNetworks(networks),
			installer.WithClusters(clusters),
			installer.WithLocalChartGenerator(lg),
			installer.WithNoLock(),
		); err != nil {
			return "", err
		}
		return "install app", nil
	},
		soft.WithCommitToBranch(fmt.Sprintf("dodo_%s", branch)),
		soft.WithForce(),
	); err != nil {
		return installer.ReleaseResources{}, err
	}
	ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
	go s.reconciler.Reconcile(ctx, namespace, "app")
	return ret, nil
}

func (s *Server) renderAppConfigTemplate(user, appType string, network installer.Network, subdomain string) (map[string][]byte, error) {
	appType = strings.Replace(appType, ":", "-", 1)
	appTmpl, err := s.appTmpls.Find(appType)
	if err != nil {
		return nil, err
	}
	return appTmpl.Render(fmt.Sprintf("%s/schemas/%s/app.schema.json", s.selfPublic, user), network, subdomain)
}

func generatePassword() string {
	return "foo"
}

func (s *Server) getNetworks(user string) ([]installer.Network, error) {
	addr := fmt.Sprintf("%s/api/networks", s.envAppManagerAddr)
	resp, err := http.Get(addr)
	if err != nil {
		return nil, err
	}
	networks := []installer.Network{}
	if json.NewDecoder(resp.Body).Decode(&networks); err != nil {
		return nil, err
	}
	return s.nf.Filter(user, networks)
}

func (s *Server) getClusters() ([]installer.Cluster, error) {
	addr := fmt.Sprintf("%s/api/clusters", s.envAppManagerAddr)
	resp, err := http.Get(addr)
	if err != nil {
		return nil, err
	}
	clusters := []installer.Cluster{}
	if json.NewDecoder(resp.Body).Decode(&clusters); err != nil {
		return nil, err
	}
	fmt.Printf("CLUSTERS %+v\n", clusters)
	return clusters, nil
}

type publicNetworkData struct {
	Name   string `json:"name"`
	Domain string `json:"domain"`
}

type publicData struct {
	Networks []publicNetworkData `json:"networks"`
	Types    []string            `json:"types"`
}

func (s *Server) handleAPIPublicData(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Access-Control-Allow-Origin", "*")
	s.l.Lock()
	defer s.l.Unlock()
	networks, err := s.getNetworks("")
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var ret publicData
	for _, n := range networks {
		if s.isNetworkUseAllowed(strings.ToLower(n.Name)) {
			ret.Networks = append(ret.Networks, publicNetworkData{n.Name, n.Domain})
		}
	}
	for _, t := range s.appTmpls.Types() {
		ret.Types = append(ret.Types, strings.Replace(t, "-", ":", 1))
	}
	if err := json.NewEncoder(w).Encode(ret); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

func (s *Server) createCommit(name, branch, hash, message string, err error, resources installer.ReleaseResources) error {
	if err != nil {
		fmt.Printf("Error: %s\n", err.Error())
		if err := s.st.CreateCommit(name, branch, hash, message, "FAILED", err.Error(), nil); err != nil {
			fmt.Printf("Error: %s\n", err.Error())
			return err
		}
		return err
	}
	var resB bytes.Buffer
	if err := json.NewEncoder(&resB).Encode(resources); err != nil {
		if err := s.st.CreateCommit(name, branch, hash, message, "FAILED", err.Error(), nil); err != nil {
			fmt.Printf("Error: %s\n", err.Error())
			return err
		}
		return err
	}
	if err := s.st.CreateCommit(name, branch, hash, message, "OK", "", resB.Bytes()); err != nil {
		fmt.Printf("Error: %s\n", err.Error())
		return err
	}
	return nil
}

func pickNetwork(networks []installer.Network, network string) []installer.Network {
	for _, n := range networks {
		if n.Name == network {
			return []installer.Network{n}
		}
	}
	return []installer.Network{}
}

type NetworkFilter interface {
	Filter(user string, networks []installer.Network) ([]installer.Network, error)
}

type noNetworkFilter struct{}

func NewNoNetworkFilter() NetworkFilter {
	return noNetworkFilter{}
}

func (f noNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
	return networks, nil
}

type filterByOwner struct {
	st Store
}

func NewNetworkFilterByOwner(st Store) NetworkFilter {
	return &filterByOwner{st}
}

func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
	if user == "" {
		return networks, nil
	}
	network, err := f.st.GetUserNetwork(user)
	if err != nil {
		return nil, err
	}
	ret := []installer.Network{}
	for _, n := range networks {
		if n.Name == network {
			ret = append(ret, n)
		}
	}
	return ret, nil
}

type allowListFilter struct {
	allowed []string
}

func NewAllowListFilter(allowed []string) NetworkFilter {
	return &allowListFilter{allowed}
}

func (f *allowListFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
	ret := []installer.Network{}
	for _, n := range networks {
		if slices.Contains(f.allowed, n.Name) {
			ret = append(ret, n)
		}
	}
	return ret, nil
}

type combinedNetworkFilter struct {
	filters []NetworkFilter
}

func NewCombinedFilter(filters ...NetworkFilter) NetworkFilter {
	return &combinedNetworkFilter{filters}
}

func (f *combinedNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
	ret := networks
	var err error
	for _, f := range f.filters {
		ret, err = f.Filter(user, ret)
		if err != nil {
			return nil, err
		}
	}
	return ret, nil
}

type user struct {
	Username      string   `json:"username"`
	Email         string   `json:"email"`
	SSHPublicKeys []string `json:"sshPublicKeys,omitempty"`
}

func (s *Server) handleAPISyncUsers(_ http.ResponseWriter, _ *http.Request) {
	go s.syncUsers()
}

func (s *Server) syncUsers() {
	if s.external {
		panic("MUST NOT REACH!")
	}
	resp, err := http.Get(fmt.Sprintf("%s?selfAddress=%s/api/sync-users", s.fetchUsersAddr, s.self))
	if err != nil {
		return
	}
	users := []user{}
	if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
		fmt.Println(err)
		return
	}
	validUsernames := make(map[string]user)
	for _, u := range users {
		validUsernames[u.Username] = u
	}
	allClientUsers, err := s.client.GetAllUsers()
	if err != nil {
		fmt.Println(err)
		return
	}
	keyToUser := make(map[string]string)
	for _, clientUser := range allClientUsers {
		if clientUser == "admin" || clientUser == "fluxcd" {
			continue
		}
		userData, ok := validUsernames[clientUser]
		if !ok {
			if err := s.client.RemoveUser(clientUser); err != nil {
				fmt.Println(err)
				return
			}
		} else {
			existingKeys, err := s.client.GetUserPublicKeys(clientUser)
			if err != nil {
				fmt.Println(err)
				return
			}
			for _, existingKey := range existingKeys {
				cleanKey := soft.CleanKey(existingKey)
				keyOk := slices.ContainsFunc(userData.SSHPublicKeys, func(key string) bool {
					return cleanKey == soft.CleanKey(key)
				})
				if !keyOk {
					if err := s.client.RemovePublicKey(clientUser, existingKey); err != nil {
						fmt.Println(err)
					}
				} else {
					keyToUser[cleanKey] = clientUser
				}
			}
		}
	}
	for _, u := range users {
		if err := s.st.CreateUser(u.Username, nil, ""); err != nil && !errors.Is(err, ErrorAlreadyExists) {
			fmt.Println(err)
			return
		}
		if len(u.SSHPublicKeys) == 0 {
			continue
		}
		ok, err := s.client.UserExists(u.Username)
		if err != nil {
			fmt.Println(err)
			return
		}
		if !ok {
			if err := s.client.AddUser(u.Username, u.SSHPublicKeys[0]); err != nil {
				fmt.Println(err)
				return
			}
		} else {
			for _, key := range u.SSHPublicKeys {
				cleanKey := soft.CleanKey(key)
				if user, ok := keyToUser[cleanKey]; ok {
					if u.Username != user {
						panic("MUST NOT REACH! IMPOSSIBLE KEY USER RECORD")
					}
					continue
				}
				if err := s.client.AddPublicKey(u.Username, cleanKey); err != nil {
					fmt.Println(err)
					return
				}
			}
		}
	}
	repos, err := s.client.GetAllRepos()
	if err != nil {
		return
	}
	for _, r := range repos {
		if r == ConfigRepoName {
			continue
		}
		for _, u := range users {
			if err := s.client.AddReadWriteCollaborator(r, u.Username); err != nil {
				fmt.Println(err)
				continue
			}
		}
	}
}

type lastCmdEnv struct {
	App struct {
		Env []string `json:"lastCmdEnv"`
	} `json:"app"`
}

func extractResourceData(resources installer.ReleaseResources) (resourceData, error) {
	var ret resourceData
	var raw lastCmdEnv
	if err := json.NewDecoder(bytes.NewReader(resources.RenderedRaw)).Decode(&raw); err != nil {
		return resourceData{}, err
	}
	ret.Env = raw.App.Env
	for _, r := range resources.Helm {
		t, ok := r.Annotations["dodo.cloud/resource-type"]
		if !ok {
			continue
		}
		internal, ok := r.Annotations["dodo.cloud/internal"]
		if ok && strings.ToLower(internal) == "true" {
			continue
		}
		switch t {
		case "volume":
			name, ok := r.Annotations["dodo.cloud/resource.volume.name"]
			if !ok {
				return resourceData{}, fmt.Errorf("no name")
			}
			size, ok := r.Annotations["dodo.cloud/resource.volume.size"]
			if !ok {
				return resourceData{}, fmt.Errorf("no size")
			}
			ret.Volume = append(ret.Volume, volume{name, size})
		case "postgresql":
			name, ok := r.Annotations["dodo.cloud/resource.postgresql.name"]
			if !ok {
				return resourceData{}, fmt.Errorf("no name")
			}
			version, ok := r.Annotations["dodo.cloud/resource.postgresql.version"]
			if !ok {
				return resourceData{}, fmt.Errorf("no version")
			}
			volume, ok := r.Annotations["dodo.cloud/resource.postgresql.volume"]
			if !ok {
				return resourceData{}, fmt.Errorf("no volume")
			}
			ret.PostgreSQL = append(ret.PostgreSQL, postgresql{name, version, volume})
		case "mongodb":
			name, ok := r.Annotations["dodo.cloud/resource.mongodb.name"]
			if !ok {
				return resourceData{}, fmt.Errorf("no name")
			}
			version, ok := r.Annotations["dodo.cloud/resource.mongodb.version"]
			if !ok {
				return resourceData{}, fmt.Errorf("no version")
			}
			volume, ok := r.Annotations["dodo.cloud/resource.mongodb.volume"]
			if !ok {
				return resourceData{}, fmt.Errorf("no volume")
			}
			ret.MongoDB = append(ret.MongoDB, mongodb{name, version, volume})
		case "ingress":
			name, ok := r.Annotations["dodo.cloud/resource.ingress.name"]
			if !ok {
				return resourceData{}, fmt.Errorf("no name")
			}
			home, ok := r.Annotations["dodo.cloud/resource.ingress.home"]
			if !ok {
				home = ""
			}
			host, ok := r.Annotations["dodo.cloud/resource.ingress.host"]
			if !ok {
				return resourceData{}, fmt.Errorf("no host")
			}
			ret.Ingress = append(ret.Ingress, ingress{name, host, home})
		case "virtual-machine":
			name, ok := r.Annotations["dodo.cloud/resource.virtual-machine.name"]
			if !ok {
				return resourceData{}, fmt.Errorf("no name")
			}
			user, ok := r.Annotations["dodo.cloud/resource.virtual-machine.user"]
			if !ok {
				return resourceData{}, fmt.Errorf("no user")
			}
			cpuCoresS, ok := r.Annotations["dodo.cloud/resource.virtual-machine.cpu-cores"]
			if !ok {
				return resourceData{}, fmt.Errorf("no cpu cores")
			}
			cpuCores, err := strconv.Atoi(cpuCoresS)
			if err != nil {
				return resourceData{}, fmt.Errorf("invalid cpu cores: %s", cpuCoresS)
			}
			memory, ok := r.Annotations["dodo.cloud/resource.virtual-machine.memory"]
			if !ok {
				return resourceData{}, fmt.Errorf("no memory")
			}
			ret.VirtualMachine = append(ret.VirtualMachine, vm{name, user, cpuCores, memory})
		default:
			fmt.Printf("Unknown resource: %+v\n", r.Annotations)
		}
	}
	sort.Slice(ret.Ingress, func(i, j int) bool {
		return strings.Compare(ret.Ingress[i].Name, ret.Ingress[j].Name) < 0
	})
	return ret, nil
}

func createDevBranchAppConfig(from []byte, branch, username, publicAddr string) (string, []byte, error) {
	cfg, err := installer.ParseCueAppConfig(installer.CueAppData{
		"app.cue": from,
	})
	if err != nil {
		return "", nil, err
	}
	if err := cfg.Err(); err != nil {
		return "", nil, err
	}
	if err := cfg.Validate(); err != nil {
		return "", nil, err
	}
	subdomain := cfg.LookupPath(cue.ParsePath("app.ingress.subdomain"))
	if err := subdomain.Err(); err != nil {
		return "", nil, err
	}
	subdomainStr, err := subdomain.String()
	network := cfg.LookupPath(cue.ParsePath("app.ingress.network"))
	if err := network.Err(); err != nil {
		return "", nil, err
	}
	networkStr, err := network.String()
	if err != nil {
		return "", nil, err
	}
	newCfg := map[string]any{}
	if err := cfg.Decode(&newCfg); err != nil {
		return "", nil, err
	}
	app, ok := newCfg["app"].(map[string]any)
	if !ok {
		return "", nil, fmt.Errorf("not a map")
	}
	app["ingress"].(map[string]any)["subdomain"] = fmt.Sprintf("%s-%s", branch, subdomainStr)
	app["dev"] = map[string]any{
		"enabled":  true,
		"username": username,
	}
	newCfg["$schema"] = fmt.Sprintf("%s/schemas/%s/app.schema.json", publicAddr, username)
	buf, err := json.MarshalIndent(newCfg, "", "\t")
	if err != nil {
		return "", nil, err
	}
	return networkStr, buf, nil
}
