package teamswebhook

import (
	"context"
	"fmt"
	"net/url"
	"regexp"
	"sort"
	"strconv"
	"strings"

	goteamsnotify "github.com/atc0005/go-teams-notify/v2"
	"github.com/atc0005/go-teams-notify/v2/adaptivecard"

	"github.com/sipeed/picoclaw/pkg/bus"
	"github.com/sipeed/picoclaw/pkg/channels"
	"github.com/sipeed/picoclaw/pkg/config"
	"github.com/sipeed/picoclaw/pkg/logger"
)

// statusCodeRe extracts HTTP status codes from error messages like "401 Unauthorized".
var statusCodeRe = regexp.MustCompile(`\b([45]\d{2})\b`)

// markdownTableRe matches a markdown table block (header + separator + rows).
// It captures the entire table including all rows.
var markdownTableRe = regexp.MustCompile(`(?m)^(\|[^\n]+\|)\n(\|[-:\|\s]+\|)\n((?:\|[^\n]+\|\n?)+)`)

// teamsMessageSender abstracts the Teams client for testability.
type teamsMessageSender interface {
	SendWithContext(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error
}

// classifyTeamsError extracts HTTP status code from error message and classifies it.
// The go-teams-notify library returns errors like "error on notification: 401 Unauthorized, ...".
// This allows proper retry behavior: 4xx errors are permanent, 5xx are temporary.
func classifyTeamsError(err error) error {
	if err == nil {
		return nil
	}
	errMsg := err.Error()
	if matches := statusCodeRe.FindStringSubmatch(errMsg); len(matches) > 1 {
		if statusCode, parseErr := strconv.Atoi(matches[1]); parseErr == nil {
			return channels.ClassifySendError(statusCode, err)
		}
	}
	// Fallback: treat as temporary network error (retryable)
	return channels.ClassifyNetError(err)
}

// TeamsWebhookChannel is an output-only channel that sends messages
// to Microsoft Teams via Power Automate workflow webhooks.
// Multiple webhook targets can be configured and selected via ChatID.
type TeamsWebhookChannel struct {
	*channels.BaseChannel
	bc     *config.Channel
	config *config.TeamsWebhookSettings
	client teamsMessageSender
}

// NewTeamsWebhookChannel creates a new Teams webhook channel.
func NewTeamsWebhookChannel(
	bc *config.Channel,
	cfg *config.TeamsWebhookSettings,
	bus *bus.MessageBus,
) (*TeamsWebhookChannel, error) {
	if len(cfg.Webhooks) == 0 {
		return nil, fmt.Errorf("teams_webhook: at least one webhook target is required")
	}

	// Require "default" webhook target
	if _, hasDefault := cfg.Webhooks["default"]; !hasDefault {
		return nil, fmt.Errorf("teams_webhook: a 'default' webhook target is required")
	}

	// Validate all webhook targets have valid HTTPS URLs
	for name, target := range cfg.Webhooks {
		webhookURL := target.WebhookURL.String()
		if webhookURL == "" {
			return nil, fmt.Errorf("teams_webhook: webhook %q has empty webhook_url", name)
		}
		parsed, err := url.Parse(webhookURL)
		if err != nil {
			return nil, fmt.Errorf("teams_webhook: webhook %q has invalid URL: %w", name, err)
		}
		if !strings.EqualFold(parsed.Scheme, "https") {
			return nil, fmt.Errorf("teams_webhook: webhook %q must use HTTPS (got %q)", name, parsed.Scheme)
		}
	}

	base := channels.NewBaseChannel(
		"teams_webhook",
		cfg,
		bus,
		[]string{
			"*",
		}, // Output-only channel; "*" suppresses misleading "allows EVERYONE" audit warning
		channels.WithMaxMessageLength(24000), // Power Automate webhook payload limit is 28KB
	)

	client := goteamsnotify.NewTeamsClient()

	return &TeamsWebhookChannel{
		BaseChannel: base,
		bc:          bc,
		config:      cfg,
		client:      client,
	}, nil
}

// Start initializes the channel. For output-only channels, this is a no-op.
func (c *TeamsWebhookChannel) Start(ctx context.Context) error {
	targets := make([]string, 0, len(c.config.Webhooks))
	for name := range c.config.Webhooks {
		targets = append(targets, name)
	}
	sort.Strings(targets)
	logger.InfoCF("teams_webhook", "Starting Teams webhook channel (output-only)", map[string]any{
		"targets": targets,
	})
	c.SetRunning(true)
	return nil
}

// Stop shuts down the channel.
func (c *TeamsWebhookChannel) Stop(ctx context.Context) error {
	logger.InfoC("teams_webhook", "Stopping Teams webhook channel")
	c.SetRunning(false)
	return nil
}

// Send delivers a message to the specified Teams webhook target.
// The target is selected by msg.ChatID which must match a key in the webhooks map.
func (c *TeamsWebhookChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
	if !c.IsRunning() {
		return nil, channels.ErrNotRunning
	}

	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}

	// Look up webhook target by ChatID, fall back to "default" if empty or unknown
	targetName := msg.ChatID
	if targetName == "" {
		targetName = "default"
	}

	target, ok := c.config.Webhooks[targetName]
	if !ok {
		// Log warning and fall back to default target
		logger.WarnCF("teams_webhook", "Unknown target, falling back to default", map[string]any{
			"requested": msg.ChatID,
			"using":     "default",
		})
		target = c.config.Webhooks["default"]
	}

	// Build an Adaptive Card for rich formatting
	card, err := c.buildAdaptiveCard(msg, target)
	if err != nil {
		return nil, fmt.Errorf("teams_webhook: failed to build card: %w", err)
	}

	// Create the message with the card
	teamsMsg, err := adaptivecard.NewMessageFromCard(card)
	if err != nil {
		return nil, fmt.Errorf("teams_webhook: failed to create message: %w", err)
	}

	// Send to Teams
	if err := c.client.SendWithContext(ctx, target.WebhookURL.String(), teamsMsg); err != nil {
		// Log without raw error to avoid leaking webhook URL (embedded in net/http errors)
		logger.ErrorCF("teams_webhook", "Failed to send message to Teams webhook", map[string]any{
			"target": msg.ChatID,
		})
		// Classify error based on status code extracted from error message.
		// The go-teams-notify library includes status in errors like "401 Unauthorized".
		// Use ClassifySendError for proper retry behavior (4xx = permanent, 5xx = temporary).
		classifiedErr := classifyTeamsError(err)
		return nil, fmt.Errorf("teams_webhook: send failed: %w", classifiedErr)
	}

	logger.DebugCF("teams_webhook", "Message sent successfully", map[string]any{
		"target": msg.ChatID,
	})

	return nil, nil
}

// buildAdaptiveCard creates a formatted Adaptive Card from the outbound message.
// It detects markdown tables and converts them to native Adaptive Card Table elements,
// since TextBlocks only support a limited markdown subset (no tables).
func (c *TeamsWebhookChannel) buildAdaptiveCard(
	msg bus.OutboundMessage,
	target config.TeamsWebhookTarget,
) (adaptivecard.Card, error) {
	card := adaptivecard.NewCard()
	card.Type = adaptivecard.TypeAdaptiveCard

	// Set full width for Teams rendering
	card.MSTeams.Width = "Full"

	// Add title if configured on the target
	title := target.Title
	if title == "" {
		title = "PicoClaw Notification"
	}

	titleBlock := adaptivecard.NewTextBlock(title, true)
	titleBlock.Size = adaptivecard.SizeLarge
	titleBlock.Weight = adaptivecard.WeightBolder
	titleBlock.Style = adaptivecard.TextBlockStyleHeading

	if err := card.AddElement(false, titleBlock); err != nil {
		return card, err
	}

	content := msg.Content
	if content == "" {
		content = "(empty message)"
	}

	// Split content into text segments and tables
	// TextBlocks support: bold, italic, bullet/numbered lists, links
	// TextBlocks do NOT support: headers, tables, images
	segments := splitContentWithTables(content)

	for _, seg := range segments {
		if seg.isTable {
			// Convert markdown table to Adaptive Card Table element
			tableElement, err := parseMarkdownTable(seg.content)
			if err != nil {
				// Fallback: render as preformatted text if parsing fails
				logger.WarnCF("teams_webhook", "Failed to parse markdown table, using fallback", map[string]any{
					"error": err.Error(),
				})
				block := adaptivecard.NewTextBlock("```\n"+seg.content+"\n```", true)
				block.Wrap = true
				if err := card.AddElement(false, block); err != nil {
					return card, err
				}
				continue
			}
			if err := card.AddElement(false, tableElement); err != nil {
				return card, err
			}
		} else {
			// Regular text content
			text := strings.TrimSpace(seg.content)
			if text == "" {
				continue
			}
			block := adaptivecard.NewTextBlock(text, true)
			block.Wrap = true
			if err := card.AddElement(false, block); err != nil {
				return card, err
			}
		}
	}

	return card, nil
}

// contentSegment represents either a text block or a table in the message content.
type contentSegment struct {
	content string
	isTable bool
}

// splitContentWithTables splits content into alternating text and table segments.
func splitContentWithTables(content string) []contentSegment {
	var segments []contentSegment

	matches := markdownTableRe.FindAllStringSubmatchIndex(content, -1)
	if len(matches) == 0 {
		// No tables found, return entire content as text
		return []contentSegment{{content: content, isTable: false}}
	}

	lastEnd := 0
	for _, match := range matches {
		// Text before this table
		if match[0] > lastEnd {
			segments = append(segments, contentSegment{
				content: content[lastEnd:match[0]],
				isTable: false,
			})
		}
		// The table itself
		segments = append(segments, contentSegment{
			content: content[match[0]:match[1]],
			isTable: true,
		})
		lastEnd = match[1]
	}

	// Text after the last table
	if lastEnd < len(content) {
		segments = append(segments, contentSegment{
			content: content[lastEnd:],
			isTable: false,
		})
	}

	return segments
}

// parseMarkdownTable converts a markdown table string to an Adaptive Card Table element.
func parseMarkdownTable(tableStr string) (adaptivecard.Element, error) {
	lines := strings.Split(strings.TrimSpace(tableStr), "\n")
	if len(lines) < 2 {
		return adaptivecard.Element{}, fmt.Errorf("table must have at least header and separator rows")
	}

	// Track header content length per column for width calculation
	var headerLengths []int

	// Parse all rows (header + data rows, skip separator)
	var allRows [][]adaptivecard.TableCell
	for i, line := range lines {
		// Skip separator row (contains only |, -, :, and spaces)
		if i == 1 && isSeparatorRow(line) {
			continue
		}

		cells := parseTableRow(line)
		if len(cells) == 0 {
			continue
		}

		var tableCells []adaptivecard.TableCell
		for _, cellText := range cells {
			trimmedText := strings.TrimSpace(cellText)

			// Use header row (first row) to determine column widths
			if i == 0 {
				headerLengths = append(headerLengths, len(trimmedText))
			}

			textBlock := adaptivecard.Element{
				Type: adaptivecard.TypeElementTextBlock,
				Text: trimmedText,
				Wrap: true,
			}
			cell := adaptivecard.TableCell{
				Type:  adaptivecard.TypeTableCell,
				Items: []*adaptivecard.Element{&textBlock},
			}
			tableCells = append(tableCells, cell)
		}
		allRows = append(allRows, tableCells)
	}

	if len(allRows) == 0 {
		return adaptivecard.Element{}, fmt.Errorf("no valid rows found in table")
	}

	// Create table with first row as headers
	firstRowAsHeaders := true
	showGridLines := true

	table, err := adaptivecard.NewTableFromTableCells(allRows, 0, firstRowAsHeaders, showGridLines)
	if err != nil {
		return adaptivecard.Element{}, fmt.Errorf("failed to create table: %w", err)
	}

	// Set column widths based on header content length
	table.Columns = calculateColumnWidths(headerLengths)

	return table, nil
}

// calculateColumnWidths creates TableColumnDefinition entries with widths
// proportional to the max content length of each column.
func calculateColumnWidths(maxLengths []int) []adaptivecard.Column {
	if len(maxLengths) == 0 {
		return nil
	}

	// Use content length as relative weight, with a minimum of 1
	columns := make([]adaptivecard.Column, len(maxLengths))
	for i, length := range maxLengths {
		weight := length
		if weight < 1 {
			weight = 1
		}
		columns[i] = adaptivecard.Column{
			Type:  "TableColumnDefinition",
			Width: weight,
		}
	}

	return columns
}

// isSeparatorRow checks if a line is a markdown table separator (e.g., |---|---|).
func isSeparatorRow(line string) bool {
	// Remove pipes and spaces, check if only dashes and colons remain
	cleaned := strings.ReplaceAll(line, "|", "")
	cleaned = strings.ReplaceAll(cleaned, " ", "")
	cleaned = strings.ReplaceAll(cleaned, "-", "")
	cleaned = strings.ReplaceAll(cleaned, ":", "")
	return cleaned == ""
}

// parseTableRow extracts cell values from a markdown table row.
func parseTableRow(line string) []string {
	// Trim leading/trailing pipes and split by |
	line = strings.TrimSpace(line)
	line = strings.TrimPrefix(line, "|")
	line = strings.TrimSuffix(line, "|")

	if line == "" {
		return nil
	}

	parts := strings.Split(line, "|")
	var cells []string
	for _, p := range parts {
		cells = append(cells, strings.TrimSpace(p))
	}
	return cells
}
