package connectors

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"strings"

	"github.com/pingcap-inc/nexuscrm-connectors/features/connector/callout"
)

type DirectoryPullRequest struct {
	RequesterOwnerPack string
	ProfileOwnerPack   string
	ProfileName        string
	ObjectKey          string
	ItemsPath          string
	MaxItems           int
}

type DirectoryReader interface {
	PullDirectory(ctx context.Context, req DirectoryPullRequest) ([]map[string]interface{}, error)
}

type ProfileDependencyAuthorizer func(ctx context.Context, db *sql.DB, requesterOwnerPack, profileOwnerPack string) error

// directoryReaderAdapter composes connector_callout.Engine and the profile loader.
type directoryReaderAdapter struct {
	db        *sql.DB
	engine    *connector_callout.Engine
	authorize ProfileDependencyAuthorizer
}

// NewDirectoryReaderAdapter constructs a DirectoryReader that pulls connector
// directories via the shared connector_callout.Engine.
// db is used to load the ConnectorCalloutProfile at call time.
// engine is used to execute the callout. Both must be non-nil.
func NewDirectoryReaderAdapter(db *sql.DB, engine *connector_callout.Engine, authorize ProfileDependencyAuthorizer) DirectoryReader {
	return &directoryReaderAdapter{
		db:        db,
		engine:    engine,
		authorize: authorize,
	}
}

// PullDirectory enforces pack authority via companion-consent, loads the active
// profile for (req.ProfileOwnerPack, req.ProfileName), resolves objectKey to a
// relative path via ObjectPathMap, issues a GET (or paginated GET if PageSpec is
// configured), extracts the items array at itemsPath, and returns all items
// as raw maps. All-or-nothing: any error returns nil slice.
//
// Same-pack pulls pass immediately (no DB required). Cross-pack pulls require
// an active companion-consent dependency row — mirroring the authority model
// used by connector push (see authorizeDirectoryProfileDependency doc comment).
func (a *directoryReaderAdapter) PullDirectory(ctx context.Context, req DirectoryPullRequest) ([]map[string]interface{}, error) {
	// Argument validation.
	if strings.TrimSpace(req.RequesterOwnerPack) == "" {
		return nil, fmt.Errorf("directory reader: RequesterOwnerPack is required")
	}
	if strings.TrimSpace(req.ProfileOwnerPack) == "" {
		return nil, fmt.Errorf("directory reader: ProfileOwnerPack is required")
	}

	// Pack authority: same-pack passes immediately; cross-pack requires an active
	// companion-consent dependency row in _System_InstalledPackDependency.
	if err := a.authorizeDirectoryProfileDependency(ctx, req.RequesterOwnerPack, req.ProfileOwnerPack); err != nil {
		return nil, fmt.Errorf("directory reader: %w", err)
	}

	// Load the active-install profile.  LoadActiveProfile rejects stale rows
	// from older installs, giving exact active-install authority.
	profile, err := connector_callout.LoadActiveProfile(ctx, a.db, req.ProfileOwnerPack, req.ProfileName)
	if err != nil {
		return nil, fmt.Errorf("directory reader: load profile %s/%s: %w", req.ProfileOwnerPack, req.ProfileName, err)
	}

	relativePath, ok := profile.ObjectPathMap[req.ObjectKey]
	if !ok || strings.TrimSpace(relativePath) == "" {
		return nil, fmt.Errorf("directory reader: objectKey %q not found in ObjectPathMap for profile %s/%s", req.ObjectKey, req.ProfileOwnerPack, req.ProfileName)
	}

	callReq := connector_callout.CalloutRequest{
		Method:        "GET",
		RelativePath:  strings.TrimSpace(relativePath),
		CredentialRef: profile.CredentialRef,
		EndpointClass: profile.EndpointClass,
	}

	var allItems []map[string]interface{}

	if profile.PageSpec.NextCursorJSONPath != "" {
		// Paginated directory pull: accumulate items across all pages.
		reader, err := a.engine.Paginate(ctx, callReq, profile.PageSpec, profile.RetryPolicy)
		if err != nil {
			return nil, fmt.Errorf("directory reader: paginate %s/%s/%s: %w", req.ProfileOwnerPack, req.ProfileName, req.ObjectKey, err)
		}
		allItems, err = accumulateDirectoryPages(req.ItemsPath, req.MaxItems, func() ([]byte, bool, error) {
			resp, hasMore, err := reader.Next(ctx)
			if err != nil {
				return nil, false, fmt.Errorf("directory reader: page error for %s/%s/%s: %w", req.ProfileOwnerPack, req.ProfileName, req.ObjectKey, err)
			}
			return resp.Body, hasMore, nil
		})
		if err != nil {
			return nil, err
		}
	} else {
		// Single-page directory pull.
		resp, err := a.engine.Do(ctx, callReq, profile.RetryPolicy)
		if err != nil {
			return nil, fmt.Errorf("directory reader: do %s/%s/%s: %w", req.ProfileOwnerPack, req.ProfileName, req.ObjectKey, err)
		}
		allItems, err = extractDirectoryItems(resp.Body, req.ItemsPath)
		if err != nil {
			return nil, fmt.Errorf("directory reader: extract items for %s/%s/%s: %w", req.ProfileOwnerPack, req.ProfileName, req.ObjectKey, err)
		}
		// Cap check for single-page path.
		if req.MaxItems > 0 && len(allItems) > req.MaxItems {
			return nil, fmt.Errorf("directory reader: item count exceeds MaxItems cap %d", req.MaxItems)
		}
	}

	if allItems == nil {
		allItems = []map[string]interface{}{}
	}
	return allItems, nil
}

// extractDirectoryItems parses a JSON response body and extracts the array of
// item objects at the dotted itemsPath. An empty path means the root value is
// the array itself. Each element must be a JSON object; non-object elements
// fail closed. Mirrors extractBatchResponseRecords in connector_inbound.
func extractDirectoryItems(body []byte, itemsPath string) ([]map[string]interface{}, error) {
	var root interface{}
	dec := json.NewDecoder(bytes.NewReader(body))
	dec.UseNumber() // preserve exact numeric representations
	if err := dec.Decode(&root); err != nil {
		return nil, fmt.Errorf("%w: decode response body: %v", connector_callout.ErrInvalidResponse, err)
	}

	current := root
	if strings.TrimSpace(itemsPath) != "" {
		var err error
		current, err = directoryExtractDottedValue(current, itemsPath)
		if err != nil {
			return nil, err
		}
	}

	arr, ok := current.([]interface{})
	if !ok {
		return nil, fmt.Errorf("%w: items path %q resolved to %T, want array", connector_callout.ErrInvalidResponse, itemsPath, current)
	}
	items := make([]map[string]interface{}, 0, len(arr))
	for i, elem := range arr {
		obj, ok := elem.(map[string]interface{})
		if !ok {
			return nil, fmt.Errorf("%w: items path %q element %d resolved to %T, want object", connector_callout.ErrInvalidResponse, itemsPath, i, elem)
		}
		items = append(items, obj)
	}
	return items, nil
}

// authorizeDirectoryProfileDependency enforces pack authority for directory
// pulls by delegating to the shared connector_webhooks.AuthorizeCrossPackProfileDependency.
// Same-pack passes immediately; cross-pack requires an active companion-consent row.
func (a *directoryReaderAdapter) authorizeDirectoryProfileDependency(ctx context.Context, requesterOwnerPack, profileOwnerPack string) error {
	requesterOwnerPack = strings.TrimSpace(requesterOwnerPack)
	profileOwnerPack = strings.TrimSpace(profileOwnerPack)
	if requesterOwnerPack == "" {
		return fmt.Errorf("connector directory profile dependency: requester owner pack is required")
	}
	if profileOwnerPack == "" {
		return fmt.Errorf("connector directory profile dependency: profile owner pack is required")
	}
	if strings.EqualFold(requesterOwnerPack, profileOwnerPack) {
		return nil
	}
	if a.db == nil {
		return fmt.Errorf("connector directory profile dependency: db is required for cross-pack profile %s -> %s", requesterOwnerPack, profileOwnerPack)
	}
	if a.authorize == nil {
		return fmt.Errorf("connector directory profile dependency: authorizer is required for cross-pack profile %s -> %s", requesterOwnerPack, profileOwnerPack)
	}
	if err := a.authorize(ctx, a.db, requesterOwnerPack, profileOwnerPack); err != nil {
		return fmt.Errorf("connector directory profile dependency: %w", err)
	}
	return nil
}

// accumulateDirectoryPages walks pages via next() until it reports no more,
// extracting items at itemsPath. Fails closed if a page reports more pages but
// contributes zero items (a stalled/rotating-cursor connector would otherwise
// loop unbounded), or if the accumulated count exceeds maxItems.
func accumulateDirectoryPages(itemsPath string, maxItems int, next func() (body []byte, hasMore bool, err error)) ([]map[string]interface{}, error) {
	var allItems []map[string]interface{}
	for {
		body, hasMore, err := next()
		if err != nil {
			return nil, err
		}
		pageItems, err := extractDirectoryItems(body, itemsPath)
		if err != nil {
			return nil, err
		}
		if hasMore && len(pageItems) == 0 {
			return nil, fmt.Errorf("directory reader: pagination stalled: hasMore=true but page returned 0 items")
		}
		allItems = append(allItems, pageItems...)
		if maxItems > 0 && len(allItems) > maxItems {
			return nil, fmt.Errorf("directory reader: item count exceeds MaxItems cap %d", maxItems)
		}
		if !hasMore {
			break
		}
	}
	if allItems == nil {
		allItems = []map[string]interface{}{}
	}
	return allItems, nil
}

// directoryExtractDottedValue walks a dotted path (e.g. "data.records") in a
// parsed JSON value, returning the value at the final segment. Mirrors the
// extractDottedValue helper in connector_inbound, kept local to avoid importing
// that unexported symbol.
func directoryExtractDottedValue(root interface{}, path string) (interface{}, error) {
	current := root
	for i, seg := range strings.Split(path, ".") {
		obj, ok := current.(map[string]interface{})
		if !ok {
			return nil, fmt.Errorf("%w: path %q segment %d (%q) descends into non-object %T", connector_callout.ErrInvalidResponse, path, i+1, seg, current)
		}
		next, exists := obj[seg]
		if !exists {
			return nil, fmt.Errorf("%w: path %q segment %d (%q) missing", connector_callout.ErrInvalidResponse, path, i+1, seg)
		}
		current = next
	}
	if current == nil {
		return nil, fmt.Errorf("%w: path %q resolved to null", connector_callout.ErrInvalidResponse, path)
	}
	return current, nil
}
