package pipeline

import (
	"encoding/base64"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestScoreNovelty(t *testing.T) {
	t.Run("no alternatives returns 10", func(t *testing.T) {
		score := scoreNovelty(nil)
		assert.Equal(t, 10, score)
	})

	t.Run("one alt with 10000 stars returns 2", func(t *testing.T) {
		alts := []Alternative{{Name: "popular-cli", Stars: 10000}}
		score := scoreNovelty(alts)
		assert.Equal(t, 2, score)
	})

	t.Run("one alt with 50 stars returns 7", func(t *testing.T) {
		alts := []Alternative{{Name: "small-cli", Stars: 50}}
		score := scoreNovelty(alts)
		assert.Equal(t, 7, score)
	})
}

func TestDeduplicateAlts(t *testing.T) {
	alts := []Alternative{
		{Name: "cli-a", URL: "https://github.com/org/cli-a"},
		{Name: "cli-b", URL: "https://github.com/org/cli-b"},
		{Name: "cli-a-dup", URL: "https://github.com/org/cli-a"},
	}
	result := deduplicateAlts(alts)
	assert.Len(t, result, 2)
	assert.Equal(t, "cli-a", result[0].Name)
	assert.Equal(t, "cli-b", result[1].Name)
}

func TestRecommend(t *testing.T) {
	tests := []struct {
		score    int
		expected string
	}{
		{1, "skip"},
		{2, "skip"},
		{3, "skip"},
		{4, "proceed-with-gaps"},
		{5, "proceed-with-gaps"},
		{6, "proceed-with-gaps"},
		{7, "proceed"},
		{8, "proceed"},
		{9, "proceed"},
		{10, "proceed"},
	}
	for _, tt := range tests {
		t.Run("", func(t *testing.T) {
			assert.Equal(t, tt.expected, recommend(tt.score))
		})
	}
}

func TestAnalyzeAlternatives(t *testing.T) {
	alts := []Alternative{
		{Name: "tool-a", Language: "python", HasJSON: false},
		{Name: "tool-b", Language: "typescript", HasJSON: true},
	}
	gaps, patterns := analyzeAlternatives(alts)
	assert.NotEmpty(t, gaps)
	assert.NotEmpty(t, patterns)
}

func TestWriteAndLoadResearch(t *testing.T) {
	dir := t.TempDir()
	result := &ResearchResult{
		APIName:        "test-api",
		NoveltyScore:   8,
		Recommendation: "proceed",
		Auth: &ResearchAuth{
			CanonicalEnvVar: "TEST_API_TOKEN",
		},
		Alternatives: []Alternative{
			{Name: "alt-1", URL: "https://example.com/alt-1"},
		},
		Gaps:     []string{"no --json"},
		Patterns: []string{"standard CRUD"},
	}

	err := writeResearchJSON(result, dir)
	require.NoError(t, err)

	loaded, err := LoadResearch(dir)
	require.NoError(t, err)
	assert.Equal(t, "test-api", loaded.APIName)
	assert.Equal(t, 8, loaded.NoveltyScore)
	assert.Equal(t, "proceed", loaded.Recommendation)
	require.NotNil(t, loaded.Auth)
	assert.Equal(t, "TEST_API_TOKEN", loaded.CanonicalAuthEnvVar())
	assert.Len(t, loaded.Alternatives, 1)
	assert.Equal(t, "alt-1", loaded.Alternatives[0].Name)
}

func TestLoadResearchAcceptsStructuredGapsAndPatterns(t *testing.T) {
	dir := t.TempDir()
	body := []byte(`{
		"api_name": "test-api",
		"gaps": [
			{"name": "No JSON output", "reason": "Agents need structured responses"},
			"limited auth support"
		],
		"patterns": [
			{"name": "Workflow commands", "reason": "Competitors compose common tasks"},
			"standard CRUD"
		]
	}`)
	require.NoError(t, os.WriteFile(filepath.Join(dir, "research.json"), body, 0o644))

	loaded, err := LoadResearch(dir)
	require.NoError(t, err)
	assert.Equal(t, []string{
		"No JSON output: Agents need structured responses",
		"limited auth support",
	}, loaded.Gaps)
	assert.Equal(t, []string{
		"Workflow commands: Competitors compose common tasks",
		"standard CRUD",
	}, loaded.Patterns)
}

func TestLoadResearchRejectsMalformedGapsAndPatterns(t *testing.T) {
	tests := []struct {
		name string
		body string
	}{
		{name: "empty structured gap", body: `{"gaps":[{}]}`},
		{name: "numeric pattern", body: `{"patterns":[42]}`},
		{name: "null gap", body: `{"gaps":[null]}`},
		{name: "non-array gaps", body: `{"gaps":{"name":"No JSON output"}}`},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dir := t.TempDir()
			require.NoError(t, os.WriteFile(filepath.Join(dir, "research.json"), []byte(tt.body), 0o644))

			_, err := LoadResearch(dir)
			require.Error(t, err)
		})
	}
}

func TestParseGitHubURL(t *testing.T) {
	tests := []struct {
		url           string
		expectedOwner string
		expectedRepo  string
	}{
		{"https://github.com/cli/cli", "cli", "cli"},
		{"https://github.com/org/repo/", "org", "repo"},
		{"https://github.com/org/repo.git", "org", "repo"},
		{"https://github.com/org/repo/tree/main", "org", "repo"},
		{"https://example.com/not-github", "", ""},
		{"", "", ""},
		{"https://github.com/only-owner", "", ""},
	}
	for _, tt := range tests {
		t.Run(tt.url, func(t *testing.T) {
			owner, repo := parseGitHubURL(tt.url)
			assert.Equal(t, tt.expectedOwner, owner)
			assert.Equal(t, tt.expectedRepo, repo)
		})
	}
}

func TestParseCommandsFromReadme(t *testing.T) {
	readme := `# my-cli

## Usage

$ my-cli list
$ my-cli create --name foo
$ my-cli delete --id 123
$ my-cli version

Some text about the tool and how it works.

$ my-cli list
`
	commands := parseCommandsFromReadme(readme)
	assert.Contains(t, commands, "list")
	assert.Contains(t, commands, "create")
	assert.Contains(t, commands, "delete")
	assert.Contains(t, commands, "version")
	// Duplicates should be removed
	count := 0
	for _, c := range commands {
		if c == "list" {
			count++
		}
	}
	assert.Equal(t, 1, count, "list should appear only once")
}

func TestContainsPainSignal(t *testing.T) {
	assert.True(t, containsPainSignal("This feature is broken"))
	assert.True(t, containsPainSignal("CLI crashes on startup"))
	assert.True(t, containsPainSignal("Slow performance"))
	assert.True(t, containsPainSignal("doesn't work with proxy"))
	assert.False(t, containsPainSignal("Add support for new endpoint"))
	assert.False(t, containsPainSignal(""))
}

func TestIsCommonWord(t *testing.T) {
	assert.True(t, isCommonWord("the"))
	assert.True(t, isCommonWord("The"))
	assert.True(t, isCommonWord("AND"))
	assert.False(t, isCommonWord("list"))
	assert.False(t, isCommonWord("create"))
}

func TestSynthesizeInsights(t *testing.T) {
	analyses := []CompetitorAnalysis{
		{
			RepoURL:         "https://github.com/org/tool-a",
			CommandsFound:   []string{"list", "create", "delete"},
			CommandCount:    3,
			FeatureRequests: []string{"Add JSON output", "Support pagination"},
			PainPoints:      []string{"CLI crashes on startup"},
		},
		{
			RepoURL:         "https://github.com/org/tool-b",
			CommandsFound:   []string{"list", "get", "update", "delete", "search"},
			CommandCount:    5,
			FeatureRequests: []string{"Add JSON output", "Support YAML config"},
			PainPoints:      []string{"Slow performance"},
		},
	}

	insights := synthesizeInsights(analyses)

	assert.Len(t, insights.Analyses, 2)
	// Target should be ceil(5 * 1.2) = 6
	assert.Equal(t, 6, insights.CommandTarget)
	// "Add JSON output" appears in both - should be deduplicated
	assert.Len(t, insights.UnmetFeatures, 3) // JSON output, pagination, YAML config
	assert.Len(t, insights.PainPointsToAvoid, 2)
}

func TestSynthesizeInsightsEmpty(t *testing.T) {
	insights := synthesizeInsights([]CompetitorAnalysis{})
	assert.Equal(t, 0, insights.CommandTarget)
	assert.Empty(t, insights.UnmetFeatures)
	assert.Empty(t, insights.PainPointsToAvoid)
}

func TestWriteAndLoadResearchWithCompetitorInsights(t *testing.T) {
	dir := t.TempDir()
	insights := &CompetitorInsights{
		Analyses: []CompetitorAnalysis{
			{RepoURL: "https://github.com/org/tool", CommandCount: 5},
		},
		CommandTarget:     6,
		UnmetFeatures:     []string{"JSON output"},
		PainPointsToAvoid: []string{"crashes"},
	}
	result := &ResearchResult{
		APIName:            "test-api",
		NoveltyScore:       8,
		Recommendation:     "proceed",
		CompetitorInsights: insights,
	}

	err := writeResearchJSON(result, dir)
	require.NoError(t, err)

	loaded, err := LoadResearch(dir)
	require.NoError(t, err)
	require.NotNil(t, loaded.CompetitorInsights)
	assert.Equal(t, 6, loaded.CompetitorInsights.CommandTarget)
	assert.Len(t, loaded.CompetitorInsights.Analyses, 1)
	assert.Equal(t, []string{"JSON output"}, loaded.CompetitorInsights.UnmetFeatures)
}

func TestWriteAndLoadResearchWithNovelFeatures(t *testing.T) {
	dir := t.TempDir()
	result := &ResearchResult{
		APIName:        "test-api",
		NoveltyScore:   8,
		Recommendation: "proceed",
		NovelFeatures: []NovelFeature{
			{
				Name:        "Health dashboard",
				Command:     "health",
				Description: "See scheduling health metrics at a glance",
				Rationale:   "Requires correlating bookings, schedules, and staff data in the local store",
			},
			{
				Name:        "Stale booking triage",
				Command:     "triage",
				Description: "Find and act on unconfirmed bookings older than N days",
				Rationale:   "No existing tool offers batch triage of pending bookings",
			},
		},
	}

	err := writeResearchJSON(result, dir)
	require.NoError(t, err)

	loaded, err := LoadResearch(dir)
	require.NoError(t, err)
	require.Len(t, loaded.NovelFeatures, 2)
	assert.Equal(t, "Health dashboard", loaded.NovelFeatures[0].Name)
	assert.Equal(t, "health", loaded.NovelFeatures[0].Command)
	assert.Equal(t, "See scheduling health metrics at a glance", loaded.NovelFeatures[0].Description)
	assert.Equal(t, "Requires correlating bookings, schedules, and staff data in the local store", loaded.NovelFeatures[0].Rationale)
	assert.Equal(t, "Stale booking triage", loaded.NovelFeatures[1].Name)
}

func TestWriteAndLoadResearchWithEnrichedNovelFeatures(t *testing.T) {
	dir := t.TempDir()
	result := &ResearchResult{
		APIName:        "test-api",
		NoveltyScore:   9,
		Recommendation: "proceed",
		NovelFeatures: []NovelFeature{
			{
				Name:         "Portfolio perf",
				Command:      "portfolio perf",
				Description:  "Compute unrealized P&L across synced lots",
				Rationale:    "No existing tool joins live quotes with a local cost basis",
				Example:      "yahoo-finance-pp-cli portfolio perf --agent",
				WhyItMatters: "Agents can answer \"how's my portfolio?\" without a second lookup",
				Group:        "Local state that compounds",
			},
		},
		Narrative: &ReadmeNarrative{
			DisplayName: "Yahoo Finance",
			Headline:    "Every Yahoo Finance feature plus a local portfolio tracker",
			ValueProp:   "Quotes, charts, fundamentals, options chains — plus portfolio and watchlist state nothing else has.",
			QuickStart: []QuickStartStep{
				{Command: "yahoo-finance-pp-cli quote AAPL", Comment: "Your first quote"},
			},
			Troubleshoots: []TroubleshootTip{
				{Symptom: "HTTP 429 on every request", Fix: "Import a Chrome session via auth login-chrome"},
			},
			WhenToUse:      "Reach for this CLI when an agent needs quotes, fundamentals, or persistent portfolio state against Yahoo Finance.",
			AntiTriggers:   []string{"Tasks that require placing trades or moving money", "Brokerage account management beyond read-only portfolio analysis"},
			Recipes:        []Recipe{{Title: "Morning digest", Command: "yahoo-finance-pp-cli digest --watchlist tech", Explanation: "Biggest movers across a named watchlist."}},
			TriggerPhrases: []string{"quote AAPL", "check my portfolio", "options for TSLA"},
		},
	}

	err := writeResearchJSON(result, dir)
	require.NoError(t, err)

	loaded, err := LoadResearch(dir)
	require.NoError(t, err)
	require.Len(t, loaded.NovelFeatures, 1)
	nf := loaded.NovelFeatures[0]
	assert.Equal(t, "yahoo-finance-pp-cli portfolio perf --agent", nf.Example)
	assert.Equal(t, "Agents can answer \"how's my portfolio?\" without a second lookup", nf.WhyItMatters)
	assert.Equal(t, "Local state that compounds", nf.Group)

	require.NotNil(t, loaded.Narrative)
	assert.Equal(t, "Yahoo Finance", loaded.Narrative.DisplayName)
	assert.Equal(t, "Every Yahoo Finance feature plus a local portfolio tracker", loaded.Narrative.Headline)
	assert.Len(t, loaded.Narrative.QuickStart, 1)
	assert.Equal(t, "Your first quote", loaded.Narrative.QuickStart[0].Comment)
	assert.Len(t, loaded.Narrative.Troubleshoots, 1)
	assert.Equal(t, "HTTP 429 on every request", loaded.Narrative.Troubleshoots[0].Symptom)
	assert.Equal(t, "Reach for this CLI when an agent needs quotes, fundamentals, or persistent portfolio state against Yahoo Finance.", loaded.Narrative.WhenToUse)
	assert.Equal(t, []string{"Tasks that require placing trades or moving money", "Brokerage account management beyond read-only portfolio analysis"}, loaded.Narrative.AntiTriggers)
	require.Len(t, loaded.Narrative.Recipes, 1)
	assert.Equal(t, "Morning digest", loaded.Narrative.Recipes[0].Title)
	assert.Equal(t, []string{"quote AAPL", "check my portfolio", "options for TSLA"}, loaded.Narrative.TriggerPhrases)
}

func TestWriteAndLoadResearchWithoutNovelFeatures(t *testing.T) {
	dir := t.TempDir()
	result := &ResearchResult{
		APIName:        "test-api",
		NoveltyScore:   5,
		Recommendation: "proceed",
	}

	err := writeResearchJSON(result, dir)
	require.NoError(t, err)

	loaded, err := LoadResearch(dir)
	require.NoError(t, err)
	assert.Nil(t, loaded.NovelFeatures)
}

func TestWriteNovelFeaturesBuilt(t *testing.T) {
	dir := t.TempDir()
	// Write initial research with planned features
	result := &ResearchResult{
		APIName: "test-api",
		NovelFeatures: []NovelFeature{
			{Name: "Health", Command: "health", Description: "Metrics", Rationale: "Local data"},
			{Name: "Triage", Command: "triage", Description: "Find stale", Rationale: "Batch ops"},
		},
	}
	require.NoError(t, writeResearchJSON(result, dir))

	// Write verified subset
	built := []NovelFeature{
		{Name: "Health", Command: "health", Description: "Metrics", Rationale: "Local data"},
	}
	require.NoError(t, WriteNovelFeaturesBuilt(dir, built))

	// Load and verify both lists are present
	loaded, err := LoadResearch(dir)
	require.NoError(t, err)
	assert.Len(t, loaded.NovelFeatures, 2, "planned list preserved")
	require.NotNil(t, loaded.NovelFeaturesBuilt)
	assert.Len(t, *loaded.NovelFeaturesBuilt, 1, "built list is verified subset")
	assert.Equal(t, "health", (*loaded.NovelFeaturesBuilt)[0].Command)
}

func TestFetchIssuesWithMockServer(t *testing.T) {
	issues := []ghIssue{
		{Title: "Add dark mode", Body: "Please add dark mode support"},
		{Title: "CLI crashes on empty input", Body: "The tool crashes when no args given"},
	}

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(issues)
	}))
	defer server.Close()

	// We can't easily override the base URL in fetchIssues without refactoring,
	// so test the parsing logic indirectly via the analysis functions
	_ = server
}

func TestBuildCompetitorPrompt(t *testing.T) {
	readme := "# my-cli\n\nA great CLI tool\n\n$ my-cli list\n$ my-cli create"
	issues := []string{"Add JSON output", "Support pagination"}

	prompt := BuildCompetitorPrompt("org", "my-cli", readme, issues)
	assert.Contains(t, prompt, "org/my-cli")
	assert.Contains(t, prompt, "A great CLI tool")
	assert.Contains(t, prompt, "Add JSON output")
	assert.Contains(t, prompt, "Support pagination")
	assert.Contains(t, prompt, "commands_found")
	assert.Contains(t, prompt, "pain_points")
}

func TestParseCompetitorResponse(t *testing.T) {
	response := `{
		"commands_found": ["list", "create", "delete"],
		"feature_requests": ["Add YAML output"],
		"pain_points": ["Slow startup"],
		"auth_method": "api_key",
		"output_formats": ["json"],
		"install_method": "brew"
	}`

	analysis, err := parseCompetitorResponse(response, "org", "tool")
	require.NoError(t, err)
	assert.Equal(t, "https://github.com/org/tool", analysis.RepoURL)
	assert.Equal(t, []string{"list", "create", "delete"}, analysis.CommandsFound)
	assert.Equal(t, 3, analysis.CommandCount)
	assert.Equal(t, []string{"Add YAML output"}, analysis.FeatureRequests)
	assert.Equal(t, []string{"Slow startup"}, analysis.PainPoints)
}

func TestParseCompetitorResponseWithFences(t *testing.T) {
	response := "Here's the analysis:\n```json\n{\"commands_found\": [\"list\"], \"feature_requests\": [], \"pain_points\": [], \"auth_method\": \"none\", \"output_formats\": [], \"install_method\": \"binary\"}\n```"

	analysis, err := parseCompetitorResponse(response, "org", "tool")
	require.NoError(t, err)
	assert.Equal(t, []string{"list"}, analysis.CommandsFound)
}

func TestExtractJSONObject(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{"plain", `{"key": "val"}`, `{"key": "val"}`},
		{"with prefix", `Here: {"key": "val"} done`, `{"key": "val"}`},
		{"nested", `{"a": {"b": 1}}`, `{"a": {"b": 1}}`},
		{"empty", "no json here", "{}"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := extractJSONObject(tt.input)
			assert.Equal(t, tt.want, got)
		})
	}
}

func TestFetchReadmeDecoding(t *testing.T) {
	readmeContent := "# My CLI\n\n$ mycli list\n$ mycli create\n"
	encoded := base64.StdEncoding.EncodeToString([]byte(readmeContent))

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(ghReadmeResponse{
			Content:  encoded,
			Encoding: "base64",
		})
	}))
	defer server.Close()

	// Test the base64 decode path directly
	commands := parseCommandsFromReadme(readmeContent)
	assert.Contains(t, commands, "list")
	assert.Contains(t, commands, "create")
}

func TestSourcesForREADME(t *testing.T) {
	t.Run("nil research returns nil", func(t *testing.T) {
		assert.Nil(t, SourcesForREADME(nil))
	})

	t.Run("filters empty URLs and sorts by stars desc", func(t *testing.T) {
		r := &ResearchResult{
			Alternatives: []Alternative{
				{Name: "low-stars", URL: "https://github.com/org/low", Language: "go", Stars: 10},
				{Name: "no-url", URL: "", Language: "python", Stars: 999},
				{Name: "high-stars", URL: "https://github.com/org/high", Language: "typescript", Stars: 5000},
				{Name: "mid-stars", URL: "https://github.com/org/mid", Language: "rust", Stars: 200},
			},
		}
		sources := SourcesForREADME(r)
		require.Len(t, sources, 3)
		assert.Equal(t, "high-stars", sources[0].Name)
		assert.Equal(t, 5000, sources[0].Stars)
		assert.Equal(t, "mid-stars", sources[1].Name)
		assert.Equal(t, "low-stars", sources[2].Name)
	})

	t.Run("no alternatives returns nil", func(t *testing.T) {
		r := &ResearchResult{Alternatives: nil}
		assert.Nil(t, SourcesForREADME(r))
	})
}

func TestParseDiscoveryPages(t *testing.T) {
	t.Run("missing file returns nil", func(t *testing.T) {
		assert.Nil(t, ParseDiscoveryPages(t.TempDir()))
	})

	t.Run("extracts URLs from pages visited section", func(t *testing.T) {
		dir := t.TempDir()
		content := `# Browser-Sniff Report

## Pages Visited

- https://example.com/app
- https://example.com/dashboard
- not-a-url

## Endpoints Discovered

- GET /api/v1/items
`
		require.NoError(t, os.WriteFile(dir+"/browser-sniff-report.md", []byte(content), 0o644))
		pages := ParseDiscoveryPages(dir)
		require.Len(t, pages, 2)
		assert.Equal(t, "https://example.com/app", pages[0])
		assert.Equal(t, "https://example.com/dashboard", pages[1])
	})

	t.Run("empty pages section returns nil", func(t *testing.T) {
		dir := t.TempDir()
		content := `# Browser-Sniff Report

## Pages Visited

## Endpoints Discovered

- GET /api/v1/items
`
		require.NoError(t, os.WriteFile(dir+"/browser-sniff-report.md", []byte(content), 0o644))
		assert.Nil(t, ParseDiscoveryPages(dir))
	})

	// Clean-break contract (D3): when only the pre-rename legacy filename is
	// present, ParseDiscoveryPages must NOT silently fall back to reading it.
	// This test guards against anyone later adding fallback-read logic.
	t.Run("legacy sniff-report.md is not read", func(t *testing.T) {
		dir := t.TempDir()
		content := `# Sniff Report

## Pages Visited

- https://example.com/legacy
`
		require.NoError(t, os.WriteFile(dir+"/sniff-report.md", []byte(content), 0o644))
		assert.Nil(t, ParseDiscoveryPages(dir), "legacy sniff-report.md must be ignored — no fallback read")
	})
}
