// Package exporter writes the in-memory graph to portable formats so users
// can load it into external visualization and query tools (Neo4j, Memgraph,
// Kuzu via Cypher; yEd, Gephi, Cytoscape via GraphML).
//
// The exporter is read-only and operates on a snapshot — it never mutates
// the graph. Filters (repo, kinds) are applied during emission.
package exporter

import (
	"encoding/json"
	"fmt"
	"io"
	"slices"
	"sort"
	"strings"

	"github.com/zzet/gortex/internal/graph"
)

// Options narrows what gets exported. Empty filters mean "include everything".
type Options struct {
	// Repo restricts emission to nodes whose RepoPrefix matches. Empty means all.
	Repo string

	// Kinds restricts emission to nodes whose Kind is in this set. Empty means all.
	// Edges are always emitted iff both endpoints are emitted.
	Kinds []graph.NodeKind

	// Languages restricts emission to nodes whose Language is in this set.
	Languages []string

	// DropSynthetic suppresses synthetic stub nodes generated for edges that
	// point to unresolved imports, external symbols (`external::error`), or
	// annotation sentinels (`annotation::go::Deprecated`). Default false:
	// stubs are emitted with kind="external" so the call topology stays
	// intact for visualization. Set true for a strict view of the graph.
	DropSynthetic bool

	// Pretty toggles human-readable formatting (line breaks, indentation).
	Pretty bool
}

// Stats reports what was emitted. Returned by every exporter Write call.
type Stats struct {
	NodesWritten   int
	EdgesWritten   int
	NodesSkipped   int
	EdgesSkipped   int
	BytesWritten   int64
}

// nodeFilter returns true for nodes that pass the option filters.
func (o *Options) nodeFilter(n *graph.Node) bool {
	if o.Repo != "" && n.RepoPrefix != o.Repo {
		return false
	}
	if len(o.Kinds) > 0 && !slices.Contains(o.Kinds, n.Kind) {
		return false
	}
	if len(o.Languages) > 0 && !slices.Contains(o.Languages, n.Language) {
		return false
	}
	return true
}

// snapshot collects nodes/edges that pass the filter into stable-sorted slices.
// Sorting makes exporter output deterministic — important for tests and diffs.
//
// When opts.DropSynthetic is false (default), edges pointing at IDs that are
// not real graph nodes (`unresolved::*`, `external::*`, `annotation::*`) get
// synthesized stub nodes added to the result so the call topology is preserved.
func snapshot(g *graph.Graph, opts Options) ([]*graph.Node, []*graph.Edge, map[string]bool) {
	allNodes := g.AllNodes()
	allEdges := g.AllEdges()

	keptNodeIDs := make(map[string]bool, len(allNodes))
	nodes := make([]*graph.Node, 0, len(allNodes))
	for _, n := range allNodes {
		if opts.nodeFilter(n) {
			nodes = append(nodes, n)
			keptNodeIDs[n.ID] = true
		}
	}

	syntheticIDs := make(map[string]bool)
	edges := make([]*graph.Edge, 0, len(allEdges))
	for _, e := range allEdges {
		fromKept := keptNodeIDs[e.From]
		toKept := keptNodeIDs[e.To]
		if fromKept && toKept {
			edges = append(edges, e)
			continue
		}
		if opts.DropSynthetic {
			continue
		}
		// One endpoint is a synthetic placeholder (unresolved::*, external::*,
		// annotation::*). Only synthesize when the *other* endpoint is real
		// — otherwise we'd add an edge between two stubs no caller cares
		// about.
		if !fromKept && !toKept {
			continue
		}
		if !fromKept && classifySynthetic(e.From) != "" {
			syntheticIDs[e.From] = true
			edges = append(edges, e)
		}
		if !toKept && classifySynthetic(e.To) != "" {
			syntheticIDs[e.To] = true
			edges = append(edges, e)
		}
	}

	for id := range syntheticIDs {
		stub := &graph.Node{
			ID:   id,
			Name: synthName(id),
			Kind: graph.NodeKind(classifySynthetic(id)),
			Meta: map[string]any{"synthetic": true},
		}
		nodes = append(nodes, stub)
		keptNodeIDs[id] = true
	}

	sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
	sort.Slice(edges, func(i, j int) bool {
		if edges[i].From != edges[j].From {
			return edges[i].From < edges[j].From
		}
		if edges[i].To != edges[j].To {
			return edges[i].To < edges[j].To
		}
		if edges[i].Kind != edges[j].Kind {
			return edges[i].Kind < edges[j].Kind
		}
		return edges[i].Line < edges[j].Line
	})

	// De-dup edges: when both From and To are synthetic-but-tied to a real
	// other endpoint, the loop above could enqueue the same edge twice.
	dedup := edges[:0]
	seen := make(map[edgeKey]bool, len(edges))
	for _, e := range edges {
		k := edgeKey{from: e.From, to: e.To, kind: e.Kind, line: e.Line}
		if seen[k] {
			continue
		}
		seen[k] = true
		dedup = append(dedup, e)
	}

	return nodes, dedup, keptNodeIDs
}

type edgeKey struct {
	from string
	to   string
	kind graph.EdgeKind
	line int
}

// classifySynthetic returns a label kind ("external", "unresolved",
// "annotation") for the recognized synthetic ID prefixes, or "" when the ID
// doesn't look like a placeholder.
func classifySynthetic(id string) string {
	switch {
	case strings.HasPrefix(id, "unresolved::"):
		return "unresolved"
	case strings.HasPrefix(id, "external::"):
		return "external"
	case strings.HasPrefix(id, "annotation::"):
		return "annotation"
	}
	return ""
}

// synthName extracts a human-readable name from a synthetic ID. The IDs use
// "::" as separator; the last segment is the most informative bit.
func synthName(id string) string {
	idx := strings.LastIndex(id, "::")
	if idx < 0 {
		return id
	}
	return id[idx+2:]
}

// flattenMeta walks Node.Meta or Edge.Meta and returns it as a sorted slice of
// key/value pairs with values coerced to (string|int64|float64|bool). Nested
// maps and slices are JSON-encoded into a string. Keys with characters that
// can't legally be Cypher / GraphML property names are sanitized.
type metaEntry struct {
	Key   string
	Value any
}

func flattenMeta(meta map[string]any) []metaEntry {
	if len(meta) == 0 {
		return nil
	}
	out := make([]metaEntry, 0, len(meta))
	for k, v := range meta {
		safeKey := sanitizePropertyName(k)
		if safeKey == "" {
			continue
		}
		out = append(out, metaEntry{Key: safeKey, Value: coerceValue(v)})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
	return out
}

// coerceValue maps a graph Meta value to a primitive the export formats can
// represent. Nested types fall back to JSON-encoded strings so no information
// is lost — receivers just have to parse the string if they care.
func coerceValue(v any) any {
	switch x := v.(type) {
	case nil:
		return nil
	case string, bool, int, int32, int64, uint, uint32, uint64, float32, float64:
		return x
	default:
		// Maps, slices, structs — encode as JSON.
		data, err := json.Marshal(x)
		if err != nil {
			return fmt.Sprintf("%v", x)
		}
		return string(data)
	}
}

// sanitizePropertyName returns a Cypher / GraphML-safe identifier. Replaces
// any character not in [a-zA-Z0-9_] with underscore. Empty / fully-illegal
// names are dropped (return "").
func sanitizePropertyName(name string) string {
	if name == "" {
		return ""
	}
	var b strings.Builder
	b.Grow(len(name))
	for i, r := range name {
		ok := r == '_' ||
			(r >= 'a' && r <= 'z') ||
			(r >= 'A' && r <= 'Z') ||
			(i > 0 && r >= '0' && r <= '9')
		if ok {
			b.WriteRune(r)
		} else {
			b.WriteRune('_')
		}
	}
	out := b.String()
	if out == "_" || strings.Trim(out, "_") == "" {
		return ""
	}
	return out
}

// nodeLabel converts a NodeKind into a Cypher / GraphML label. Capitalizes
// the first letter of each underscore-separated segment so "function" →
// "Function" and "import_path" → "ImportPath".
func nodeLabel(kind graph.NodeKind) string {
	if kind == "" {
		return "Unknown"
	}
	parts := strings.Split(string(kind), "_")
	for i, p := range parts {
		if p == "" {
			continue
		}
		parts[i] = strings.ToUpper(p[:1]) + p[1:]
	}
	return strings.Join(parts, "")
}

// edgeRelType converts an EdgeKind to UPPER_SNAKE for Cypher relationship
// type / GraphML edge label.
func edgeRelType(kind graph.EdgeKind) string {
	if kind == "" {
		return "RELATED"
	}
	return strings.ToUpper(string(kind))
}

// countingWriter wraps an io.Writer to track bytes written.
type countingWriter struct {
	w io.Writer
	n int64
}

func (cw *countingWriter) Write(p []byte) (int, error) {
	n, err := cw.w.Write(p)
	cw.n += int64(n)
	return n, err
}
