//go:build e2e

package e2e

import (
	"archive/tar"
	"archive/zip"
	"bytes"
	"compress/gzip"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/pingcap/failpoint"

	"github.com/tidbcloud/AgentTeam/desktop/internal/api/dashboard"
)

var (
	buildAgentTeamOnce sync.Once
	buildAgentTeamPath string
	buildAgentTeamErr  error
)

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// buildAgentTeam returns the path to the agentteam binary (builds if needed).
func buildAgentTeam(t *testing.T) string {
	t.Helper()
	buildAgentTeamOnce.Do(func() {
		tmpDir, err := os.MkdirTemp("", "agentteam-e2e-bin-*")
		if err != nil {
			buildAgentTeamErr = err
			return
		}
		buildAgentTeamPath = filepath.Join(tmpDir, "agentteam")
		cmd := exec.Command("go", "build", "-o", buildAgentTeamPath, "./cmd/agentteam")
		cmd.Dir = filepath.Join(repoRoot(t), ".")
		out, err := cmd.CombinedOutput()
		if err != nil {
			buildAgentTeamErr = fmt.Errorf("build agentteam: %w\n%s", err, out)
		}
	})
	if buildAgentTeamErr != nil {
		t.Fatal(buildAgentTeamErr)
	}
	return buildAgentTeamPath
}

func repoRoot(t *testing.T) string {
	t.Helper()
	// Walk up from the test file's directory to find go.mod
	dir, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}
	for {
		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
			return dir
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			t.Fatal("cannot find go.mod")
		}
		dir = parent
	}
}

func runAgentTeam(t *testing.T, bin string, args ...string) string {
	t.Helper()
	cmd := exec.Command(bin, args...)
	cmd.Env = append(os.Environ(),
		"AGENTTEAM_CONFIG_DIR="+t.TempDir(),
		"HOME="+t.TempDir(),
	)
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("agentteam %v: %v\n%s", args, err, out)
	}
	return string(out)
}

func runAgentTeamExpectErr(t *testing.T, bin string, wantErr bool, args ...string) string {
	t.Helper()
	cmd := exec.Command(bin, args...)
	cfgDir := t.TempDir()
	cmd.Env = append(os.Environ(),
		"AGENTTEAM_CONFIG_DIR="+cfgDir,
		"HOME="+t.TempDir(),
	)
	out, err := cmd.CombinedOutput()
	if wantErr && err == nil {
		t.Fatalf("agentteam %v: expected error, got success:\n%s", args, out)
	}
	if !wantErr && err != nil {
		t.Fatalf("agentteam %v: unexpected error: %v\n%s", args, err, out)
	}
	return string(out)
}

// failpointEnv returns environment variables with GO_FAILPOINTS set to inject
// the given config directory via the agentteam-config-dir failpoint.
func failpointEnv(cfgDir string) []string {
	return append(os.Environ(),
		"GO_FAILPOINTS="+fmt.Sprintf(`agentteam-config-dir=return("%s")`, cfgDir),
		"HOME="+os.TempDir(),
	)
}

// ---------------------------------------------------------------------------
// CLI e2e tests
// ---------------------------------------------------------------------------

func TestCLI_SkillImportListGet(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	// Create a test markdown file
	md := filepath.Join(cfgDir, "test.md")
	os.WriteFile(md, []byte("---\nid: cli-skill\nname: CLI Skill\nversion: \"1.0\"\n---\n# CLI Skill\n"), 0644)

	cmd := exec.Command(bin, "skill", "import", md)
	cmd.Env = append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("skill import: %v\n%s", err, out)
	}
	if !strings.Contains(string(out), "cli-skill") {
		t.Fatalf("expected output to contain 'cli-skill', got %s", out)
	}

	// List
	cmd = exec.Command(bin, "skill", "list")
	cmd.Env = append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())
	out, _ = cmd.CombinedOutput()
	if !strings.Contains(string(out), "cli-skill") {
		t.Fatalf("list should contain 'cli-skill', got %s", out)
	}

	// Get
	cmd = exec.Command(bin, "skill", "get", "cli-skill")
	cmd.Env = append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())
	out, _ = cmd.CombinedOutput()
	if !strings.Contains(string(out), "CLI Skill") {
		t.Fatalf("get should contain 'CLI Skill', got %s", out)
	}
}

func TestCLI_SkillImportMetaFlags(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	md := filepath.Join(cfgDir, "no-meta.md")
	os.WriteFile(md, []byte("# No front-matter\n"), 0644)

	cmd := exec.Command(bin, "skill", "import", md, "--id", "flag-skill", "--name", "Flag Skill")
	cmd.Env = append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("skill import with flags: %v\n%s", err, out)
	}
	if !strings.Contains(string(out), "flag-skill") {
		t.Fatalf("expected 'flag-skill' in output, got %s", out)
	}
}

func TestCLI_SkillImportDuplicateError(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	md := filepath.Join(cfgDir, "dup.md")
	os.WriteFile(md, []byte("---\nid: dup-cli\nname: Dup\n---\n# Dup\n"), 0644)

	env := append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())

	cmd := exec.Command(bin, "skill", "import", md)
	cmd.Env = env
	if _, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("first import: %v", err)
	}

	cmd = exec.Command(bin, "skill", "import", md)
	cmd.Env = env
	out, _ := cmd.CombinedOutput()
	if !strings.Contains(string(out), "already exists") {
		t.Fatalf("expected 'already exists' error, got %s", out)
	}
}

func TestCLI_SkillImportForce(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	md := filepath.Join(cfgDir, "force.md")
	os.WriteFile(md, []byte("---\nid: force-cli\nname: Original\n---\n# Original\n"), 0644)

	env := append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())

	cmd := exec.Command(bin, "skill", "import", md)
	cmd.Env = env
	if _, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("first import: %v", err)
	}

	os.WriteFile(md, []byte("---\nid: force-cli\nname: Replaced\n---\n# Replaced\n"), 0644)

	cmd = exec.Command(bin, "skill", "import", md, "-f")
	cmd.Env = env
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("force import: %v\n%s", err, out)
	}
	if !strings.Contains(string(out), "Replaced") {
		t.Fatalf("expected 'Replaced' in output, got %s", out)
	}
}

func TestCLI_SkillListOmitsEnabled(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	md := filepath.Join(cfgDir, "list.md")
	os.WriteFile(md, []byte("---\nid: list-cli\nname: List\n---\n# List\n"), 0644)

	env := append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())

	cmd := exec.Command(bin, "skill", "import", md)
	cmd.Env = env
	if _, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("import: %v", err)
	}

	cmd = exec.Command(bin, "skill", "list", "-f", "json")
	cmd.Env = env
	out, _ := cmd.CombinedOutput()
	if strings.Contains(string(out), `"enabled"`) {
		t.Fatalf("expected enabled field to be omitted, got %s", out)
	}
}

func TestCLI_SkillDelete(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	md := filepath.Join(cfgDir, "del.md")
	os.WriteFile(md, []byte("---\nid: del-cli\nname: Del CLI\n---\n# Del\n"), 0644)

	env := append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())

	cmd := exec.Command(bin, "skill", "import", md)
	cmd.Env = env
	if _, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("import: %v", err)
	}

	cmd = exec.Command(bin, "skill", "delete", "del-cli")
	cmd.Env = env
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("delete: %v\n%s", err, out)
	}
	if !strings.Contains(string(out), "del-cli") {
		t.Fatalf("expected 'del-cli' in output, got %s", out)
	}

	cmd = exec.Command(bin, "skill", "get", "del-cli")
	cmd.Env = env
	_, err = cmd.CombinedOutput()
	if err == nil {
		t.Fatal("expected error after deleting skill")
	}
}

func TestCLI_SkillImportZip(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	zipPath := filepath.Join(cfgDir, "cli-skill.zip")
	createE2ETestZip(t, zipPath, map[string]string{
		"SKILL.md":            "---\nid: zip-cli\nname: Zip CLI\n---\n# Zip CLI\n",
		"examples/example.md": "Example content",
	})

	cmd := exec.Command(bin, "skill", "import", zipPath)
	cmd.Env = append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("skill import zip: %v\n%s", err, out)
	}
	if !strings.Contains(string(out), "zip-cli") {
		t.Fatalf("expected 'zip-cli' in output, got %s", out)
	}

	// Verify extra files preserved their relative path.
	exPath := filepath.Join(cfgDir, "skills", "zip-cli", "examples", "example.md")
	if _, err := os.Stat(exPath); err != nil {
		t.Fatalf("extra file not extracted: %v", err)
	}
}

func TestCLI_SkillImportURL_FakeServer(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	// Start a fake HTTP server
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/markdown")
		fmt.Fprint(w, "---\nid: url-skill\nname: URL Skill\n---\n# URL Skill\n")
	}))
	defer srv.Close()

	cmd := exec.Command(bin, "skill", "import", srv.URL+"/skill.md")
	cmd.Env = append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("skill import url: %v\n%s", err, out)
	}
	if !strings.Contains(string(out), "url-skill") {
		t.Fatalf("expected 'url-skill' in output, got %s", out)
	}
}

func TestCLI_SkillImportURL_ArchiveBatch(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	zipBuf := &bytes.Buffer{}
	zw := zip.NewWriter(zipBuf)
	files := map[string]string{
		"skills/alpha/SKILL.md": "---\nid: cli-alpha\nname: CLI Alpha\n---\n# CLI Alpha\n",
		"skills/beta/SKILL.md":  "---\nid: cli-beta\nname: CLI Beta\n---\n# CLI Beta\n",
	}
	for name, content := range files {
		fw, err := zw.Create(name)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := fw.Write([]byte(content)); err != nil {
			t.Fatal(err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatal(err)
	}

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/zip")
		_, _ = w.Write(zipBuf.Bytes())
	}))
	defer srv.Close()

	cmd := exec.Command(bin, "skill", "import", srv.URL+"/skills.zip")
	cmd.Env = append(os.Environ(), "AGENTTEAM_CONFIG_DIR="+cfgDir, "HOME="+t.TempDir())
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("skill import archive url: %v\n%s", err, out)
	}
	if !strings.Contains(string(out), "cli-alpha") || !strings.Contains(string(out), "cli-beta") {
		t.Fatalf("expected batch output to contain imported skills, got %s", out)
	}
}

// TestCLI_SkillFailpointInjection tests that the agentteam-config-dir failpoint
// works correctly when the binary is run as a subprocess with GO_FAILPOINTS.
func TestCLI_SkillFailpointInjection(t *testing.T) {
	bin := buildAgentTeam(t)
	cfgDir := t.TempDir()

	md := filepath.Join(cfgDir, "fp-skill.md")
	os.WriteFile(md, []byte("---\nid: fp-skill\nname: Failpoint Skill\n---\n# FP\n"), 0644)

	env := failpointEnv(cfgDir)

	// Import
	cmd := exec.Command(bin, "skill", "import", md)
	cmd.Env = env
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("import: %v\n%s", err, out)
	}
	if !strings.Contains(string(out), "fp-skill") {
		t.Fatalf("expected 'fp-skill', got %s", out)
	}

	// List
	cmd = exec.Command(bin, "skill", "list")
	cmd.Env = env
	out, _ = cmd.CombinedOutput()
	if !strings.Contains(string(out), "fp-skill") {
		t.Fatalf("list should contain 'fp-skill', got %s", out)
	}

	// Get
	cmd = exec.Command(bin, "skill", "get", "fp-skill")
	cmd.Env = env
	out, _ = cmd.CombinedOutput()
	if !strings.Contains(string(out), "Failpoint Skill") {
		t.Fatalf("get should contain 'Failpoint Skill', got %s", out)
	}

	// Delete
	cmd = exec.Command(bin, "skill", "delete", "fp-skill")
	cmd.Env = env
	out, err = cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("delete: %v\n%s", err, out)
	}

	// Verify deleted
	cmd = exec.Command(bin, "skill", "get", "fp-skill")
	cmd.Env = env
	_, err = cmd.CombinedOutput()
	if err == nil {
		t.Fatal("expected error after deleting skill")
	}

	// Verify files stored under skills/{id}/SKILL.md
	skillDir := filepath.Join(cfgDir, "skills", "fp-skill")
	if _, err := os.Stat(skillDir); err != nil && !os.IsNotExist(err) {
		// dir may have been cleaned up by delete, just check it existed
	}
}

// ---------------------------------------------------------------------------
// API e2e tests
// ---------------------------------------------------------------------------

func newSkillAPIRouter(t *testing.T) *gin.Engine {
	t.Helper()
	gin.SetMode(gin.TestMode)
	r := gin.New()
	r.Use(gin.Recovery())

	api := r.Group("/api/internal")
	api.GET("/skills", dashboard.NewListSkillsHandler())
	api.POST("/skills/import", dashboard.NewImportSkillHandler())
	api.POST("/skills/import-batch", dashboard.NewImportBatchSkillHandler())
	api.GET("/skills/:id", dashboard.NewGetSkillHandler())
	api.PUT("/skills/:id", dashboard.NewUpdateSkillHandler())
	api.DELETE("/skills/:id", dashboard.NewDeleteSkillHandler())

	return r
}

func setupAPITest(t *testing.T) (*gin.Engine, string) {
	t.Helper()
	cfgDir := t.TempDir()

	failpoint.Enable("agentteam-config-dir", fmt.Sprintf(`return("%s")`, cfgDir))
	t.Cleanup(func() { failpoint.Disable("agentteam-config-dir") })

	router := newSkillAPIRouter(t)
	return router, cfgDir
}

func TestAPI_SkillListEmpty(t *testing.T) {
	router, _ := setupAPITest(t)

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/api/internal/skills", nil)
	router.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}

	var skills []map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skills)
	if len(skills) != 0 {
		t.Fatalf("expected 0 skills, got %d", len(skills))
	}
}

func TestAPI_SkillImportAndGet(t *testing.T) {
	router, _ := setupAPITest(t)

	md := filepath.Join(t.TempDir(), "test.md")
	os.WriteFile(md, []byte("---\nid: api-skill\nname: API Skill\nversion: \"1.0\"\n---\n# API\n"), 0644)

	// Import
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "test.md")
	part.Write([]byte("---\nid: api-skill\nname: API Skill\nversion: \"1.0\"\n---\n# API\n"))
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("import: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["id"] != "api-skill" {
		t.Fatalf("expected id 'api-skill', got %v", skill["id"])
	}

	// Get
	w = httptest.NewRecorder()
	req, _ = http.NewRequest("GET", "/api/internal/skills/api-skill", nil)
	router.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("get: expected 200, got %d: %s", w.Code, w.Body.String())
	}

	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["name"] != "API Skill" {
		t.Fatalf("expected name 'API Skill', got %v", skill["name"])
	}
}

func TestAPI_SkillImportInfersMetadata(t *testing.T) {
	router, _ := setupAPITest(t)

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "no-meta.md")
	part.Write([]byte("# No front-matter\n\nImported without manual fields."))
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &resp)
	if resp["id"] != "no-front-matter" {
		t.Fatalf("expected inferred id 'no-front-matter', got %v", resp["id"])
	}
	if resp["name"] != "No front-matter" {
		t.Fatalf("expected inferred name 'No front-matter', got %v", resp["name"])
	}
}

func TestAPI_SkillImportNeedsMeta_WithFormFields(t *testing.T) {
	router, _ := setupAPITest(t)

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "no-meta.md")
	part.Write([]byte("# No front-matter"))
	writer.WriteField("id", "form-skill")
	writer.WriteField("name", "Form Skill")
	writer.WriteField("description", "From form")
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["id"] != "form-skill" {
		t.Fatalf("expected id 'form-skill', got %v", skill["id"])
	}
	if skill["name"] != "Form Skill" {
		t.Fatalf("expected name 'Form Skill', got %v", skill["name"])
	}
}

func TestAPI_SkillImportConflict(t *testing.T) {
	router, _ := setupAPITest(t)

	// First import
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "dup.md")
	part.Write([]byte("---\nid: dup-api\nname: Dup\n---\n# Dup\n"))
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)
	if w.Code != http.StatusCreated {
		t.Fatalf("first import: expected 201, got %d", w.Code)
	}

	// Second import without force
	body = &bytes.Buffer{}
	writer = multipart.NewWriter(body)
	part, _ = writer.CreateFormFile("file", "dup.md")
	part.Write([]byte("---\nid: dup-api\nname: Dup\n---\n# Dup\n"))
	writer.Close()

	w = httptest.NewRecorder()
	req, _ = http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusConflict {
		t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &resp)
	if resp["exists"] != true {
		t.Fatalf("expected exists=true, got %v", resp["exists"])
	}
}

func TestAPI_SkillImportConflict_WithForce(t *testing.T) {
	router, _ := setupAPITest(t)

	importSkill := func(content string, force bool) int {
		body := &bytes.Buffer{}
		writer := multipart.NewWriter(body)
		part, _ := writer.CreateFormFile("file", "f.md")
		part.Write([]byte(content))
		if force {
			writer.WriteField("force", "true")
		}
		writer.Close()

		w := httptest.NewRecorder()
		req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
		req.Header.Set("Content-Type", writer.FormDataContentType())
		router.ServeHTTP(w, req)
		return w.Code
	}

	code := importSkill("---\nid: force-api\nname: Original\n---\n# Orig\n", false)
	if code != http.StatusCreated {
		t.Fatalf("first import: expected 201, got %d", code)
	}

	code = importSkill("---\nid: force-api\nname: Replaced\n---\n# Replaced\n", true)
	if code != http.StatusCreated {
		t.Fatalf("force import: expected 201, got %d", code)
	}

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/api/internal/skills/force-api", nil)
	router.ServeHTTP(w, req)

	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["name"] != "Replaced" {
		t.Fatalf("expected name 'Replaced' after force, got %v", skill["name"])
	}
}

func TestAPI_SkillImportURL(t *testing.T) {
	router, _ := setupAPITest(t)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/markdown")
		fmt.Fprint(w, "---\nid: url-api\nname: URL API\n---\n# URL API\n")
	}))
	defer srv.Close()

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	writer.WriteField("url", srv.URL+"/skill.md")
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("url import: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["id"] != "url-api" {
		t.Fatalf("expected id 'url-api', got %v", skill["id"])
	}
}

func TestAPI_SkillUpdate(t *testing.T) {
	router, _ := setupAPITest(t)

	importSkill := func() {
		body := &bytes.Buffer{}
		writer := multipart.NewWriter(body)
		part, _ := writer.CreateFormFile("file", "upd.md")
		part.Write([]byte("---\nid: upd-api\nname: Original\n---\n# Original\n"))
		writer.Close()

		w := httptest.NewRecorder()
		req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
		req.Header.Set("Content-Type", writer.FormDataContentType())
		router.ServeHTTP(w, req)
		if w.Code != http.StatusCreated {
			t.Fatalf("import: expected 201, got %d", w.Code)
		}
	}

	importSkill()

	updateBody := `{"name":"Updated","description":"New desc"}`
	w := httptest.NewRecorder()
	req, _ := http.NewRequest("PUT", "/api/internal/skills/upd-api", bytes.NewBufferString(updateBody))
	req.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("update: expected 200, got %d: %s", w.Code, w.Body.String())
	}

	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["name"] != "Updated" {
		t.Fatalf("expected name 'Updated', got %v", skill["name"])
	}
	if _, ok := skill["enabled"]; ok {
		t.Fatalf("expected enabled field to be omitted, got %v", skill["enabled"])
	}
}

func TestAPI_SkillDelete(t *testing.T) {
	router, _ := setupAPITest(t)

	// Import first
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "del-api.md")
	part.Write([]byte("---\nid: del-api\nname: Del API\n---\n# Del\n"))
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)
	if w.Code != http.StatusCreated {
		t.Fatalf("import: expected 201, got %d", w.Code)
	}

	// Delete
	w = httptest.NewRecorder()
	req, _ = http.NewRequest("DELETE", "/api/internal/skills/del-api", nil)
	router.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("delete: expected 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify gone
	w = httptest.NewRecorder()
	req, _ = http.NewRequest("GET", "/api/internal/skills/del-api", nil)
	router.ServeHTTP(w, req)
	if w.Code != http.StatusNotFound {
		t.Fatalf("get after delete: expected 404, got %d", w.Code)
	}
}

func TestAPI_SkillImportZip(t *testing.T) {
	router, cfgDir := setupAPITest(t)

	zipPath := filepath.Join(t.TempDir(), "api-skill.zip")
	createE2ETestZip(t, zipPath, map[string]string{
		"SKILL.md":            "---\nid: zip-api\nname: Zip API\n---\n# Zip API\n",
		"examples/example.md": "Example content",
	})

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "api-skill.zip")
	f, _ := os.Open(zipPath)
	defer f.Close()
	io.Copy(part, f)
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("zip import: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["id"] != "zip-api" {
		t.Fatalf("expected id 'zip-api', got %v", skill["id"])
	}
	if _, err := os.Stat(filepath.Join(cfgDir, "skills", "zip-api", "examples", "example.md")); err != nil {
		t.Fatalf("expected preserved example path, got %v", err)
	}
}

func TestAPI_SkillImportTarGz(t *testing.T) {
	router, cfgDir := setupAPITest(t)

	tgzPath := filepath.Join(t.TempDir(), "api-skill.tar.gz")
	createE2ETestTarGz(t, tgzPath, map[string]string{
		"SKILL.md":        "---\nid: tar-api\nname: Tar API\n---\n# Tar API\n",
		"examples/eg.txt": "Example",
	})

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "api-skill.tar.gz")
	f, _ := os.Open(tgzPath)
	defer f.Close()
	io.Copy(part, f)
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("tar.gz import: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["id"] != "tar-api" {
		t.Fatalf("expected id 'tar-api', got %v", skill["id"])
	}
	if _, err := os.Stat(filepath.Join(cfgDir, "skills", "tar-api", "examples", "eg.txt")); err != nil {
		t.Fatalf("expected preserved example path, got %v", err)
	}
}

func TestAPI_SkillImport_ZipAutoBatch(t *testing.T) {
	router, _ := setupAPITest(t)

	zipPath := filepath.Join(t.TempDir(), "auto-batch.zip")
	createE2ETestZip(t, zipPath, map[string]string{
		"skills/alpha/SKILL.md": "---\nid: auto-alpha\nname: Auto Alpha\n---\n# Alpha\n",
		"skills/beta/SKILL.md":  "---\nid: auto-beta\nname: Auto Beta\n---\n# Beta\n",
	})

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "auto-batch.zip")
	f, _ := os.Open(zipPath)
	defer f.Close()
	_, _ = io.Copy(part, f)
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("auto batch import: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &resp)
	imported, ok := resp["imported"].([]interface{})
	if !ok || len(imported) != 2 {
		t.Fatalf("expected 2 imported skills, got %v", resp["imported"])
	}
}

func TestAPI_SkillImport_URLArchiveAutoBatch(t *testing.T) {
	router, _ := setupAPITest(t)

	zipBuf := &bytes.Buffer{}
	zw := zip.NewWriter(zipBuf)
	for name, content := range map[string]string{
		"skills/url-alpha/SKILL.md": "---\nid: url-alpha\nname: URL Alpha\n---\n# URL Alpha\n",
		"skills/url-beta/SKILL.md":  "---\nid: url-beta\nname: URL Beta\n---\n# URL Beta\n",
	} {
		fw, err := zw.Create(name)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := fw.Write([]byte(content)); err != nil {
			t.Fatal(err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatal(err)
	}

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/zip")
		_, _ = w.Write(zipBuf.Bytes())
	}))
	defer srv.Close()

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	_ = writer.WriteField("url", srv.URL+"/skills.zip")
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("auto batch url import: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &resp)
	imported, ok := resp["imported"].([]interface{})
	if !ok || len(imported) != 2 {
		t.Fatalf("expected 2 imported skills, got %v", resp["imported"])
	}
}

// ---------------------------------------------------------------------------
// Batch import API e2e tests
// ---------------------------------------------------------------------------

func TestAPI_SkillBatchImport_Zip(t *testing.T) {
	router, cfgDir := setupAPITest(t)

	zipPath := filepath.Join(t.TempDir(), "batch.zip")
	createE2ETestZip(t, zipPath, map[string]string{
		"skills/alpha/SKILL.md":       "---\nid: batch-alpha\nname: Batch Alpha\n---\n# Alpha\n",
		"skills/alpha/scripts/run.sh": "#!/bin/bash\necho alpha",
		"skills/beta/SKILL.md":        "---\nid: batch-beta\nname: Batch Beta\n---\n# Beta\n",
		"README.md":                   "Not a skill",
	})

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "batch.zip")
	f, _ := os.Open(zipPath)
	defer f.Close()
	io.Copy(part, f)
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import-batch", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("batch import: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &resp)

	imported, ok := resp["imported"].([]interface{})
	if !ok || len(imported) != 2 {
		t.Fatalf("expected 2 imported, got %v", resp["imported"])
	}
	if int(resp["created"].(float64)) != 2 {
		t.Fatalf("expected created=2, got %v", resp["created"])
	}

	// Verify skills exist via GET.
	for _, id := range []string{"batch-alpha", "batch-beta"} {
		w := httptest.NewRecorder()
		req, _ := http.NewRequest("GET", "/api/internal/skills/"+id, nil)
		router.ServeHTTP(w, req)
		if w.Code != http.StatusOK {
			t.Fatalf("get %s: expected 200, got %d", id, w.Code)
		}
	}

	// Verify extra files on disk.
	scriptPath := filepath.Join(cfgDir, "skills", "batch-alpha", "scripts", "run.sh")
	if _, err := os.Stat(scriptPath); err != nil {
		t.Fatalf("extra file scripts/run.sh not found: %v", err)
	}
}

func TestAPI_SkillBatchImport_Conflict(t *testing.T) {
	router, _ := setupAPITest(t)

	// Pre-import a skill.
	{
		body := &bytes.Buffer{}
		writer := multipart.NewWriter(body)
		part, _ := writer.CreateFormFile("file", "pre.md")
		part.Write([]byte("---\nid: batch-conflict\nname: Existing\n---\n# Existing\n"))
		writer.Close()
		w := httptest.NewRecorder()
		req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
		req.Header.Set("Content-Type", writer.FormDataContentType())
		router.ServeHTTP(w, req)
		if w.Code != http.StatusCreated {
			t.Fatalf("pre-import: expected 201, got %d", w.Code)
		}
	}

	zipPath := filepath.Join(t.TempDir(), "conflict.zip")
	createE2ETestZip(t, zipPath, map[string]string{
		"skills/batch-conflict/SKILL.md": "---\nid: batch-conflict\nname: New\n---\n# New\n",
		"skills/batch-new/SKILL.md":      "---\nid: batch-new\nname: New Skill\n---\n# New Skill\n",
	})

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "conflict.zip")
	f, _ := os.Open(zipPath)
	defer f.Close()
	io.Copy(part, f)
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import-batch", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("batch import conflict: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &resp)

	if int(resp["created"].(float64)) != 1 {
		t.Fatalf("expected created=1, got %v", resp["created"])
	}
	if int(resp["skipped"].(float64)) != 1 {
		t.Fatalf("expected skipped=1, got %v", resp["skipped"])
	}

	// Verify the existing skill was not overwritten.
	w = httptest.NewRecorder()
	req, _ = http.NewRequest("GET", "/api/internal/skills/batch-conflict", nil)
	router.ServeHTTP(w, req)
	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["name"] != "Existing" {
		t.Fatalf("conflicting skill should not be overwritten, got name %v", skill["name"])
	}
}

func TestAPI_SkillBatchImport_ForceOverwrite(t *testing.T) {
	router, _ := setupAPITest(t)

	// Pre-import a skill.
	{
		body := &bytes.Buffer{}
		writer := multipart.NewWriter(body)
		part, _ := writer.CreateFormFile("file", "pre.md")
		part.Write([]byte("---\nid: batch-force\nname: Old\n---\n# Old\n"))
		writer.Close()
		w := httptest.NewRecorder()
		req, _ := http.NewRequest("POST", "/api/internal/skills/import", body)
		req.Header.Set("Content-Type", writer.FormDataContentType())
		router.ServeHTTP(w, req)
		if w.Code != http.StatusCreated {
			t.Fatalf("pre-import: expected 201, got %d", w.Code)
		}
	}

	zipPath := filepath.Join(t.TempDir(), "force.zip")
	createE2ETestZip(t, zipPath, map[string]string{
		"skills/batch-force/SKILL.md": "---\nid: batch-force\nname: Overwritten\n---\n# Overwritten\n",
	})

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	part, _ := writer.CreateFormFile("file", "force.zip")
	f, _ := os.Open(zipPath)
	defer f.Close()
	io.Copy(part, f)
	writer.WriteField("force", "true")
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import-batch", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("batch force: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &resp)

	imported, _ := resp["imported"].([]interface{})
	if len(imported) != 1 {
		t.Fatalf("expected 1 imported, got %v", imported)
	}
	result := imported[0].(map[string]interface{})
	if result["status"] != "overwritten" {
		t.Fatalf("expected status 'overwritten', got %v", result["status"])
	}

	// Verify overwritten.
	w = httptest.NewRecorder()
	req, _ = http.NewRequest("GET", "/api/internal/skills/batch-force", nil)
	router.ServeHTTP(w, req)
	var skill map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &skill)
	if skill["name"] != "Overwritten" {
		t.Fatalf("expected name 'Overwritten', got %v", skill["name"])
	}
}

func TestAPI_SkillBatchImport_URL(t *testing.T) {
	router, _ := setupAPITest(t)

	// Create a ZIP in a fake server.
	zipBuf := &bytes.Buffer{}
	zw := zip.NewWriter(zipBuf)
	fw, _ := zw.Create("skills/from-url/SKILL.md")
	fw.Write([]byte("---\nid: from-url\nname: From URL\n---\n# From URL\n"))
	zw.Close()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/zip")
		w.Write(zipBuf.Bytes())
	}))
	defer srv.Close()

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	writer.WriteField("url", srv.URL+"/skills.zip")
	writer.Close()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import-batch", body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	router.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("batch URL import: expected 201, got %d: %s", w.Code, w.Body.String())
	}

	var resp map[string]interface{}
	json.Unmarshal(w.Body.Bytes(), &resp)
	if int(resp["created"].(float64)) != 1 {
		t.Fatalf("expected created=1, got %v", resp["created"])
	}
}

func TestAPI_SkillBatchImport_NoFileOrURL(t *testing.T) {
	router, _ := setupAPITest(t)

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("POST", "/api/internal/skills/import-batch", nil)
	req.Header.Set("Content-Type", "multipart/form-data")
	router.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
	}
}

// ---------------------------------------------------------------------------
// Archive helpers
// ---------------------------------------------------------------------------

func createE2ETestZip(t *testing.T, path string, files map[string]string) {
	t.Helper()
	var buf bytes.Buffer
	w := zip.NewWriter(&buf)
	for name, content := range files {
		fw, err := w.Create(name)
		if err != nil {
			t.Fatal(err)
		}
		fw.Write([]byte(content))
	}
	w.Close()
	os.WriteFile(path, buf.Bytes(), 0644)
}

func createE2ETestTarGz(t *testing.T, path string, files map[string]string) {
	t.Helper()
	var buf bytes.Buffer
	gw := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gw)
	for name, content := range files {
		tw.WriteHeader(&tar.Header{
			Name: name,
			Mode: 0644,
			Size: int64(len(content)),
		})
		tw.Write([]byte(content))
	}
	tw.Close()
	gw.Close()
	os.WriteFile(path, buf.Bytes(), 0644)
}
