package daemon

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"path/filepath"
	"regexp"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/standardbeagle/agnt/internal/config"
	"github.com/standardbeagle/agnt/internal/debug"
	"github.com/standardbeagle/agnt/internal/incident"
	"github.com/standardbeagle/agnt/internal/protocol"

	"github.com/standardbeagle/agnt/internal/project"
	"github.com/standardbeagle/agnt/internal/proxy"
	hubpkg "github.com/standardbeagle/go-cli-server/hub"
	goprocess "github.com/standardbeagle/go-cli-server/process"
	hubproto "github.com/standardbeagle/go-cli-server/protocol"
)

// unmarshalCommand decodes cmd.Data into T. Returns zero value of T with nil
// error when Data is empty (valid case — no config provided). Returns an error
// when Data is non-empty but cannot be decoded.
func unmarshalCommand[T any](cmd *hubproto.Command) (T, error) {
	var zero T
	if len(cmd.Data) == 0 {
		return zero, nil
	}
	var v T
	if err := json.Unmarshal(cmd.Data, &v); err != nil {
		return zero, err
	}
	return v, nil
}

// writeStructuredErr writes a structured error response and logs it for debugging.

func writeStructuredErr(conn *hubpkg.Connection, component string, err *hubproto.StructuredError) error {
	debug.Log(component, "error: %s - %s (code=%v)", err.Command, err.Message, err.Code)
	return conn.WriteStructuredErr(err)
}

// normalizePath normalizes a path for consistent comparison.

func normalizePath(path string) string {
	if path == "" || path == "." {
		return "."
	}
	abs, err := filepath.Abs(path)
	if err != nil {
		abs = filepath.Clean(path)
	}
	// On Windows, normalize to lowercase for case-insensitive comparison.
	if runtime.GOOS == "windows" {
		abs = strings.ToLower(abs)
	}
	return abs
}

// getSessionScoped retrieves a resource with session-scoped fuzzy matching.
// If the connection has an associated session, only resources in that session's
// project path are considered for fuzzy lookup. Exact ID matches always work.
func getSessionScoped[T any](d *Daemon, conn *hubpkg.Connection, id string, getFn func(string, string) (T, error)) (T, error) {
	var pathFilter string
	if sessionCode := conn.SessionCode(); sessionCode != "" {
		if session, ok := d.sessionRegistry.Get(sessionCode); ok {
			pathFilter = session.ProjectPath
		}
	}
	return getFn(id, pathFilter)
}

// getSessionProjectPath returns the project path from the connection's session.

func (d *Daemon) getSessionProjectPath(conn *hubpkg.Connection) string {
	if sessionCode := conn.SessionCode(); sessionCode != "" {
		if session, ok := d.sessionRegistry.Get(sessionCode); ok {
			return session.ProjectPath
		}
	}
	return ""
}

// errNoSessionScope is returned by resolveProjectScope when a non-global
// query arrives on a connection with no resolvable session. It mirrors the
// INCIDENTS QUERY "no session attached" contract: scope is mandatory, so an
// unscopable non-global call fails loud rather than silently leaking every
// project's data.
var errNoSessionScope = errors.New("no session attached — call SESSION ATTACH first or pass global:true")

// resolveProjectScope is the session-scope chokepoint. It is the single
// resolution point every non-debug list/query handler routes through (wired
// in C4/C5). Given a per-call DirectoryFilter and the connection's bound
// session code, it returns the project path the handler must filter by:
//
//   - global == true  → ("", true, nil): no project filter (cross-project).
//   - explicit SessionCode → that session's project path (error if unknown).
//   - explicit Directory   → the normalized directory.
//   - otherwise the connection's bound session's project path.
//   - none of the above   → ("", false, errNoSessionScope): fail loud.
//
// Per-call global always wins. Resolution order mirrors the existing
// proc/proxy LIST handlers so behavior is uniform across the surface.
func (d *Daemon) resolveProjectScope(filter protocol.DirectoryFilter, connSessionCode string) (projectPath string, global bool, err error) {
	if filter.Global {
		return "", true, nil
	}
	if filter.SessionCode != "" {
		session, ok := d.sessionRegistry.Get(filter.SessionCode)
		if !ok {
			return "", false, fmt.Errorf("session %q not found", filter.SessionCode)
		}
		return normalizePath(session.ProjectPath), false, nil
	}
	if filter.Directory != "" {
		return normalizePath(filter.Directory), false, nil
	}
	if connSessionCode != "" {
		if session, ok := d.sessionRegistry.Get(connSessionCode); ok {
			return normalizePath(session.ProjectPath), false, nil
		}
	}
	return "", false, errNoSessionScope
}

// RogueProcessInfo contains information about a detected rogue process.
type RogueProcessInfo struct {
	Port       int   // The port being used
	PIDs       []int // PIDs of processes using the port
	IsManaged  bool  // Whether any of the PIDs are managed by agnt
	HasWarning bool  // Whether to show warning
}

// detectRogueProcess checks if there's an unmanaged process using the port
// associated with a stopped/failed process. Returns info about the rogue process.

func (d *Daemon) detectRogueProcess(ctx context.Context, proc *goprocess.ManagedProcess) *RogueProcessInfo {
	// Only check stopped/failed processes
	state := proc.State()
	if state != goprocess.StateStopped && state != goprocess.StateFailed {
		return nil
	}

	// Try to determine the expected port
	port := d.getExpectedPortForProcess(proc)
	if port <= 0 {
		return nil
	}

	// Check if port is in use
	pids := config.FindPIDsByPort(ctx, port)
	if len(pids) == 0 {
		return nil
	}

	// Check if any of these PIDs are managed by agnt
	isManaged := false
	for _, pid := range pids {
		if d.hub.ProcessManager().IsManagedPID(pid) {
			isManaged = true
			break
		}
	}

	// If port is in use by unmanaged process, return warning info
	if !isManaged {
		return &RogueProcessInfo{
			Port:       port,
			PIDs:       pids,
			IsManaged:  false,
			HasWarning: true,
		}
	}

	return nil
}

// getExpectedPortForProcess extracts the expected port for a process.
// Checks URLs from URL tracker, then falls back to command-line parsing.

func (d *Daemon) getExpectedPortForProcess(proc *goprocess.ManagedProcess) int {
	// First, check URLs from URL tracker
	urls := d.urlTracker.GetURLs(proc.ID)
	for _, urlStr := range urls {
		if port := extractPortFromURL(urlStr); port > 0 {
			return port
		}
	}

	// Fall back to command-line parsing
	return extractPortFromCommand(proc.Command, proc.Args)
}

// extractPortFromURL extracts a port number from a URL string.

func extractPortFromURL(urlStr string) int {
	// Simple pattern match for localhost URLs with ports
	patterns := []string{
		`localhost:(\d+)`,
		`127\.0\.0\.1:(\d+)`,
		`0\.0\.0\.0:(\d+)`,
		`\[::1\]:(\d+)`,
	}

	for _, pattern := range patterns {
		re := regexp.MustCompile(pattern)
		if matches := re.FindStringSubmatch(urlStr); len(matches) > 1 {
			if port, err := strconv.Atoi(matches[1]); err == nil && port > 0 && port < 65536 {
				return port
			}
		}
	}
	return 0
}

// registerAgntCommands registers agnt-specific commands with the Hub.
// This enables Hub's command dispatch to route these commands to the daemon's handlers.
// Note: Registering a command that Hub already registered will override Hub's handler.

func formatDuration(d time.Duration) string {
	if d < time.Second {
		return fmt.Sprintf("%dms", d.Milliseconds())
	}
	if d < time.Minute {
		return fmt.Sprintf("%.1fs", d.Seconds())
	}
	if d < time.Hour {
		m := int(d.Minutes())
		s := int(d.Seconds()) % 60
		return fmt.Sprintf("%dm%ds", m, s)
	}
	h := int(d.Hours())
	m := int(d.Minutes()) % 60
	return fmt.Sprintf("%dh%dm", h, m)
}

// hubHandleDetect handles the DETECT command.
func (d *Daemon) hubHandleDetect(ctx context.Context, conn *hubpkg.Connection, cmd *hubproto.Command) error {
	debug.Log("daemon", "DETECT: args=%v", cmd.Args)
	path := "."
	if len(cmd.Args) > 0 {
		path = cmd.Args[0]
	}

	proj, err := project.Detect(path)
	if err != nil {
		return conn.WriteErr(hubproto.ErrInternal, err.Error())
	}

	resp := map[string]interface{}{
		"type":            proj.Type,
		"path":            proj.Path,
		"package_manager": proj.PackageManager,
		"scripts":         project.GetCommandNames(proj),
	}

	data, err := json.Marshal(resp)
	if err != nil {
		return conn.WriteErr(hubproto.ErrInternal, err.Error())
	}

	return conn.WriteJSON(data)
}

// wireProxyLogger connects a proxy's traffic logger to the alert hub
// so that log entries are broadcast to active stream sinks.
//
// The TrafficLogger ring buffer always receives the entry first (the
// ring write happens upstream in TrafficLogger.LogEntry, before the
// SetOnLogEntry callback runs), so `proxylog query` continues to surface
// suppressed entries on demand.
//
// Suppression contract: when the proxy is linked to a process that is in
// a transient/unhealthy state (Starting, Stopping, Failed, or within the
// 5s grace window after returning to Running), the gate drops the
// broadcast to the alert hub. See proxyBroadcastGate for the gate logic
// and internal/daemon/health_tracker.go for the full state matrix.
//
// Transport-signal contract: in addition to process-state suppression,
// the gate watches for transport-error bursts (refused/EOF/dial timeout)
// and HTTP 2xx/3xx recoveries. A burst flips the proxy into synthetic
// outage and held entries collect in the HoldBuffer. A recovery signal
// drains the buffer — cascade entries are dropped, real errors emitted.
// See internal/daemon/hold_buffer.go and the OutageHold config block.
func (d *Daemon) wireProxyLogger(server *proxy.ProxyServer) {
	if d.eventHub == nil {
		return
	}
	proxyID := server.ID
	d.eventHub.RegisterProxyPath(proxyID, server.Path)
	server.Logger().SetOnLogEntry(func(entry proxy.LogEntry) {
		// Feed transport signals to the tracker before gating so the
		// classifier sees recovery signals immediately.
		d.feedTransportSignals(proxyID, entry)

		decision := d.proxyBroadcastDecision(proxyID, entry)
		switch decision {
		case gateAccept:
			d.fireGatedFanOut(entry, proxyID, 1)
		case gateHold:
			hb := d.holdBuffer.Load()
			if hb == nil {
				return
			}
			fp := FingerprintForEntry(entry)
			cascade := d.classifyCascade(entry)
			hb.Hold(entry, proxyID, fp, cascade)
		case gateDrop:
			// Drop without emission — used for diagnostic suppression
			// markers we never want to re-emit.
		}
	})
}

// feedTransportSignals inspects the entry for transport-error or
// recovery signals and updates the HealthTracker accordingly. Diagnostic
// entries categorised "transport" trigger an err record; HTTP responses
// with status <500 (non-error) trigger a recovery signal (proof the
// upstream is reachable).
func (d *Daemon) feedTransportSignals(proxyID string, entry proxy.LogEntry) {
	if d.healthTracker == nil {
		return
	}
	now := time.Now()
	switch entry.Type {
	case proxy.LogTypeDiagnostic:
		if entry.Diagnostic == nil {
			return
		}
		if entry.Diagnostic.Category == "transport" || isTransportEvent(entry.Diagnostic.Event) {
			d.healthTracker.RecordTransportError(proxyID, now)
		}
	case proxy.LogTypeHTTP:
		if entry.HTTP == nil {
			return
		}
		// Anything below 500 is "upstream answered" — counts as recovery.
		// 5xx is server-error, not connection failure, so it doesn't
		// signal connection health either way.
		if entry.HTTP.StatusCode > 0 && entry.HTTP.StatusCode < 500 {
			d.healthTracker.RecordRecoverySignal(proxyID, now)
		}
	}
}

// classifyCascade reports whether the entry should be treated as a
// transport-cascade message that the buffer drops on recovery. Transport
// diagnostics and matching browser-JS errors qualify; HTTP entries do
// not (5xx that arrives during outage is upstream signal worth keeping).
func (d *Daemon) classifyCascade(entry proxy.LogEntry) bool {
	switch entry.Type {
	case proxy.LogTypeDiagnostic:
		return true
	case proxy.LogTypeError:
		hb := d.holdBuffer.Load()
		if entry.Error == nil || hb == nil {
			return false
		}
		return hb.MatchesJSCascade(entry.Error.Message + " " + entry.Error.Stack)
	default:
		return false
	}
}

// fireGatedFanOut delivers an entry to all consumers after the gate
// (or hold-buffer emit) has approved it: EventHub stream sinks AND the
// incident bus. Used both for the immediate-pass path and as the
// HoldBuffer emit callback. mergedCount > 1 indicates the entry coalesced
// during the hold window; today the count is informational only — sinks
// see the original entry — but a future pass can synthesise a count
// prefix on the summary.
func (d *Daemon) fireGatedFanOut(entry proxy.LogEntry, proxyID string, _ int) {
	if d.eventHub != nil {
		d.eventHub.BroadcastLogEntry(entry, proxyID)
	}
	d.fireToIncidentBus(entry, proxyID)
}

// fireHoldEmit is the HoldBuffer emit callback. Same fan-out as direct
// gate-accept but with the merged-count signal preserved for telemetry.
func (d *Daemon) fireHoldEmit(entry proxy.LogEntry, proxyID string, mergedCount int) {
	d.fireGatedFanOut(entry, proxyID, mergedCount)
}

// fireToIncidentBus runs the right adapter for entry's type and publishes
// the result on the daemon's incident bus. This is the first production
// wire of the incident adapter layer (see Phase A migration in
// event_hub.go). For sessions without a registered pipeline the bus is a
// no-op, so this remains safe to call unconditionally.
func (d *Daemon) fireToIncidentBus(entry proxy.LogEntry, proxyID string) {
	if d.incidentBus == nil {
		return
	}
	switch entry.Type {
	case proxy.LogTypeDiagnostic:
		if entry.Diagnostic == nil {
			return
		}
		if ev, ok := incident.FromProxyDiagnostic(*entry.Diagnostic, proxyID); ok {
			d.incidentBus.Publish(ev)
		}
	case proxy.LogTypeHTTP:
		if entry.HTTP == nil {
			return
		}
		if ev, ok := incident.FromHTTPEntry(*entry.HTTP, proxyID); ok {
			d.incidentBus.Publish(ev)
		}
	case proxy.LogTypeError:
		if entry.Error == nil {
			return
		}
		ev := incident.FromFrontendError(*entry.Error, proxyID)
		d.incidentBus.Publish(ev)
	}
}

// isTransportEvent mirrors the transport-event classification used by
// internal/incident/adapter_diag.go so the gate and adapter agree.
func isTransportEvent(event string) bool {
	switch strings.ToLower(event) {
	case "refused", "timeout", "error", "dial", "tls", "io_error", "net_error":
		return true
	}
	return false
}

// gateDecision is the tri-state output of the broadcast gate.
type gateDecision int

const (
	// gateAccept allows the entry to flow to all consumers immediately.
	gateAccept gateDecision = iota
	// gateHold queues the entry into the HoldBuffer; emission is
	// deferred until the hold window expires or the proxy recovers.
	gateHold
	// gateDrop discards the entry without emission. Used only for
	// diagnostic suppression markers that have already been delivered
	// once and should not be re-emitted.
	gateDrop
)

// proxyBroadcastGate is preserved as a thin wrapper over
// proxyBroadcastDecision for legacy callers. true == accept, false ==
// drop-or-hold (the wrapper collapses both into "do not call
// BroadcastLogEntry directly"). New code should use the tri-state
// proxyBroadcastDecision.
func (d *Daemon) proxyBroadcastGate(proxyID string, entry proxy.LogEntry) bool {
	return d.proxyBroadcastDecision(proxyID, entry) == gateAccept
}

// proxyBroadcastDecision returns the gate decision for entry on proxyID:
// accept (forward immediately), hold (queue into HoldBuffer), or drop
// (discard).
//
// Diagnostic entries always accept — they are how the daemon
// communicates suppression state to the agent.
//
// For non-diagnostic entries:
//   - The HealthTracker is invoked for its side-effect (edge detection,
//     marker emission) regardless of the result.
//   - The OutageClassifier's tri-state SuppressionMode now considers BOTH
//     the linked process state (existing behaviour) AND the proxy's
//     synthetic transport-outage flag (new behaviour). When the proxy is
//     in transport outage but the process is healthy, ModeFull applies.
//   - ModeFull → hold. ModeDiagnosticOnly → hold for error-class entries,
//     accept for the rest. ModeOff → accept.
//
// The hold buffer (NewHoldBuffer) drives final disposition: held entries
// either replay on window expiry or drop on recovery.
func (d *Daemon) proxyBroadcastDecision(proxyID string, entry proxy.LogEntry) gateDecision {
	if entry.Type == proxy.LogTypeDiagnostic {
		return gateAccept
	}
	linkedProcessID := d.linkedScriptForProxy(proxyID)
	if linkedProcessID != "" {
		// Drive process-edge detection bookkeeping for the classifier.
		_ = d.healthTracker.IsInSuppressionWindow(proxyID, linkedProcessID)
	}

	mode := d.outageClassifier.SuppressionModeProxy(proxyID, linkedProcessID)
	switch mode {
	case ModeFull:
		return gateHold
	case ModeDiagnosticOnly:
		if isErrorClassEntry(entry) {
			return gateHold
		}
		return gateAccept
	case ModeOff:
		fallthrough
	default:
		// Just past the grace window — emit the close marker now
		// (idempotent) so the agent sees suppression end at the same
		// instant errors actually start flowing again.
		if linkedProcessID != "" {
			d.healthTracker.MaybeCloseGraceWindow(proxyID, linkedProcessID)
		}
		return gateAccept
	}
}

// isErrorClassEntry reports whether a proxy log entry is "error class"
// for the purposes of ModeDiagnosticOnly suppression. Frontend errors,
// HTTP 5xx responses, and explicit error-level diagnostics all qualify.
// Diagnostics are handled before this gate function runs, so callers
// only see non-diagnostic types here.
func isErrorClassEntry(entry proxy.LogEntry) bool {
	switch entry.Type {
	case proxy.LogTypeError:
		return true
	case proxy.LogTypeHTTP:
		if entry.HTTP != nil && entry.HTTP.StatusCode >= 500 {
			return true
		}
		return false
	default:
		return false
	}
}
