package api

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"regexp"
	"strings"
	"time"

	"github.com/alexei/tinyforge/internal/docker"
	"github.com/alexei/tinyforge/internal/staticsite"
)

// Discovery endpoints feed the /apps/new wizard's auto-discovery and
// connection-test flow. They wrap staticsite.GitProvider so the form
// can validate a repo + token before the workload is created, browse
// repos/branches/folders without leaving the page, and warn the operator
// when an image is already in use by another workload.
//
// The endpoints are workload-agnostic on purpose — they are scoped under
// /api/discovery rather than tied to the static_sites table the cutover
// dropped. Any future Git-driven source plugin can reuse them.

// Per-request budget for outbound calls. Short enough that a malicious
// or stuck upstream cannot pin a worker for long; long enough for slow
// self-hosted Gitea instances to respond.
const discoveryTimeout = 15 * time.Second

// gitProviderRequest is the shared request body for the four Git
// discovery endpoints. Token is plaintext over HTTPS — the wizard has
// not yet persisted it, so there is nothing to decrypt server-side.
// Empty Provider triggers DetectProviderWithProbe.
type gitProviderRequest struct {
	Provider    string `json:"provider"`
	BaseURL     string `json:"base_url"`
	AccessToken string `json:"access_token"`
	RepoOwner   string `json:"repo_owner"`
	RepoName    string `json:"repo_name"`
	Branch      string `json:"branch"`
	Query       string `json:"query"`
}

// gitIdentRe accepts Git owner / repo identifiers as the major hosts
// (GitHub, GitLab, Gitea/Forgejo) accept them: alphanumeric plus dot,
// underscore, hyphen. Rejecting other characters at the API boundary
// prevents `..` traversal and URL injection in the provider code that
// interpolates these segments into request paths.
var gitIdentRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)

// gitBranchRe is more permissive than gitIdentRe: branches may contain
// `/` (e.g. `feature/foo`) but still cannot contain `..` or control
// characters. The check below pairs this regex with an explicit `..`
// reject so a `feature/../admin` value cannot slip through.
var gitBranchRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)

// validateGitIdent guards owner / repo path segments at the boundary
// so the provider code can interpolate them with fmt.Sprintf without
// risking traversal. Empty input is reported with the supplied field
// name so the error message is actionable.
func validateGitIdent(field, value string) error {
	v := strings.TrimSpace(value)
	if v == "" {
		return &apiError{msg: field + " is required"}
	}
	if !gitIdentRe.MatchString(v) {
		return &apiError{msg: field + " contains invalid characters"}
	}
	return nil
}

// validateGitBranch is the branch-shaped variant of validateGitIdent.
// Branches legitimately contain `/`; the extra `..` reject covers the
// one traversal vector the regex still admits.
func validateGitBranch(value string) error {
	v := strings.TrimSpace(value)
	if v == "" {
		return &apiError{msg: "branch is required"}
	}
	if strings.Contains(v, "..") {
		return &apiError{msg: "branch contains invalid sequence '..'"}
	}
	if !gitBranchRe.MatchString(v) {
		return &apiError{msg: "branch contains invalid characters"}
	}
	return nil
}

// apiError is a small typed error so handlers can distinguish a
// validation failure (→ 400) from any other error (→ 500/502). The
// type lives in this file because nothing outside discovery uses it
// yet — promote to response.go if other handlers need the same shape.
type apiError struct{ msg string }

func (e *apiError) Error() string { return e.msg }

// providerType normalizes the provider string into the typed enum used
// by staticsite.NewGitProvider. Empty input falls through to provider
// auto-detection inside NewGitProvider.
func (req gitProviderRequest) providerType() staticsite.ProviderType {
	switch strings.ToLower(strings.TrimSpace(req.Provider)) {
	case "github":
		return staticsite.ProviderGitHub
	case "gitlab":
		return staticsite.ProviderGitLab
	case "gitea":
		return staticsite.ProviderGitea
	default:
		return ""
	}
}

// newProvider constructs the GitProvider for the request, or writes a
// 400 to w and returns nil if the inputs are invalid. BaseURL is fully
// validated here (scheme + host shape); connect-time IP filtering is
// enforced inside the safe-HTTP transport the provider receives.
func (req gitProviderRequest) newProvider(w http.ResponseWriter) staticsite.GitProvider {
	if err := staticsite.ValidateBaseURL(req.BaseURL); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return nil
	}
	provider, err := staticsite.NewGitProvider(req.providerType(), req.BaseURL, req.AccessToken)
	if err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return nil
	}
	return provider
}

// upstreamError logs the detailed upstream failure server-side and
// writes a generic 502 to the client. Echoing the raw error string
// would leak any access token reflected by a misconfigured or
// attacker-controlled upstream into the response body.
func upstreamError(w http.ResponseWriter, op string, err error) {
	slog.Warn("discovery upstream call failed", "op", op, "error", err)
	respondError(w, http.StatusBadGateway, "upstream git provider returned an error")
}

// detectGitProviderRequest is the body for POST /api/discovery/git/detect-provider.
type detectGitProviderRequest struct {
	BaseURL string `json:"base_url"`
}

// detectGitProvider probes the base URL for known Git provider API
// signatures so the wizard can auto-fill the provider dropdown.
// POST /api/discovery/git/detect-provider.
func (s *Server) detectGitProvider(w http.ResponseWriter, r *http.Request) {
	var req detectGitProviderRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	if err := staticsite.ValidateBaseURL(req.BaseURL); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
	defer cancel()

	provider := staticsite.DetectProviderWithProbe(ctx, req.BaseURL)
	respondJSON(w, http.StatusOK, map[string]string{"provider": string(provider)})
}

// testGitConnection verifies the configured base URL + token + repo
// reach the provider successfully so the wizard can fail fast.
// POST /api/discovery/git/test-connection.
func (s *Server) testGitConnection(w http.ResponseWriter, r *http.Request) {
	var req gitProviderRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	if err := validateGitIdent("repo_name", req.RepoName); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	provider := req.newProvider(w)
	if provider == nil {
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
	defer cancel()

	if err := provider.TestConnection(ctx, req.RepoOwner, req.RepoName); err != nil {
		upstreamError(w, "test_connection", err)
		return
	}
	respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// listGitRepos returns repositories accessible with the supplied token,
// optionally filtered by a name query.
// POST /api/discovery/git/repos.
func (s *Server) listGitRepos(w http.ResponseWriter, r *http.Request) {
	var req gitProviderRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	provider := req.newProvider(w)
	if provider == nil {
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
	defer cancel()

	repos, err := provider.ListRepos(ctx, req.Query)
	if err != nil {
		upstreamError(w, "list_repos", err)
		return
	}
	if repos == nil {
		repos = []staticsite.RepoInfo{}
	}
	respondJSON(w, http.StatusOK, repos)
}

// listGitBranches returns the branch list for a repo.
// POST /api/discovery/git/branches.
func (s *Server) listGitBranches(w http.ResponseWriter, r *http.Request) {
	var req gitProviderRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	if err := validateGitIdent("repo_name", req.RepoName); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	provider := req.newProvider(w)
	if provider == nil {
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
	defer cancel()

	branches, err := provider.ListBranches(ctx, req.RepoOwner, req.RepoName)
	if err != nil {
		upstreamError(w, "list_branches", err)
		return
	}
	if branches == nil {
		branches = []string{}
	}
	respondJSON(w, http.StatusOK, branches)
}

// listGitTree returns the full directory tree for a branch so the
// wizard can render the folder picker.
// POST /api/discovery/git/tree.
func (s *Server) listGitTree(w http.ResponseWriter, r *http.Request) {
	var req gitProviderRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	if err := validateGitIdent("repo_owner", req.RepoOwner); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	if err := validateGitIdent("repo_name", req.RepoName); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	if err := validateGitBranch(req.Branch); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	provider := req.newProvider(w)
	if provider == nil {
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
	defer cancel()

	tree, err := provider.ListTree(ctx, req.RepoOwner, req.RepoName, req.Branch)
	if err != nil {
		upstreamError(w, "list_tree", err)
		return
	}
	if tree == nil {
		tree = []staticsite.FolderEntry{}
	}
	respondJSON(w, http.StatusOK, tree)
}

// imageConflict is a slim projection of Workload, scoped to what the
// /apps/new conflict dialog needs to render.
type imageConflict struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	Image string `json:"image"`
	AppID string `json:"app_id,omitempty"`
}

// listImageConflicts finds existing image-source workloads whose
// configured image matches the supplied ref, with or without tag.
// GET /api/discovery/image/conflicts?image=<ref>.
//
// Matching mirrors the legacy quickDeploy behavior: collide on
// repository-without-tag so nginx:1.25 surfaces nginx, nginx:latest,
// and nginx:1.26 as conflicts. This is intentionally permissive — the
// wizard surfaces matches but lets the operator decide.
func (s *Server) listImageConflicts(w http.ResponseWriter, r *http.Request) {
	image := strings.TrimSpace(r.URL.Query().Get("image"))
	if image == "" {
		respondError(w, http.StatusBadRequest, "image query parameter is required")
		return
	}
	target := stripImageTag(image)
	if target == "" {
		respondError(w, http.StatusBadRequest, "image is empty after tag strip")
		return
	}

	workloads, err := s.store.ListWorkloads("")
	if err != nil {
		slog.Error("list workloads for conflict check", "error", err)
		respondError(w, http.StatusInternalServerError, "internal server error")
		return
	}

	conflicts := []imageConflict{}
	for _, wl := range workloads {
		if wl.SourceKind != "image" {
			continue
		}
		ref := imageRefFromSourceConfig(wl.SourceConfig)
		if ref == "" {
			continue
		}
		if stripImageTag(ref) != target {
			continue
		}
		conflicts = append(conflicts, imageConflict{
			ID:    wl.ID,
			Name:  wl.Name,
			Image: ref,
			AppID: wl.AppID,
		})
	}
	respondJSON(w, http.StatusOK, conflicts)
}

// inspectImageRequest is the body for POST /api/discovery/image/inspect.
type inspectImageRequest struct {
	Image string `json:"image"`
}

// inspectImageResponse mirrors the frontend InspectResult shape the
// new-app wizard pre-fills from: the first exposed port (parsed to int,
// 0 when none) and the image's HEALTHCHECK command string.
type inspectImageResponse struct {
	Port        int    `json:"port"`
	Healthcheck string `json:"healthcheck"`
}

// inspectImageMetadata inspects a LOCAL image and returns its first
// exposed port + healthcheck so the wizard can pre-fill those fields.
// POST /api/discovery/image/inspect.
//
// This inspects local images only — it does not pull. When the image is
// not present locally the docker call fails; we return a generic,
// non-leaky 400 rather than the git-specific upstreamError so a raw
// docker daemon string (which may echo the ref) never reaches the client.
func (s *Server) inspectImageMetadata(w http.ResponseWriter, r *http.Request) {
	var req inspectImageRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	image := strings.TrimSpace(req.Image)
	if image == "" {
		respondError(w, http.StatusBadRequest, "image is required")
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), discoveryTimeout)
	defer cancel()

	info, err := s.docker.InspectImage(ctx, image)
	if err != nil {
		slog.Warn("inspect image metadata failed", "error", err)
		respondError(w, http.StatusBadRequest, "could not inspect image — make sure it is pulled locally and the reference is correct")
		return
	}

	respondJSON(w, http.StatusOK, inspectImageResponse{
		Port:        docker.ExtractPort(info.ExposedPorts),
		Healthcheck: info.Healthcheck,
	})
}

// stripImageTag returns the image reference with the trailing :tag
// removed, taking care to leave a registry port (e.g. registry:5000/foo)
// intact. Digest references (image@sha256:...) are returned unchanged.
func stripImageTag(ref string) string {
	ref = strings.TrimSpace(ref)
	if ref == "" {
		return ""
	}
	// Digest reference: keep as-is so two pinned-by-digest workloads do
	// not collide with each other or with tag-based refs unless the
	// caller asks for exact-match (we currently don't).
	if at := strings.Index(ref, "@"); at >= 0 {
		return ref[:at]
	}
	// Strip a :tag suffix only when the colon is in the final path
	// segment — earlier colons belong to a registry port.
	lastSlash := strings.LastIndex(ref, "/")
	tail := ref
	if lastSlash >= 0 {
		tail = ref[lastSlash+1:]
	}
	if colon := strings.LastIndex(tail, ":"); colon >= 0 {
		// Only strip if the tag part looks like a tag (no slashes,
		// non-empty). Otherwise leave alone. When lastSlash is -1 the
		// arithmetic still yields the right cut point (-1 + 1 + colon
		// == colon), so no special case is needed.
		tag := tail[colon+1:]
		if tag != "" && !strings.ContainsAny(tag, "/") {
			return ref[:lastSlash+1+colon]
		}
	}
	return ref
}

// imageRefFromSourceConfig extracts the "image" field from a workload's
// source_config JSON. Returns "" when the blob is missing, malformed,
// or has no image field — those workloads simply do not contribute to
// conflict detection.
func imageRefFromSourceConfig(raw string) string {
	if raw == "" {
		return ""
	}
	var cfg struct {
		Image string `json:"image"`
	}
	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
		return ""
	}
	return strings.TrimSpace(cfg.Image)
}

