package mcpserver

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/modelcontextprotocol/go-sdk/mcp"

	"hitkeep/internal/api"
	"hitkeep/internal/database"
	opportunitysvc "hitkeep/internal/opportunities"
)

func (s *service) registerTools(server *mcp.Server) {
	readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: new(false)}

	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_list_sites",
		Title:       "List HitKeep Sites",
		Description: "List HitKeep sites visible to the bearer API client.",
		Annotations: readOnly,
	}, s.listSites)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_site_overview",
		Title:       "Get HitKeep Site Overview",
		Description: "Read aggregate traffic KPIs, top dimensions, goals, chart data, and optional comparison for one site.",
		Annotations: readOnly,
	}, s.getSiteOverview)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_event_names",
		Title:       "Get HitKeep Event Names",
		Description: "List event names tracked for one site in a date range.",
		Annotations: readOnly,
	}, s.getEventNames)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_event_breakdown",
		Title:       "Get HitKeep Event Breakdown",
		Description: "Read a count breakdown for one event property.",
		Annotations: readOnly,
	}, s.getEventBreakdown)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_ecommerce",
		Title:       "Get HitKeep Ecommerce Analytics",
		Description: "Read ecommerce summary, top products, source stats, and aggregate city, provider, and ASN breakdowns for one site.",
		Annotations: readOnly,
	}, s.getEcommerce)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_web_vitals",
		Title:       "Get HitKeep Web Vitals",
		Description: "Read aggregate Web Vitals p75, sample counts, rating counts, and optional page or visitor-context breakdowns for one site.",
		Annotations: readOnly,
	}, s.getWebVitals)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_ai_visibility",
		Title:       "Get HitKeep AI Visibility",
		Description: "Read AI crawler fetch overview, timeseries, and optional fetch-to-visit correlation for one site.",
		Annotations: readOnly,
	}, s.getAIVisibility)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_opportunities",
		Title:       "Get HitKeep Opportunities",
		Description: "Read saved localized Opportunities recommendations for one site without raw prompts or provider payloads.",
		Annotations: readOnly,
	}, s.getOpportunities)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_search_console_status",
		Title:       "Get HitKeep Search Console Status",
		Description: "Read Google Search Console mapping and sync status for one HitKeep site.",
		Annotations: readOnly,
	}, s.getSearchConsoleStatus)
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_search_console",
		Title:       "Get HitKeep Search Console",
		Description: "Read imported Google Search Console overview, series, and optional dimension reports for one site.",
		Annotations: readOnly,
	}, s.getSearchConsole)
	if s.docs != nil {
		docsReadOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: new(true)}
		mcp.AddTool(server, &mcp.Tool{
			Name:        "hitkeep_search_docs",
			Title:       "Search HitKeep Docs",
			Description: "Search official HitKeep documentation using the LLM catalog and cached markdown pages.",
			Annotations: docsReadOnly,
		}, s.searchDocs)
		mcp.AddTool(server, &mcp.Tool{
			Name:        "hitkeep_get_doc",
			Title:       "Get HitKeep Doc",
			Description: "Fetch an official HitKeep docs page as markdown.",
			Annotations: docsReadOnly,
		}, s.getDoc)
		mcp.AddTool(server, &mcp.Tool{
			Name:        "hitkeep_get_api_reference",
			Title:       "Get HitKeep API Reference",
			Description: "Fetch an official HitKeep REST API reference page as markdown by path or operation slug.",
			Annotations: docsReadOnly,
		}, s.getAPIReference)
	}
	mcp.AddTool(server, &mcp.Tool{
		Name:        "hitkeep_get_mcp_help",
		Title:       "Get HitKeep MCP Help",
		Description: "Return local MCP usage guidance, token setup, privacy boundaries, date ranges, and filter syntax.",
		Annotations: readOnly,
	}, s.getMCPHelp)
}

func (s *service) listSites(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, listSitesOutput, error) {
	authz, err := apiAuth(ctx)
	if err != nil {
		return nil, listSitesOutput{}, err
	}

	var sites []api.Site
	switch {
	case authz.UserID != uuid.Nil:
		sites, err = s.store.GetSites(ctx, authz.UserID)
	case authz.TenantID != uuid.Nil:
		sites, err = s.store.ListSitesForTenant(ctx, authz.TenantID)
	default:
		return nil, listSitesOutput{}, errors.New("unauthorized")
	}
	if err != nil {
		return nil, listSitesOutput{}, err
	}

	filtered := make([]api.Site, 0, len(sites))
	for _, site := range sites {
		if _, ok := authz.SiteRoles[site.ID]; ok {
			filtered = append(filtered, site)
		}
	}
	return nil, listSitesOutput{Sites: toMCPSites(filtered)}, nil
}

func (s *service) getSiteOverview(ctx context.Context, _ *mcp.CallToolRequest, input siteOverviewInput) (*mcp.CallToolResult, siteOverviewOutput, error) {
	siteID, start, end, err := s.parseSiteRange(input.SiteID, rangeInput{From: input.From, To: input.To})
	if err != nil {
		return nil, siteOverviewOutput{}, err
	}
	authz, err := s.requireSiteView(ctx, siteID)
	if err != nil {
		return nil, siteOverviewOutput{}, err
	}
	filters, err := parseFilters(input.Filters)
	if err != nil {
		return nil, siteOverviewOutput{}, err
	}

	params := api.AnalyticsParams{
		SiteID:  siteID,
		UserID:  authz.UserID,
		Start:   start,
		End:     end,
		Filters: filters,
	}
	if input.CompareFrom != "" || input.CompareTo != "" {
		compareStart, compareEnd, err := parseExplicitRange(input.CompareFrom, input.CompareTo, s.conf.MCPMaxRangeDays)
		if err != nil {
			return nil, siteOverviewOutput{}, err
		}
		params.CompareStart = compareStart
		params.CompareEnd = compareEnd
	}

	analyticsStore, err := s.analyticsStore(ctx, siteID)
	if err != nil {
		return nil, siteOverviewOutput{}, err
	}
	stats, err := analyticsStore.GetSiteStats(ctx, params)
	if err != nil {
		return nil, siteOverviewOutput{}, err
	}
	return nil, siteOverviewOutput{SiteID: siteID.String(), From: formatMCPTime(start), To: formatMCPTime(end), Stats: toMCPSiteStats(stats)}, nil
}

func (s *service) getEventNames(ctx context.Context, _ *mcp.CallToolRequest, input siteRangeInput) (*mcp.CallToolResult, eventNamesOutput, error) {
	siteID, start, end, err := s.parseSiteRange(input.SiteID, input.rangeInput)
	if err != nil {
		return nil, eventNamesOutput{}, err
	}
	if _, err := s.requireSiteView(ctx, siteID); err != nil {
		return nil, eventNamesOutput{}, err
	}
	analyticsStore, err := s.analyticsStore(ctx, siteID)
	if err != nil {
		return nil, eventNamesOutput{}, err
	}
	names, err := analyticsStore.GetEventNames(ctx, api.EventNamesParams{SiteID: siteID, Start: start, End: end})
	if err != nil {
		return nil, eventNamesOutput{}, err
	}
	return nil, eventNamesOutput{SiteID: siteID.String(), From: formatMCPTime(start), To: formatMCPTime(end), Names: names}, nil
}

func (s *service) getEventBreakdown(ctx context.Context, _ *mcp.CallToolRequest, input eventBreakdownInput) (*mcp.CallToolResult, eventBreakdownOutput, error) {
	siteID, start, end, err := s.parseSiteRange(input.SiteID, input.rangeInput)
	if err != nil {
		return nil, eventBreakdownOutput{}, err
	}
	if _, err := s.requireSiteView(ctx, siteID); err != nil {
		return nil, eventBreakdownOutput{}, err
	}
	eventName := strings.TrimSpace(input.EventName)
	propertyKey := strings.TrimSpace(input.PropertyKey)
	if eventName == "" || propertyKey == "" {
		return nil, eventBreakdownOutput{}, errors.New("event_name and property_key are required")
	}

	analyticsStore, err := s.analyticsStore(ctx, siteID)
	if err != nil {
		return nil, eventBreakdownOutput{}, err
	}
	breakdown, err := analyticsStore.GetEventPropertyBreakdown(ctx, api.EventBreakdownParams{
		SiteID: siteID, Start: start, End: end, EventName: eventName, PropertyKey: propertyKey,
	})
	if err != nil {
		return nil, eventBreakdownOutput{}, err
	}
	breakdown = limitSlice(breakdown, normalizeLimit(input.Limit))
	return nil, eventBreakdownOutput{SiteID: siteID.String(), From: formatMCPTime(start), To: formatMCPTime(end), Breakdown: breakdown}, nil
}

func (s *service) getEcommerce(ctx context.Context, _ *mcp.CallToolRequest, input ecommerceInput) (*mcp.CallToolResult, ecommerceOutput, error) {
	siteID, start, end, err := s.parseSiteRange(input.SiteID, input.rangeInput)
	if err != nil {
		return nil, ecommerceOutput{}, err
	}
	if _, err := s.requireSiteView(ctx, siteID); err != nil {
		return nil, ecommerceOutput{}, err
	}
	filters, err := parseFilters(input.Filters)
	if err != nil {
		return nil, ecommerceOutput{}, err
	}
	params := api.EcommerceParams{
		SiteID: siteID, Start: start, End: end, Filters: filters,
		ItemID: strings.TrimSpace(input.ItemID), ItemName: strings.TrimSpace(input.ItemName),
		Limit: normalizeLimit(input.Limit),
	}
	analyticsStore, err := s.analyticsStore(ctx, siteID)
	if err != nil {
		return nil, ecommerceOutput{}, err
	}
	summary, err := analyticsStore.GetEcommerceSummary(ctx, params)
	if err != nil {
		return nil, ecommerceOutput{}, err
	}
	products, err := analyticsStore.GetEcommerceTopProducts(ctx, params)
	if err != nil {
		return nil, ecommerceOutput{}, err
	}
	sources, err := analyticsStore.GetEcommerceSources(ctx, params)
	if err != nil {
		return nil, ecommerceOutput{}, err
	}
	return nil, ecommerceOutput{SiteID: siteID.String(), From: formatMCPTime(start), To: formatMCPTime(end), Summary: summary, Products: products, Sources: sources}, nil
}

func (s *service) getWebVitals(ctx context.Context, _ *mcp.CallToolRequest, input webVitalsInput) (*mcp.CallToolResult, webVitalsOutput, error) {
	siteID, start, end, err := s.parseSiteRange(input.SiteID, input.rangeInput)
	if err != nil {
		return nil, webVitalsOutput{}, err
	}
	if _, err := s.requireSiteView(ctx, siteID); err != nil {
		return nil, webVitalsOutput{}, err
	}

	params, breakdownDimension, err := parseMCPWebVitalsParams(siteID, start, end, input)
	if err != nil {
		return nil, webVitalsOutput{}, err
	}
	analyticsStore, err := s.analyticsStore(ctx, siteID)
	if err != nil {
		return nil, webVitalsOutput{}, err
	}
	summary, err := analyticsStore.GetWebVitalsSummary(ctx, params)
	if err != nil {
		return nil, webVitalsOutput{}, err
	}
	output := webVitalsOutput{
		SiteID:  siteID.String(),
		From:    formatMCPTime(start),
		To:      formatMCPTime(end),
		Summary: summary,
	}
	if err := loadMCPWebVitalsDetails(ctx, analyticsStore, params, input, breakdownDimension, &output); err != nil {
		return nil, webVitalsOutput{}, err
	}
	return nil, output, nil
}

func parseMCPWebVitalsParams(siteID uuid.UUID, start, end time.Time, input webVitalsInput) (api.WebVitalsParams, api.WebVitalDimension, error) {
	metric, err := parseMCPWebVitalMetric(input)
	if err != nil {
		return api.WebVitalsParams{}, "", err
	}
	rating, err := parseMCPWebVitalRating(input.Rating)
	if err != nil {
		return api.WebVitalsParams{}, "", err
	}
	breakdownDimension := api.WebVitalDimension(strings.TrimSpace(input.BreakdownDimension))
	if breakdownDimension != "" && !isAllowedWebVitalBreakdownDimension(breakdownDimension) {
		return api.WebVitalsParams{}, "", fmt.Errorf("invalid web vital breakdown dimension %q", input.BreakdownDimension)
	}

	return api.WebVitalsParams{
		SiteID: siteID,
		Start:  start,
		End:    end,
		Metric: metric,
		Path:   strings.TrimSpace(input.Path),
		Rating: rating,
		Limit:  normalizeLimit(input.Limit),
	}, breakdownDimension, nil
}

func parseMCPWebVitalMetric(input webVitalsInput) (api.WebVitalMetric, error) {
	metric := api.WebVitalMetric(strings.TrimSpace(input.Metric))
	if metric != "" {
		if _, err := database.WebVitalRatingForValue(metric, 0); err != nil {
			return "", err
		}
	}
	if metric == "" && (input.IncludePages || strings.TrimSpace(input.BreakdownDimension) != "") {
		return api.WebVitalLCP, nil
	}
	return metric, nil
}

func parseMCPWebVitalRating(rawRating string) (api.WebVitalRating, error) {
	rating := api.WebVitalRating(strings.TrimSpace(rawRating))
	if rating == "" {
		return "", nil
	}
	switch rating {
	case api.WebVitalRatingGood, api.WebVitalRatingNeedsImprovement, api.WebVitalRatingPoor:
		return rating, nil
	default:
		return "", fmt.Errorf("invalid web vital rating %q", rawRating)
	}
}

func loadMCPWebVitalsDetails(ctx context.Context, store *database.Store, params api.WebVitalsParams, input webVitalsInput, breakdownDimension api.WebVitalDimension, output *webVitalsOutput) error {
	if input.IncludePages {
		pages, err := store.GetWebVitalsPages(ctx, params)
		if err != nil {
			return err
		}
		output.Metric = params.Metric
		output.Pages = pages
	}
	if breakdownDimension != "" {
		breakdown, err := store.GetWebVitalsBreakdown(ctx, params, breakdownDimension)
		if err != nil {
			return err
		}
		output.Metric = params.Metric
		output.BreakdownDimension = breakdownDimension
		output.Breakdown = breakdown
	}
	return nil
}

func isAllowedWebVitalBreakdownDimension(dimension api.WebVitalDimension) bool {
	switch dimension {
	case api.WebVitalDimensionBrowser,
		api.WebVitalDimensionCountry,
		api.WebVitalDimensionLanguage,
		api.WebVitalDimensionDevice,
		api.WebVitalDimensionCity,
		api.WebVitalDimensionProvider,
		api.WebVitalDimensionASN:
		return true
	default:
		return false
	}
}

func (s *service) getAIVisibility(ctx context.Context, _ *mcp.CallToolRequest, input aiVisibilityInput) (*mcp.CallToolResult, aiVisibilityOutput, error) {
	siteID, start, end, err := s.parseSiteRange(input.SiteID, input.rangeInput)
	if err != nil {
		return nil, aiVisibilityOutput{}, err
	}
	if _, err := s.requireSiteView(ctx, siteID); err != nil {
		return nil, aiVisibilityOutput{}, err
	}
	params := api.AIFetchQueryParams{
		SiteID: siteID, Start: start, End: end,
		AssistantName:   strings.TrimSpace(input.AssistantName),
		AssistantFamily: strings.TrimSpace(input.AssistantFamily),
		ResourceType:    strings.TrimSpace(input.ResourceType),
	}
	analyticsStore, err := s.analyticsStore(ctx, siteID)
	if err != nil {
		return nil, aiVisibilityOutput{}, err
	}
	overview, err := analyticsStore.GetAIFetchOverview(ctx, params)
	if err != nil {
		return nil, aiVisibilityOutput{}, err
	}
	timeseries, err := analyticsStore.GetAIFetchTimeseries(ctx, params)
	if err != nil {
		return nil, aiVisibilityOutput{}, err
	}
	output := aiVisibilityOutput{SiteID: siteID.String(), From: formatMCPTime(start), To: formatMCPTime(end), Overview: overview, Timeseries: toMCPAIFetchSeries(timeseries)}
	if input.IncludeCorrelation {
		windowDays := input.WindowDays
		if windowDays <= 0 {
			windowDays = 30
		}
		if windowDays > 90 {
			windowDays = 90
		}
		correlation, err := analyticsStore.GetAIFetchCorrelation(ctx, api.AIFetchCorrelationParams{
			SiteID: siteID, Start: start, End: end,
			AssistantName: params.AssistantName, AssistantFamily: params.AssistantFamily, ResourceType: params.ResourceType,
			WindowDays: windowDays,
		})
		if err != nil {
			return nil, aiVisibilityOutput{}, err
		}
		output.Correlation = correlation
	}
	return nil, output, nil
}

func (s *service) getOpportunities(ctx context.Context, _ *mcp.CallToolRequest, input opportunitiesInput) (*mcp.CallToolResult, opportunitiesOutput, error) {
	siteID, status, limit, err := s.parseOpportunitiesRequest(ctx, input)
	if err != nil {
		return nil, opportunitiesOutput{}, err
	}
	opportunities, err := s.store.ListOpportunities(ctx, siteID)
	if err != nil {
		return nil, opportunitiesOutput{}, err
	}
	opportunities = opportunitysvc.RankOpportunities(opportunities)
	return nil, opportunitiesOutput{SiteID: siteID.String(), Opportunities: toMCPOpportunities(opportunities, status, limit)}, nil
}

func (s *service) parseOpportunitiesRequest(ctx context.Context, input opportunitiesInput) (uuid.UUID, string, int, error) {
	siteID, err := uuid.Parse(strings.TrimSpace(input.SiteID))
	if err != nil {
		return uuid.Nil, "", 0, errors.New("invalid site_id")
	}
	if _, err := s.requireSiteView(ctx, siteID); err != nil {
		return uuid.Nil, "", 0, err
	}
	status, err := normalizeOpportunityStatus(input.Status)
	if err != nil {
		return uuid.Nil, "", 0, err
	}
	return siteID, status, normalizeLimit(input.Limit), nil
}

func toMCPOpportunities(opportunities []api.Opportunity, status string, limit int) []mcpOpportunity {
	filtered := make([]mcpOpportunity, 0, min(len(opportunities), limit))
	for _, opportunity := range opportunities {
		if !opportunityStatusMatches(opportunity, status) {
			continue
		}
		filtered = append(filtered, toMCPOpportunity(opportunity))
		if len(filtered) == limit {
			break
		}
	}
	return filtered
}

func opportunityStatusMatches(opportunity api.Opportunity, status string) bool {
	return status == "" || opportunity.Status == status
}

func toMCPOpportunity(opportunity api.Opportunity) mcpOpportunity {
	return mcpOpportunity{
		ID:               opportunity.ID.String(),
		SiteID:           opportunity.SiteID.String(),
		Kind:             opportunity.Kind,
		TypeKey:          opportunity.TypeKey,
		TitleKey:         opportunity.TitleKey,
		SummaryKey:       opportunity.SummaryKey,
		ActionKey:        opportunity.ActionKey,
		DigestKey:        opportunity.DigestKey,
		CopyParams:       opportunity.CopyParams,
		ImpactValue:      opportunity.ImpactValue,
		ImpactLabelKey:   opportunity.ImpactLabelKey,
		Confidence:       opportunity.Confidence,
		Score:            opportunity.Score,
		ScoreBreakdown:   opportunity.ScoreBreakdown,
		Status:           opportunity.Status,
		RouteLabelKey:    opportunity.RouteLabelKey,
		RouteParams:      opportunity.RouteParams,
		RouteIcon:        opportunity.RouteIcon,
		DetectorVersion:  opportunity.DetectorVersion,
		Evidence:         citedMCPOpportunityEvidence(opportunity.Evidence, opportunity.CitedEvidenceIDs),
		CitedEvidenceIDs: opportunity.CitedEvidenceIDs,
		GeneratedAt:      formatMCPTime(opportunity.GeneratedAt),
		CreatedAt:        formatMCPTime(opportunity.CreatedAt),
		UpdatedAt:        formatMCPTime(opportunity.UpdatedAt),
	}
}

func citedMCPOpportunityEvidence(evidence []api.OpportunityEvidence, citedEvidenceIDs []string) []api.OpportunityEvidence {
	cited := make(map[string]bool, len(citedEvidenceIDs))
	for _, id := range citedEvidenceIDs {
		if strings.TrimSpace(id) != "" {
			cited[id] = true
		}
	}
	out := make([]api.OpportunityEvidence, 0, len(evidence))
	for _, item := range evidence {
		if cited[item.ID] {
			out = append(out, item)
		}
	}
	return out
}

var opportunityStatusFilters = map[string]string{
	"":          "",
	"all":       "",
	"new":       "new",
	"saved":     "saved",
	"done":      "done",
	"dismissed": "dismissed",
}

func normalizeOpportunityStatus(raw string) (string, error) {
	status := strings.ToLower(strings.TrimSpace(raw))
	normalized, ok := opportunityStatusFilters[status]
	if !ok {
		return "", fmt.Errorf("invalid opportunity status %q", raw)
	}
	return normalized, nil
}

func (s *service) getSearchConsoleStatus(ctx context.Context, _ *mcp.CallToolRequest, input searchConsoleStatusInput) (*mcp.CallToolResult, searchConsoleStatusOutput, error) {
	siteID, err := uuid.Parse(strings.TrimSpace(input.SiteID))
	if err != nil {
		return nil, searchConsoleStatusOutput{}, errors.New("invalid site_id")
	}
	if _, err := s.requireSiteView(ctx, siteID); err != nil {
		return nil, searchConsoleStatusOutput{}, err
	}
	status, err := s.searchConsoleStatus(ctx, siteID)
	if err != nil {
		return nil, searchConsoleStatusOutput{}, err
	}
	return nil, status, nil
}

func (s *service) getSearchConsole(ctx context.Context, _ *mcp.CallToolRequest, input searchConsoleInput) (*mcp.CallToolResult, searchConsoleOutput, error) {
	siteID, start, end, err := s.parseSiteRange(input.SiteID, input.rangeInput)
	if err != nil {
		return nil, searchConsoleOutput{}, err
	}
	if _, err := s.requireSiteView(ctx, siteID); err != nil {
		return nil, searchConsoleOutput{}, err
	}
	status, err := s.searchConsoleStatus(ctx, siteID)
	if err != nil {
		return nil, searchConsoleOutput{}, err
	}
	if !status.Mapped {
		return nil, searchConsoleOutput{}, errors.New("Search Console property is not mapped")
	}

	analyticsStore, err := s.analyticsStore(ctx, siteID)
	if err != nil {
		return nil, searchConsoleOutput{}, err
	}
	params := api.SearchConsoleReportParams{
		SiteID:      siteID,
		PropertyURI: status.PropertyURI,
		Start:       start,
		End:         end,
		Page:        strings.TrimSpace(input.Page),
		Path:        strings.TrimSpace(input.Path),
		Country:     strings.TrimSpace(input.Country),
		Device:      strings.TrimSpace(input.Device),
		Limit:       normalizeSearchConsoleLimit(input.Limit),
	}
	output := searchConsoleOutput{
		SiteID:      siteID.String(),
		From:        formatMCPTime(start),
		To:          formatMCPTime(end),
		PropertyURI: status.PropertyURI,
		SyncStatus:  status.SyncStatus,
		Warnings:    searchConsoleWarnings(status, start, end),
	}
	sections, err := parseSearchConsoleSections(input.Sections)
	if err != nil {
		return nil, searchConsoleOutput{}, err
	}
	if err := loadSearchConsoleSections(ctx, analyticsStore, params, sections, &output); err != nil {
		return nil, searchConsoleOutput{}, err
	}
	return nil, output, nil
}

func (s *service) searchConsoleStatus(ctx context.Context, siteID uuid.UUID) (searchConsoleStatusOutput, error) {
	teamID, err := s.store.GetSiteTenantID(ctx, siteID)
	if err != nil {
		return searchConsoleStatusOutput{}, err
	}
	output := searchConsoleStatusOutput{
		SiteID: siteID.String(),
		TeamID: teamID.String(),
		Reason: "unmapped",
	}
	mapping, err := s.store.GetGoogleSearchConsoleSiteMappingForTeam(ctx, siteID, teamID)
	if err != nil {
		return searchConsoleStatusOutput{}, err
	}
	if mapping == nil {
		return output, nil
	}
	output.Mapped = true
	output.PropertyURI = mapping.PropertyURI
	if err := s.applySearchConsoleProperty(ctx, teamID, mapping.PropertyURI, &output); err != nil {
		return searchConsoleStatusOutput{}, err
	}
	if err := s.applySearchConsoleSyncState(ctx, siteID, &output); err != nil {
		return searchConsoleStatusOutput{}, err
	}
	output.Reason = searchConsoleStatusReason(output)
	return output, nil
}

func (s *service) applySearchConsoleProperty(ctx context.Context, teamID uuid.UUID, propertyURI string, output *searchConsoleStatusOutput) error {
	property, err := s.store.GetGoogleSearchConsoleProperty(ctx, teamID, propertyURI)
	if err != nil {
		return err
	}
	if property != nil {
		output.PropertyPermissionLevel = property.PermissionLevel
	}
	return nil
}

func (s *service) applySearchConsoleSyncState(ctx context.Context, siteID uuid.UUID, output *searchConsoleStatusOutput) error {
	state, err := s.store.GetGoogleSearchConsoleSyncState(ctx, siteID)
	if err != nil {
		return err
	}
	output.SyncStatus = toMCPSearchConsoleSyncStatus(state)
	output.DataAvailable = state != nil && state.ImportedStartDate != nil && state.ImportedEndDate != nil
	if output.DataAvailable {
		output.AvailableFrom = formatMCPDate(*state.ImportedStartDate)
		output.AvailableTo = formatMCPDate(*state.ImportedEndDate)
	}
	output.NeedsAttention = state != nil && state.State == "needs_attention"
	return nil
}

func searchConsoleStatusReason(status searchConsoleStatusOutput) string {
	switch {
	case !status.Mapped:
		return "unmapped"
	case status.NeedsAttention:
		return "needs_attention"
	case searchConsoleSyncState(status) == "failed":
		return "failed"
	case !status.DataAvailable:
		return "not_synced"
	default:
		return "ready"
	}
}

func normalizeSearchConsoleLimit(limit int) int {
	if limit <= 0 {
		return defaultLimit
	}
	if limit > maxLimit {
		return maxLimit
	}
	return limit
}

func searchConsoleWarnings(status searchConsoleStatusOutput, start, end time.Time) []string {
	warnings := make([]string, 0, 3)
	if status.NeedsAttention {
		warnings = append(warnings, "search_console_sync_needs_attention")
	}
	if searchConsoleSyncState(status) == "failed" {
		warnings = append(warnings, "search_console_sync_failed")
	}
	if !status.DataAvailable {
		return append(warnings, "search_console_data_not_synced")
	}
	if status.AvailableFrom != "" && formatMCPDate(start) < status.AvailableFrom {
		warnings = append(warnings, "requested_range_starts_before_imported_data")
	}
	if status.AvailableTo != "" && formatMCPDate(end) > status.AvailableTo {
		warnings = append(warnings, "requested_range_ends_after_imported_data")
	}
	return warnings
}

func searchConsoleSyncState(status searchConsoleStatusOutput) string {
	if status.SyncStatus == nil {
		return ""
	}
	return status.SyncStatus.State
}

type searchConsoleReportStore interface {
	GetSearchConsoleOverview(context.Context, api.SearchConsoleReportParams) (api.SearchConsoleOverview, error)
	GetSearchConsoleSeries(context.Context, api.SearchConsoleReportParams) (api.SearchConsoleSeriesResponse, error)
	GetSearchConsoleDimension(context.Context, api.SearchConsoleReportParams, string) (api.SearchConsoleDimensionResponse, error)
}

func loadSearchConsoleSections(ctx context.Context, store searchConsoleReportStore, params api.SearchConsoleReportParams, sections map[string]bool, output *searchConsoleOutput) error {
	loaders := map[string]func() error{
		"overview": func() error { return loadSearchConsoleOverview(ctx, store, params, output) },
		"series":   func() error { return loadSearchConsoleSeries(ctx, store, params, output) },
		"queries":  func() error { return loadSearchConsoleDimension(ctx, store, params, output, "query") },
		"pages":    func() error { return loadSearchConsoleDimension(ctx, store, params, output, "page") },
		"country":  func() error { return loadSearchConsoleDimension(ctx, store, params, output, "country") },
		"device":   func() error { return loadSearchConsoleDimension(ctx, store, params, output, "device") },
	}
	for section := range sections {
		if err := loaders[section](); err != nil {
			return err
		}
	}
	return nil
}

func loadSearchConsoleOverview(ctx context.Context, store searchConsoleReportStore, params api.SearchConsoleReportParams, output *searchConsoleOutput) error {
	overview, err := store.GetSearchConsoleOverview(ctx, params)
	if err != nil {
		return err
	}
	output.Overview = &overview
	return nil
}

func loadSearchConsoleSeries(ctx context.Context, store searchConsoleReportStore, params api.SearchConsoleReportParams, output *searchConsoleOutput) error {
	series, err := store.GetSearchConsoleSeries(ctx, params)
	if err != nil {
		return err
	}
	output.Series = toMCPSearchConsoleSeries(series)
	return nil
}

func loadSearchConsoleDimension(ctx context.Context, store searchConsoleReportStore, params api.SearchConsoleReportParams, output *searchConsoleOutput, dimension string) error {
	rows, err := store.GetSearchConsoleDimension(ctx, params, dimension)
	if err != nil {
		return err
	}
	switch dimension {
	case "query":
		output.Queries = &rows
	case "page":
		output.Pages = &rows
	case "country":
		output.Country = &rows
	case "device":
		output.Device = &rows
	}
	return nil
}

func parseSearchConsoleSections(input []string) (map[string]bool, error) {
	if len(input) == 0 {
		return map[string]bool{"overview": true, "series": true}, nil
	}
	sections := make(map[string]bool, len(input))
	for _, section := range input {
		normalized := strings.ToLower(strings.TrimSpace(section))
		switch normalized {
		case "overview", "series", "queries", "pages", "country", "device":
			sections[normalized] = true
		default:
			return nil, fmt.Errorf("invalid Search Console section %q", section)
		}
	}
	return sections, nil
}

func (s *service) searchDocs(ctx context.Context, _ *mcp.CallToolRequest, input docQueryInput) (*mcp.CallToolResult, docSearchOutput, error) {
	if s.docs == nil {
		return nil, docSearchOutput{}, errors.New("MCP docs tools are disabled")
	}
	results, err := s.docs.Search(ctx, input.Query, normalizeLimit(input.Limit))
	return nil, docSearchOutput{Results: results}, err
}

func (s *service) getDoc(ctx context.Context, _ *mcp.CallToolRequest, input docPathInput) (*mcp.CallToolResult, docOutput, error) {
	return s.fetchDoc(ctx, input.Path)
}

func (s *service) getAPIReference(ctx context.Context, _ *mcp.CallToolRequest, input apiReferenceInput) (*mcp.CallToolResult, docOutput, error) {
	path := strings.TrimSpace(input.PathOrOperation)
	if path == "" {
		return nil, docOutput{}, errors.New("path_or_operation is required")
	}
	if !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
		path = "/api/operations/" + strings.Trim(path, "/") + "/"
	}
	return s.fetchDoc(ctx, path)
}

func (s *service) getMCPHelp(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, docOutput, error) {
	markdown := mcpHelpMarkdown()
	return nil, docOutput{URL: "hitkeep://help/mcp", Path: "hitkeep://help/mcp", Markdown: markdown}, nil
}

func (s *service) fetchDoc(ctx context.Context, path string) (*mcp.CallToolResult, docOutput, error) {
	if s.docs == nil {
		return nil, docOutput{}, errors.New("MCP docs tools are disabled")
	}
	page, err := s.docs.GetMarkdown(ctx, path)
	if err != nil {
		return nil, docOutput{}, err
	}
	return nil, docOutput(page), nil
}

func (s *service) parseSiteRange(rawSiteID string, input rangeInput) (uuid.UUID, time.Time, time.Time, error) {
	siteID, err := uuid.Parse(strings.TrimSpace(rawSiteID))
	if err != nil {
		return uuid.Nil, time.Time{}, time.Time{}, errors.New("invalid site_id")
	}
	start, end, err := parseRange(input, s.conf.MCPMaxRangeDays)
	return siteID, start, end, err
}

func parseRange(input rangeInput, maxDays int) (time.Time, time.Time, error) {
	end := time.Now().UTC()
	start := end.AddDate(0, 0, -defaultRangeDays)
	if strings.TrimSpace(input.To) != "" {
		parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(input.To))
		if err != nil {
			return time.Time{}, time.Time{}, errors.New("invalid to timestamp, expected RFC3339")
		}
		end = parsed
	}
	if strings.TrimSpace(input.From) != "" {
		parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(input.From))
		if err != nil {
			return time.Time{}, time.Time{}, errors.New("invalid from timestamp, expected RFC3339")
		}
		start = parsed
	} else if strings.TrimSpace(input.To) != "" {
		start = end.AddDate(0, 0, -defaultRangeDays)
	}
	if !end.After(start) {
		return time.Time{}, time.Time{}, errors.New("to must be after from")
	}
	if maxDays <= 0 {
		maxDays = 366
	}
	if end.Sub(start) > time.Duration(maxDays)*24*time.Hour {
		return time.Time{}, time.Time{}, fmt.Errorf("date range exceeds %d days", maxDays)
	}
	return start.UTC(), end.UTC(), nil
}

func parseExplicitRange(from, to string, maxDays int) (time.Time, time.Time, error) {
	if strings.TrimSpace(from) == "" || strings.TrimSpace(to) == "" {
		return time.Time{}, time.Time{}, errors.New("compare_from and compare_to are required together")
	}
	start, err := time.Parse(time.RFC3339, strings.TrimSpace(from))
	if err != nil {
		return time.Time{}, time.Time{}, errors.New("invalid compare_from timestamp, expected RFC3339")
	}
	end, err := time.Parse(time.RFC3339, strings.TrimSpace(to))
	if err != nil {
		return time.Time{}, time.Time{}, errors.New("invalid compare_to timestamp, expected RFC3339")
	}
	if !end.After(start) {
		return time.Time{}, time.Time{}, errors.New("compare_to must be after compare_from")
	}
	if maxDays <= 0 {
		maxDays = 366
	}
	if end.Sub(start) > time.Duration(maxDays)*24*time.Hour {
		return time.Time{}, time.Time{}, fmt.Errorf("comparison date range exceeds %d days", maxDays)
	}
	return start.UTC(), end.UTC(), nil
}

func parseFilters(inputs []filterInput) ([]api.Filter, error) {
	if len(inputs) == 0 {
		return nil, nil
	}
	filters := make([]api.Filter, 0, len(inputs))
	for _, input := range inputs {
		filterType := strings.ToLower(strings.TrimSpace(input.Type))
		filterValue := strings.TrimSpace(input.Value)
		if filterType == "" || filterValue == "" {
			return nil, errors.New("filter type and value are required together")
		}
		if !isAllowedFilter(filterType) {
			return nil, fmt.Errorf("invalid filter type %q", filterType)
		}
		filters = append(filters, api.Filter{Type: filterType, Value: filterValue})
	}
	return filters, nil
}
