// Package codegen detects whether a source file was emitted by a code
// generator and, when possible, identifies the schema source it came
// from. Recognises the conventional Go "Code generated by … DO NOT
// EDIT." header plus the broader "@generated" marker used by Buck,
// Bazel, GraphQL Codegen, and others.
//
// Output is kept deliberately conservative: a generated-file flag
// plus optional generator tool name and source-file reference. File-
// pair linking against the actual schema (e.g. `foo.pb.go` →
// `foo.proto`) is left to the caller — only the source field
// declared inside the marker comment itself is parsed here. That
// keeps the scanner free of repo-walking state and lets it run in
// the per-file extraction path.
package codegen

import (
	"bufio"
	"bytes"
	"path/filepath"
	"regexp"
	"strings"
	"sync"

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

// scanBufPool holds reusable 64 KB scratch buffers for bufio.Scanner.
// Allocating a fresh buffer per call shows up as the top allocator in
// the indexer's GC-bound warmup phase (Scan runs on every file across
// every tracked repo). Pooling keeps the per-call footprint at the
// pointer header.
var scanBufPool = sync.Pool{
	New: func() any {
		b := make([]byte, 64*1024)
		return &b
	},
}

// Marker is the parsed result of scanning a file. Generated is true
// iff a recognised marker was found in the header window.
type Marker struct {
	Generated bool
	// Tool is the generator name extracted from `// Code generated
	// by <tool>` or `// @generated by <tool>`. Empty when the marker
	// was a bare `@generated`.
	Tool string
	// Source is the path or identifier extracted from a
	// `// source: <path>` line that immediately follows a Go-style
	// codegen header. Empty when the file's marker doesn't include
	// one.
	Source string
}

// commentLineRe enforces that the marker appears inside a real
// comment, not embedded in a prose string. The line must begin
// with whitespace and a known comment opener; anything after that
// is the comment body. We split the marker check into two passes:
// commentLineRe extracts the body, then markerRe / atGeneratedRe
// scan the body for the marker. This keeps "see the 'Code
// generated by' header" inside a doc comment from triggering, but
// still matches `/* Automatically @generated by tree-sitter */`
// where the marker isn't the first comment word.
var commentLineRe = regexp.MustCompile(`^\s*(?://|#|--|/\*|\*)\s*(.+?)\s*(?:\*/)?\s*$`)

// codegenRe matches "Code generated by <tool>." optionally
// followed by " DO NOT EDIT." within a comment body. Case-
// sensitive — the marker is a fixed convention, not natural
// language.
var codegenRe = regexp.MustCompile(`Code generated\s+(?:by\s+)?([^.]+?)\.\s*(?:DO NOT EDIT\.?)?`)

// atGeneratedRe matches the `@generated` marker used by tools
// outside the Go ecosystem (Buck, Bazel, GraphQL Codegen) within a
// comment body.
var atGeneratedRe = regexp.MustCompile(`@generated(?:\s+by\s+([^\s]+))?`)

// sourceRe matches the optional `// source: <path>` companion line
// that protoc-gen-go and friends emit immediately after the
// codegen header. Stops at end-of-line.
var sourceRe = regexp.MustCompile(`^\s*(?://|#|\*|--)\s*source:\s*([^\s]+)\s*$`)

const headerWindowLines = 10

// maxMarkerOffset bounds how far into a comment body the marker may
// appear before we stop trusting it. Tree-sitter's "Automatically
// @generated by …" puts @generated at offset 14; a generous cap of
// 20 lets that and similar prefixes through while still rejecting
// prose mentioning the marker mid-sentence.
const maxMarkerOffset = 20

// Scan walks the first headerWindowLines lines of source and
// returns a Marker. Generated stays false when no recognised marker
// fires; Tool/Source stay empty when the marker doesn't expose
// them. Lines are scanned in order; the first hit wins for both
// marker and source.
func Scan(source []byte) Marker {
	var m Marker
	if len(source) == 0 {
		return m
	}
	bufPtr := scanBufPool.Get().(*[]byte)
	defer scanBufPool.Put(bufPtr)
	scanner := bufio.NewScanner(bytes.NewReader(source))
	scanner.Buffer(*bufPtr, 1024*1024)
	lineNum := 0
	for scanner.Scan() && lineNum < headerWindowLines {
		lineNum++
		line := scanner.Text()
		// Restrict marker matching to lines that are actually
		// comments — guards against prose hits inside doc strings
		// and inside this scanner's own source.
		commentBody := commentLineRe.FindStringSubmatch(line)
		if commentBody == nil {
			continue
		}
		body := commentBody[1]
		if !m.Generated {
			// The marker must appear near the start of the comment
			// body. Real codegen headers put the marker at column 0
			// (Go's `Code generated by …`) or after a short
			// adverb-like prefix (tree-sitter's
			// `Automatically @generated by …`). Prose comments
			// mentioning the marker as a quoted phrase have it well
			// past column 20 — so we require the match index to be
			// no further than that.
			if hit := codegenRe.FindStringSubmatchIndex(body); hit != nil && hit[0] <= maxMarkerOffset {
				m.Generated = true
				m.Tool = strings.TrimSpace(body[hit[2]:hit[3]])
				continue
			}
			if hit := atGeneratedRe.FindStringSubmatchIndex(body); hit != nil && hit[0] <= maxMarkerOffset {
				m.Generated = true
				if hit[2] >= 0 {
					m.Tool = strings.TrimSpace(body[hit[2]:hit[3]])
				}
				continue
			}
		}
		if m.Source == "" {
			if hit := sourceRe.FindStringSubmatch(line); hit != nil {
				m.Source = strings.TrimSpace(hit[1])
			}
		}
	}
	return m
}

// BuildGraphArtifacts converts a Marker into the file-meta updates
// and the EdgeGeneratedBy edge to append. Returns nothing when
// Generated is false. The fileMeta map is the caller's existing
// Node.Meta — keys are merged in place, never replacing.
//
// The edge target is, in order of preference:
//   - the resolved schema source path when Marker.Source is set
//     (relative to repo root; the indexer's applyRepoPrefix handles
//     multi-repo namespacing downstream);
//   - a `generator::<tool>` synthetic node when only Tool is known;
//   - a `generator::unknown` sentinel for bare `@generated` markers.
//
// The returned edge slice is appended by the caller; the file node
// has meta.generated stamped by the caller using the returned Marker.
func BuildGraphArtifacts(filePath string, marker Marker) []*graph.Edge {
	if !marker.Generated {
		return nil
	}
	filePath = filepath.ToSlash(filePath)
	target := generatorNodeID(marker)
	return []*graph.Edge{{
		From:     filePath,
		To:       target,
		Kind:     graph.EdgeGeneratedBy,
		FilePath: filePath,
		Origin:   graph.OriginASTResolved,
		Meta: map[string]any{
			"tool":   marker.Tool,
			"source": marker.Source,
		},
	}}
}

// generatorNodeID picks the most specific target available for the
// EdgeGeneratedBy edge. A real source path wins; a tool name comes
// next; the unknown sentinel is the last resort. Uses the
// `external::` synthetic-ID prefix so the exporter and other
// downstream consumers that recognise the existing synthetic
// prefixes (alongside `unresolved::` and `annotation::`) materialise
// a stub node automatically — same pattern EdgeThrows uses for
// `external::error`.
func generatorNodeID(m Marker) string {
	if m.Source != "" {
		// Source paths are stored verbatim — for protoc-emitted Go
		// the value is typically a Go-style import path like
		// `github.com/foo/bar/baz.proto`, which is not a literal
		// filesystem path. The resolver can match it later via
		// suffix-prefix logic if a real .proto file exists in the
		// repo. Until then it's a synthetic external pointer that
		// still de-duplicates correctly across files generated from
		// the same source.
		return "external::generator-source:" + filepath.ToSlash(m.Source)
	}
	if m.Tool != "" {
		return "external::generator-tool:" + m.Tool
	}
	return "external::generator-unknown"
}

// MarkFileNode stamps generator metadata onto an existing file
// Node.Meta. Called by the indexer after a Scan returns Generated;
// keeps the file node compact while still surfacing the flag in
// brief listings.
func MarkFileNode(meta map[string]any, m Marker) {
	if !m.Generated {
		return
	}
	meta["generated"] = true
	if m.Tool != "" {
		meta["generated_by"] = m.Tool
	}
	if m.Source != "" {
		meta["generated_from"] = m.Source
	}
}
