package tianyancha

import (
	"context"
	"encoding/json"
	"math/big"
	"net/url"
	"strings"

	"github.com/tidbcloud/AgentTeam/internal/extension/runner"
)

// actionPathByName maps every catalog action onto its Open API route.
var actionPathByName = map[string]string{
	ActionSearchCompanies:                   "/services/open/search/2.0",
	ActionSearchCompaniesAdvanced:           "/services/open/searchx",
	ActionGetCompanyBasicInfo:               "/services/open/ic/baseinfo/normal",
	ActionVerifyCompanyIdentity:             "/services/open/ic/verify/2.0",
	ActionGetCompanyContactInfo:             "/services/open/ic/contact",
	ActionListCompanyKeyPersonnel:           "/services/open/ic/staff/2.0",
	ActionListCompanyShareholders:           "/services/open/ic/holder/2.0",
	ActionListCompanyInvestments:            "/services/open/ic/inverst/2.0",
	ActionListCompanyEquityChanges:          "/services/open/ic/holderChange/2.0",
	ActionListCompanyHistoricalShareholders: "/services/open/hi/holder/2.0",
	ActionListCompanyHistoricalInvestments:  "/services/open/hi/invest/2.0",
	ActionListCompanyBranches:               "/services/open/ic/branch/2.0",
	ActionListCompanyChanges:                "/services/open/ic/changeinfo/2.0",
	ActionGetCompanyAnnualReports:           "/services/open/ic/annualreport/2.0",
	ActionGetCompanyRisk:                    "/services/open/risk/riskInfo/2.0",
	ActionGetCompanyRiskDetail:              "/services/v4/open/riskDetail",
	ActionGetCompanyJudicialRisk:            "/services/open/cb/judicial/2.0",
	ActionGetCompanyDevelopmentInfo:         "/services/open/cb/development/2.0",
	ActionGetCompanyIntellectualProperty:    "/services/open/cb/ipr/3.0",
	ActionListCompanyUltimateBeneficiaries:  "/services/open/ic/humanholding/2.0",
	ActionGetCompanyActualControl:           "/services/open/ic/actualControl/3.0",
	ActionFindCompanyRelationshipPaths:      "/services/open/rela/shortPath/2.0",
	ActionSearchTenders:                     "/services/open/m/bids/search",
	ActionListCompanyNews:                   "/services/open/ps/news/2.0",
}

// Enum code tables ported verbatim from the upstream query builder.
var (
	relationshipTypeCode = map[string]string{
		"legal_representative":            "OWN",
		"employment":                      "SERVE_ALL",
		"investment":                      "INVEST",
		"branch":                          "BRANCH",
		"legal":                           "LAW",
		"pledge_and_mortgage":             "EQ",
		"business_cooperation":            "CAC",
		"shared_contact":                  "CI",
		"historical_shareholder":          "HISTORICAL_SHAREHOLDER",
		"historical_employment":           "HISTORICAL_SERVE_ALL",
		"historical_legal_representative": "HISTORICAL_OWN",
		"all":                             "ALL",
	}
	tenderSearchTypeCode = map[string]string{
		"title":     "1",
		"purchaser": "2",
		"supplier":  "3",
	}
	tenderNoticeTypeCode = map[string]string{
		"forecast":     "1",
		"announcement": "2",
		"result":       "4",
	}
)

// Handlers returns the Action dispatch table. The keys must exactly match
// the Definition catalog; bootstrap verifies the coverage.
func Handlers() map[string]runner.RESTHandler {
	return map[string]runner.RESTHandler{
		ActionSearchCompanies:                   handleSearchCompanies,
		ActionSearchCompaniesAdvanced:           handleSearchCompaniesAdvanced,
		ActionGetCompanyBasicInfo:               handleKeywordAction(ActionGetCompanyBasicInfo),
		ActionVerifyCompanyIdentity:             handleVerifyCompanyIdentity,
		ActionGetCompanyContactInfo:             handleKeywordAction(ActionGetCompanyContactInfo),
		ActionListCompanyKeyPersonnel:           handleKeywordPageAction(ActionListCompanyKeyPersonnel),
		ActionListCompanyShareholders:           handleListCompanyShareholders,
		ActionListCompanyInvestments:            handleKeywordPageAction(ActionListCompanyInvestments),
		ActionListCompanyEquityChanges:          handleKeywordPageAction(ActionListCompanyEquityChanges),
		ActionListCompanyHistoricalShareholders: handleKeywordPageAction(ActionListCompanyHistoricalShareholders),
		ActionListCompanyHistoricalInvestments:  handleKeywordPageAction(ActionListCompanyHistoricalInvestments),
		ActionListCompanyBranches:               handleKeywordPageAction(ActionListCompanyBranches),
		ActionListCompanyChanges:                handleKeywordPageAction(ActionListCompanyChanges),
		ActionGetCompanyAnnualReports:           handleGetCompanyAnnualReports,
		ActionGetCompanyRisk:                    handleGetCompanyRisk,
		ActionGetCompanyRiskDetail:              handleGetCompanyRiskDetail,
		ActionGetCompanyJudicialRisk:            handleKeywordAction(ActionGetCompanyJudicialRisk),
		ActionGetCompanyDevelopmentInfo:         handleKeywordAction(ActionGetCompanyDevelopmentInfo),
		ActionGetCompanyIntellectualProperty:    handleKeywordAction(ActionGetCompanyIntellectualProperty),
		ActionListCompanyUltimateBeneficiaries:  handleKeywordPageAction(ActionListCompanyUltimateBeneficiaries),
		ActionGetCompanyActualControl:           handleGetCompanyActualControl,
		ActionFindCompanyRelationshipPaths:      handleFindCompanyRelationshipPaths,
		ActionSearchTenders:                     handleSearchTenders,
		ActionListCompanyNews:                   handleListCompanyNews,
	}
}

// Input shapes. Optional integers decode as json.Number so the integer query
// formatter can preserve their exact value while emitting plain decimal text.
type (
	searchInput struct {
		Keyword  string       `json:"keyword"`
		PageNum  *json.Number `json:"pageNum"`
		PageSize *json.Number `json:"pageSize"`
	}
	advancedSearchInput struct {
		Keyword      string       `json:"keyword"`
		IndustryCode string       `json:"industryCode"`
		AreaCode     string       `json:"areaCode"`
		PageNum      *json.Number `json:"pageNum"`
		PageSize     *json.Number `json:"pageSize"`
	}
	keywordInput struct {
		Keyword string `json:"keyword"`
	}
	verifyInput struct {
		CompanyName      string `json:"companyName"`
		LegalPersonName  string `json:"legalPersonName"`
		RegistrationCode string `json:"registrationCode"`
	}
	keywordPageInput struct {
		Keyword  string       `json:"keyword"`
		PageNum  *json.Number `json:"pageNum"`
		PageSize *json.Number `json:"pageSize"`
		Source   *json.Number `json:"source"`
	}
	annualReportInput struct {
		Keyword string `json:"keyword"`
		Year    string `json:"year"`
	}
	riskDetailInput struct {
		RiskID   json.Number `json:"riskId"`
		RiskType json.Number `json:"riskType"`
		PageNum  json.Number `json:"pageNum"`
		PageSize json.Number `json:"pageSize"`
	}
	relationshipInput struct {
		SourceCompanyName string       `json:"sourceCompanyName"`
		SourceCompanyID   *json.Number `json:"sourceCompanyId"`
		TargetCompanyName string       `json:"targetCompanyName"`
		TargetCompanyID   *json.Number `json:"targetCompanyId"`
		RelationshipTypes []string     `json:"relationshipTypes"`
		MaxDepth          *json.Number `json:"maxDepth"`
	}
	tenderInput struct {
		Keyword          string       `json:"keyword"`
		SearchTypes      []string     `json:"searchTypes"`
		NoticeType       string       `json:"noticeType"`
		Province         string       `json:"province"`
		PublishStartDate string       `json:"publishStartDate"`
		PublishEndDate   string       `json:"publishEndDate"`
		PageNum          *json.Number `json:"pageNum"`
		PageSize         *json.Number `json:"pageSize"`
	}
	newsInput struct {
		CompanyName string       `json:"companyName"`
		CompanyID   *json.Number `json:"companyId"`
		StartDate   string       `json:"startDate"`
		EndDate     string       `json:"endDate"`
		Tags        []string     `json:"tags"`
		PageNum     *json.Number `json:"pageNum"`
		PageSize    *json.Number `json:"pageSize"`
	}
)

func decodeInput(input json.RawMessage, target any) error {
	if len(input) == 0 {
		return nil
	}
	if err := json.Unmarshal(input, target); err != nil {
		return runner.Errorf(runner.ErrorValidation, "action input was not decodable", err)
	}
	return nil
}

func setOptionalNumber(query url.Values, name string, value *json.Number) {
	if value != nil && *value != "" {
		query.Set(name, value.String())
	}
}

func handleSearchCompanies(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed searchInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.Keyword = strings.TrimSpace(parsed.Keyword)
	if err := runner.FormatIntegerQueryValuesInPlace(parsed.PageNum, parsed.PageSize); err != nil {
		return nil, err
	}
	query := url.Values{"word": []string{parsed.Keyword}}
	setOptionalNumber(query, "pageNum", parsed.PageNum)
	setOptionalNumber(query, "pageSize", parsed.PageSize)
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionSearchCompanies], query)
	if err != nil {
		return nil, err
	}
	return pagedOutput("companies", result, parsed.PageNum, parsed.PageSize)
}

func handleSearchCompaniesAdvanced(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed advancedSearchInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.Keyword = strings.TrimSpace(parsed.Keyword)
	parsed.IndustryCode = strings.TrimSpace(parsed.IndustryCode)
	parsed.AreaCode = strings.TrimSpace(parsed.AreaCode)
	if err := runner.FormatIntegerQueryValuesInPlace(parsed.PageNum, parsed.PageSize); err != nil {
		return nil, err
	}
	query := url.Values{"word": []string{parsed.Keyword}}
	if parsed.IndustryCode != "" {
		query.Set("categoryGuobiao", parsed.IndustryCode)
	}
	if parsed.AreaCode != "" {
		query.Set("areaCode", parsed.AreaCode)
	}
	setOptionalNumber(query, "pageNum", parsed.PageNum)
	setOptionalNumber(query, "pageSize", parsed.PageSize)
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionSearchCompaniesAdvanced], query)
	if err != nil {
		return nil, err
	}
	return pagedOutput("companies", result, parsed.PageNum, parsed.PageSize)
}

// handleKeywordAction serves the plain keyword GETs whose result is returned
// under a provider-specific key or null.
func handleKeywordAction(action string) runner.RESTHandler {
	details := map[string]struct {
		outputKey string
		name      string
	}{
		ActionGetCompanyBasicInfo:            {"company", "company"},
		ActionGetCompanyContactInfo:          {"contact", "contact"},
		ActionGetCompanyJudicialRisk:         {"judicialRisk", "judicialRisk"},
		ActionGetCompanyDevelopmentInfo:      {"development", "development"},
		ActionGetCompanyIntellectualProperty: {"intellectualProperty", "intellectualProperty"},
	}[action]
	return func(
		ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
	) (json.RawMessage, error) {
		var parsed keywordInput
		if err := decodeInput(input, &parsed); err != nil {
			return nil, err
		}
		parsed.Keyword = strings.TrimSpace(parsed.Keyword)
		query := url.Values{"keyword": []string{parsed.Keyword}}
		result, err := callAction(ctx, client, credentials, actionPathByName[action], query)
		if err != nil {
			return nil, err
		}
		return rawKeyOutput(details.outputKey, result, details.name)
	}
}

func handleVerifyCompanyIdentity(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed verifyInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.CompanyName = strings.TrimSpace(parsed.CompanyName)
	parsed.LegalPersonName = strings.TrimSpace(parsed.LegalPersonName)
	parsed.RegistrationCode = strings.TrimSpace(parsed.RegistrationCode)
	query := url.Values{
		"name":            []string{parsed.CompanyName},
		"legalPersonName": []string{parsed.LegalPersonName},
		"code":            []string{parsed.RegistrationCode},
	}
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionVerifyCompanyIdentity], query)
	if err != nil {
		return nil, err
	}
	if isNullResult(result) {
		return marshalOutput(map[string]any{"matchStatus": nil, "remark": nil})
	}
	var verification struct {
		Result *json.Number `json:"result"`
		Remark *string      `json:"remark"`
	}
	if err := json.Unmarshal(result, &verification); err != nil || verification.Result == nil {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned invalid company verification data", nil)
	}
	matchStatus, err := formatResponseInteger(verification.Result, "match status", 0, 2)
	if err != nil {
		return nil, err
	}
	return marshalOutput(map[string]any{
		"matchStatus": matchStatus,
		"remark":      nullableString(verification.Remark),
	})
}

func handleListCompanyShareholders(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed keywordPageInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.Keyword = strings.TrimSpace(parsed.Keyword)
	if err := runner.FormatIntegerQueryValuesInPlace(parsed.PageNum, parsed.PageSize, parsed.Source); err != nil {
		return nil, err
	}
	query := url.Values{"keyword": []string{parsed.Keyword}}
	setOptionalNumber(query, "pageNum", parsed.PageNum)
	setOptionalNumber(query, "pageSize", parsed.PageSize)
	setOptionalNumber(query, "source", parsed.Source)
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionListCompanyShareholders], query)
	if err != nil {
		return nil, err
	}
	return pagedOutput("shareholders", result, parsed.PageNum, parsed.PageSize)
}

// handleKeywordPageAction serves the keyword+pagination GETs whose result is
// a standard paged payload.
func handleKeywordPageAction(action string) runner.RESTHandler {
	outputKey := map[string]string{
		ActionListCompanyKeyPersonnel:           "personnel",
		ActionListCompanyInvestments:            "investments",
		ActionListCompanyEquityChanges:          "equityChanges",
		ActionListCompanyHistoricalShareholders: "historicalShareholders",
		ActionListCompanyHistoricalInvestments:  "historicalInvestments",
		ActionListCompanyBranches:               "branches",
		ActionListCompanyChanges:                "changes",
		ActionListCompanyUltimateBeneficiaries:  "beneficiaries",
	}[action]
	return func(
		ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
	) (json.RawMessage, error) {
		var parsed keywordPageInput
		if err := decodeInput(input, &parsed); err != nil {
			return nil, err
		}
		parsed.Keyword = strings.TrimSpace(parsed.Keyword)
		if err := runner.FormatIntegerQueryValuesInPlace(parsed.PageNum, parsed.PageSize); err != nil {
			return nil, err
		}
		query := url.Values{"keyword": []string{parsed.Keyword}}
		setOptionalNumber(query, "pageNum", parsed.PageNum)
		setOptionalNumber(query, "pageSize", parsed.PageSize)
		result, err := callAction(ctx, client, credentials, actionPathByName[action], query)
		if err != nil {
			return nil, err
		}
		return pagedOutput(outputKey, result, parsed.PageNum, parsed.PageSize)
	}
}

func handleGetCompanyAnnualReports(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed annualReportInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.Keyword = strings.TrimSpace(parsed.Keyword)
	parsed.Year = strings.TrimSpace(parsed.Year)
	query := url.Values{"keyword": []string{parsed.Keyword}}
	if parsed.Year != "" {
		query.Set("year", parsed.Year)
	}
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionGetCompanyAnnualReports], query)
	if err != nil {
		return nil, err
	}
	page, err := asPagedResult(result)
	if err != nil {
		return nil, err
	}
	return marshalOutput(map[string]any{
		"annualReports": page.Items,
		"total":         page.Total,
	})
}

func handleGetCompanyRisk(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed keywordInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.Keyword = strings.TrimSpace(parsed.Keyword)
	query := url.Values{"keyword": []string{parsed.Keyword}}
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionGetCompanyRisk], query)
	if err != nil {
		return nil, err
	}
	if isNullResult(result) {
		return marshalOutput(map[string]any{"riskLevel": nil, "risks": []json.RawMessage{}})
	}
	var risk struct {
		RiskLevel *string           `json:"riskLevel"`
		RiskList  []json.RawMessage `json:"riskList"`
	}
	if err := json.Unmarshal(result, &risk); err != nil || risk.RiskList == nil {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned invalid risk data", nil)
	}
	return marshalOutput(map[string]any{
		"riskLevel": nullableString(risk.RiskLevel),
		"risks":     risk.RiskList,
	})
}

func handleGetCompanyRiskDetail(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed riskDetailInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	if err := runner.FormatIntegerQueryValuesInPlace(
		&parsed.RiskID, &parsed.RiskType, &parsed.PageNum, &parsed.PageSize,
	); err != nil {
		return nil, err
	}
	pageNum, pageSize := pageOrDefault(parsed.PageNum, 1), pageOrDefault(parsed.PageSize, 20)
	query := url.Values{"id": []string{parsed.RiskID.String()}, "type": []string{parsed.RiskType.String()}}
	setOptionalNumber(query, "pageNum", optionalNumber(parsed.PageNum))
	setOptionalNumber(query, "pageSize", optionalNumber(parsed.PageSize))
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionGetCompanyRiskDetail], query)
	if err != nil {
		return nil, err
	}
	if isNullResult(result) {
		return marshalOutput(map[string]any{
			"riskType": parsed.RiskType,
			"records":  []json.RawMessage{},
			"total":    json.Number("0"),
			"pageNum":  pageNum,
			"pageSize": pageSize,
		})
	}
	var detail struct {
		Count    *json.Number      `json:"count"`
		Type     *json.Number      `json:"type"`
		DataList []json.RawMessage `json:"dataList"`
		PN       *json.Number      `json:"pn"`
	}
	if err := json.Unmarshal(result, &detail); err != nil ||
		detail.Count == nil || detail.Type == nil || detail.DataList == nil {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned invalid risk detail data", nil)
	}
	riskType, err := formatResponseInteger(detail.Type, "risk type", 1)
	if err != nil {
		return nil, err
	}
	count, err := formatResponseInteger(detail.Count, "risk count", 0)
	if err != nil {
		return nil, err
	}
	returnedPageNum := pageNum
	if detail.PN != nil {
		formatted, err := formatResponseInteger(detail.PN, "risk page number", 1)
		if err != nil {
			return nil, err
		}
		returnedPageNum = formatted
	}
	return marshalOutput(map[string]any{
		"riskType": riskType,
		"records":  detail.DataList,
		"total":    count,
		"pageNum":  returnedPageNum,
		"pageSize": pageSize,
	})
}

func handleGetCompanyActualControl(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed keywordInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.Keyword = strings.TrimSpace(parsed.Keyword)
	query := url.Values{"keyword": []string{parsed.Keyword}}
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionGetCompanyActualControl], query)
	if err != nil {
		return nil, err
	}
	if isNullResult(result) {
		return marshalOutput(map[string]any{
			"controllers": []json.RawMessage{},
			"paths":       map[string]json.RawMessage{},
		})
	}
	var control struct {
		ActualControllerList []json.RawMessage          `json:"actualControllerList"`
		PathMap              map[string]json.RawMessage `json:"pathMap"`
	}
	if err := json.Unmarshal(result, &control); err != nil {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned invalid actual-control data", nil)
	}
	controllers := control.ActualControllerList
	if controllers == nil {
		controllers = []json.RawMessage{}
	}
	paths := control.PathMap
	if paths == nil {
		paths = map[string]json.RawMessage{}
	}
	return marshalOutput(map[string]any{
		"controllers": controllers,
		"paths":       paths,
	})
}

func handleFindCompanyRelationshipPaths(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed relationshipInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.SourceCompanyName = strings.TrimSpace(parsed.SourceCompanyName)
	parsed.TargetCompanyName = strings.TrimSpace(parsed.TargetCompanyName)
	if err := runner.FormatIntegerQueryValuesInPlace(
		parsed.SourceCompanyID, parsed.TargetCompanyID, parsed.MaxDepth,
	); err != nil {
		return nil, err
	}
	if parsed.SourceCompanyName == "" && parsed.SourceCompanyID == nil {
		return nil, runner.Errorf(
			runner.ErrorValidation, "sourceCompanyName or sourceCompanyId is required", nil)
	}
	if parsed.TargetCompanyName == "" && parsed.TargetCompanyID == nil {
		return nil, runner.Errorf(
			runner.ErrorValidation, "targetCompanyName or targetCompanyId is required", nil)
	}
	query := url.Values{}
	if parsed.SourceCompanyName != "" {
		query.Set("nameFrom", parsed.SourceCompanyName)
	}
	if parsed.SourceCompanyID != nil {
		query.Set("idFrom", parsed.SourceCompanyID.String())
	}
	if parsed.TargetCompanyName != "" {
		query.Set("nameTo", parsed.TargetCompanyName)
	}
	if parsed.TargetCompanyID != nil {
		query.Set("idTo", parsed.TargetCompanyID.String())
	}
	if codes, err := mapEnumList(parsed.RelationshipTypes, relationshipTypeCode, "relationshipTypes"); err != nil {
		return nil, err
	} else if codes != "" {
		query.Set("types", codes)
	}
	setOptionalNumber(query, "depth", parsed.MaxDepth)
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionFindCompanyRelationshipPaths], query)
	if err != nil {
		return nil, err
	}
	if isNullResult(result) {
		return marshalOutput(map[string]any{"hasRelationship": false, "paths": []json.RawMessage{}})
	}
	relationship, err := asResultObject(result, "company relationship")
	if err != nil {
		return nil, err
	}
	paths := make([]json.RawMessage, 0, len(relationship))
	for name, value := range relationship {
		if !strings.HasPrefix(name, "p_") {
			continue
		}
		var path map[string]json.RawMessage
		if err := json.Unmarshal(value, &path); err != nil || path == nil {
			continue
		}
		paths = append(paths, value)
	}
	return marshalOutput(map[string]any{
		"hasRelationship": len(paths) > 0,
		"paths":           paths,
	})
}

func handleSearchTenders(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed tenderInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.Keyword = strings.TrimSpace(parsed.Keyword)
	parsed.Province = strings.TrimSpace(parsed.Province)
	if err := runner.FormatIntegerQueryValuesInPlace(parsed.PageNum, parsed.PageSize); err != nil {
		return nil, err
	}
	query := url.Values{"keyword": []string{parsed.Keyword}}
	if codes, err := mapEnumList(parsed.SearchTypes, tenderSearchTypeCode, "searchTypes"); err != nil {
		return nil, err
	} else if codes != "" {
		query.Set("searchType", codes)
	}
	if parsed.NoticeType != "" {
		code, ok := tenderNoticeTypeCode[parsed.NoticeType]
		if !ok {
			return nil, runner.Errorf(
				runner.ErrorValidation, "noticeType is not a valid tender notice type", nil)
		}
		query.Set("type", code)
	}
	if parsed.Province != "" {
		query.Set("province", parsed.Province)
	}
	if parsed.PublishStartDate != "" {
		query.Set("publishStartTime", parsed.PublishStartDate)
	}
	if parsed.PublishEndDate != "" {
		query.Set("publishEndTime", parsed.PublishEndDate)
	}
	setOptionalNumber(query, "pageNum", parsed.PageNum)
	setOptionalNumber(query, "pageSize", parsed.PageSize)
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionSearchTenders], query)
	if err != nil {
		return nil, err
	}
	return pagedOutput("tenders", result, parsed.PageNum, parsed.PageSize)
}

func handleListCompanyNews(
	ctx context.Context, client *runner.RESTClient, credentials runner.Credentials, input json.RawMessage,
) (json.RawMessage, error) {
	var parsed newsInput
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	parsed.CompanyName = strings.TrimSpace(parsed.CompanyName)
	if err := runner.FormatIntegerQueryValuesInPlace(parsed.CompanyID, parsed.PageNum, parsed.PageSize); err != nil {
		return nil, err
	}
	if parsed.CompanyName == "" && parsed.CompanyID == nil {
		return nil, runner.Errorf(
			runner.ErrorValidation, "companyName or companyId is required", nil)
	}
	query := url.Values{}
	if parsed.CompanyName != "" {
		query.Set("name", parsed.CompanyName)
	}
	if parsed.CompanyID != nil {
		query.Set("id", parsed.CompanyID.String())
	}
	if parsed.StartDate != "" {
		query.Set("startTime", strings.ReplaceAll(parsed.StartDate, "-", ""))
	}
	if parsed.EndDate != "" {
		query.Set("endTime", strings.ReplaceAll(parsed.EndDate, "-", ""))
	}
	if len(parsed.Tags) > 0 {
		query.Set("tags", strings.Join(parsed.Tags, ","))
	}
	setOptionalNumber(query, "pageNum", parsed.PageNum)
	setOptionalNumber(query, "pageSize", parsed.PageSize)
	result, err := callAction(ctx, client, credentials, actionPathByName[ActionListCompanyNews], query)
	if err != nil {
		return nil, err
	}
	return pagedOutput("news", result, parsed.PageNum, parsed.PageSize)
}

// mapEnumList maps a validated enum list onto its comma-joined wire codes and
// fails closed on a value the schema should have rejected.
func mapEnumList(
	values []string, table map[string]string, field string,
) (string, error) {
	if len(values) == 0 {
		return "", nil
	}
	codes := make([]string, 0, len(values))
	for _, value := range values {
		code, ok := table[value]
		if !ok {
			return "", runner.Errorf(
				runner.ErrorValidation, field+" contains an unknown value", nil)
		}
		codes = append(codes, code)
	}
	return strings.Join(codes, ","), nil
}

func optionalNumber(value json.Number) *json.Number {
	if value == "" {
		return nil
	}
	return &value
}

func pageOrDefault(value json.Number, fallback int) any {
	if value == "" {
		return fallback
	}
	return value
}

func nullableString(value *string) any {
	if value == nil {
		return nil
	}
	return *value
}

func isNullResult(result json.RawMessage) bool {
	return string(result) == "null"
}

// rawKeyOutput wraps one passthrough result under its provider-specific key;
// a null result forwards null and a non-object result fails closed.
func rawKeyOutput(key string, result json.RawMessage, name string) (json.RawMessage, error) {
	if isNullResult(result) {
		return marshalOutput(map[string]any{key: nil})
	}
	if _, err := asResultObject(result, name); err != nil {
		return nil, err
	}
	return marshalOutput(map[string]any{key: result})
}

func asResultObject(result json.RawMessage, name string) (map[string]json.RawMessage, error) {
	var object map[string]json.RawMessage
	if err := json.Unmarshal(result, &object); err != nil || object == nil {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure,
			"tianyancha returned invalid "+name+" data", nil)
	}
	return object, nil
}

type pagedPayload struct {
	Items []json.RawMessage
	Total json.Number
}

// formatResponseInteger accepts an exact JSON-number integer from Tianyancha,
// enforces the output schema range, and returns plain decimal JSON. It is
// intentionally response-specific rather than reusing the query formatter:
// a provider payload violation is upstream_failure, not input validation.
func formatResponseInteger(
	value *json.Number, field string, minimum int64, maximum ...int64,
) (json.Number, error) {
	if value == nil {
		return "", runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned invalid "+field, nil)
	}
	var lexical json.Number
	if err := json.Unmarshal([]byte(value.String()), &lexical); err != nil {
		return "", runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned invalid "+field, nil)
	}
	var rational big.Rat
	if _, ok := rational.SetString(lexical.String()); !ok || !rational.IsInt() {
		return "", runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned invalid "+field, nil)
	}
	integer := rational.Num()
	if integer.Cmp(big.NewInt(minimum)) < 0 ||
		(len(maximum) == 1 && integer.Cmp(big.NewInt(maximum[0])) > 0) {
		return "", runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned out-of-range "+field, nil)
	}
	return json.Number(integer.String()), nil
}

// asPagedResult decodes the standard {items, total} page. A null result is
// the empty page; anything malformed fails closed.
func asPagedResult(result json.RawMessage) (pagedPayload, error) {
	if isNullResult(result) {
		return pagedPayload{Items: []json.RawMessage{}, Total: json.Number("0")}, nil
	}
	var payload struct {
		Items []json.RawMessage `json:"items"`
		Total *json.Number      `json:"total"`
	}
	if err := json.Unmarshal(result, &payload); err != nil || payload.Items == nil || payload.Total == nil {
		return pagedPayload{}, runner.Errorf(
			runner.ErrorUpstreamFailure, "tianyancha returned invalid paginated data", nil)
	}
	total, err := formatResponseInteger(payload.Total, "paginated total", 0)
	if err != nil {
		return pagedPayload{}, err
	}
	return pagedPayload{Items: payload.Items, Total: total}, nil
}

// pagedOutput composes one keyed page with the pagination echo.
func pagedOutput(
	key string, result json.RawMessage, pageNum, pageSize *json.Number,
) (json.RawMessage, error) {
	page, err := asPagedResult(result)
	if err != nil {
		return nil, err
	}
	defaultPageNum, defaultPageSize := any(1), any(20)
	if pageNum != nil {
		defaultPageNum = *pageNum
	}
	if pageSize != nil {
		defaultPageSize = *pageSize
	}
	return marshalOutput(map[string]any{
		key:        page.Items,
		"total":    page.Total,
		"pageNum":  defaultPageNum,
		"pageSize": defaultPageSize,
	})
}

func marshalOutput(output map[string]any) (json.RawMessage, error) {
	encoded, err := json.Marshal(output)
	if err != nil {
		return nil, runner.Errorf(runner.ErrorUpstreamFailure, "action output was not encodable", err)
	}
	return encoded, nil
}
