/*
 * Copyright 2026 CloudWeGo Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package agentsmd

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/cloudwego/eino/adk/filesystem"
	"github.com/cloudwego/eino/adk/internal"
)

// importRegex matches @path/to/file anywhere in text.
// The path must start with a letter, digit, dot, underscore, slash, or tilde, followed by
// path characters (letters, digits, dots, slashes, hyphens, underscores).
// A post-match filter further requires the path to contain "/" or end with
// an allowed extension (see allowedImportExts), so bare words like @someone
// and email-like patterns like @example.com are ignored.
var importRegex = regexp.MustCompile(`@([a-zA-Z0-9_.~/][a-zA-Z0-9_.~/\-]*)`)

// allowedImportExts is the set of file extensions recognised as @import targets.
// Paths without "/" must end with one of these extensions to be treated as imports;
// this avoids false positives on email addresses (@example.com) and mentions (@foo.bar).
var allowedImportExts = map[string]bool{
	".md":   true,
	".txt":  true,
	".mdx":  true,
	".yaml": true,
	".yml":  true,
	".json": true,
	".toml": true,
}

const maxImportDepth = 5

// ReadRequest is an alias for filesystem.ReadRequest.
type ReadRequest = filesystem.ReadRequest
type FileContent = filesystem.FileContent

// Backend defines the file access interface for loading Agents.md files.
// Implementations can use local filesystem, remote storage, or any other backend.
type Backend interface {
	// Read reads the content of a file.
	// If the file does not exist, implementations should return an error wrapping
	// os.ErrNotExist (so that errors.Is(err, os.ErrNotExist) returns true). This allows the loader
	// to silently skip missing files and notify via OnLoadWarning callback.
	// Other errors (e.g. permission denied, I/O errors) will abort the loading process.
	Read(ctx context.Context, req *ReadRequest) (*FileContent, error)
}

// loaderConfig holds the immutable configuration for creating loaders.
// It is safe for concurrent use by multiple goroutines.
type loaderConfig struct {
	backend   Backend
	files     []string                         // ordered file paths from config
	maxBytes  int                              // cumulative read budget; 0 means unlimited
	onWarning func(filePath string, err error) // callback for non-fatal loading warnings
}

func newLoaderConfig(backend Backend, files []string, maxBytes int, onWarning func(filePath string, err error)) *loaderConfig {
	if onWarning == nil {
		onWarning = func(filePath string, err error) {
			log.Printf("[agentsmd] warning: %s: %v", filePath, err)
		}
	}
	return &loaderConfig{
		backend:   backend,
		files:     files,
		maxBytes:  maxBytes,
		onWarning: onWarning,
	}
}

// loader handles loading and @import resolution for agents.md files.
// A new loader is created for each load() call to avoid sharing mutable state
// (totalBytes) across concurrent invocations.
type loader struct {
	*loaderConfig
	totalBytes int // accumulated bytes during this load call
}

func (cfg *loaderConfig) newLoader() *loader {
	return &loader{loaderConfig: cfg}
}

// load reads all agents.md files and returns the formatted content.
// Each top-level file and its @imported files appear as separate sections.
func (cfg *loaderConfig) load(ctx context.Context) (string, error) {
	l := cfg.newLoader()

	var parts []loadedFile
	seen := make(map[string]bool) // dedup across all files and imports

	for i, filePath := range l.files {
		files, err := l.loadFile(ctx, filePath, 0, make(map[string]bool), seen)
		if err != nil {
			return "", fmt.Errorf("failed to load %q: %w", filePath, err)
		}

		// If loading this file caused the budget to be exceeded, skip it
		// (but always include the first file).
		if i > 0 && l.maxBytes > 0 && l.totalBytes > l.maxBytes {
			l.onWarning(filePath, fmt.Errorf("skipped: cumulative size %d exceeds max bytes %d", l.totalBytes, l.maxBytes))
			break
		}

		parts = append(parts, files...)
	}

	return formatContent(parts), nil
}

// loadFile reads a file via Backend and collects @imported files as separate entries.
// Returns a slice where the first element is this file itself, followed by all
// transitively imported files (in encounter order, preserving @path in original text).
// visited tracks the current ancestor chain to detect circular imports.
// seen tracks globally loaded files to avoid duplicate reads and byte counting.
func (l *loader) loadFile(ctx context.Context, filePath string, depth int, visited map[string]bool, seen map[string]bool) ([]loadedFile, error) {
	filePath = filepath.Clean(filePath)

	if depth > maxImportDepth {
		l.onWarning(filePath, fmt.Errorf("@import depth exceeds maximum of %d", maxImportDepth))
		return nil, nil
	}

	if visited[filePath] {
		l.onWarning(filePath, fmt.Errorf("circular @import detected"))
		return nil, nil
	}

	if seen[filePath] {
		return nil, nil
	}

	visited[filePath] = true
	defer delete(visited, filePath)

	fileContent, err := l.backend.Read(ctx, &ReadRequest{FilePath: filePath, Offset: 1})
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			l.onWarning(filePath, fmt.Errorf("file not found, skipping"))
			return nil, nil
		}
		return nil, err
	}
	content := ""
	if fileContent != nil {
		content = fileContent.Content
	}

	l.totalBytes += len(content)
	seen[filePath] = true

	if content == "" {
		return nil, nil
	}

	// Collect imported files as separate sections (content stays untouched).
	imports, err := l.collectImports(ctx, filePath, content, depth, visited, seen)
	if err != nil {
		return nil, err
	}

	// This file first, then its imports.
	result := make([]loadedFile, 0, 1+len(imports))
	result = append(result, loadedFile{path: filePath, content: content})
	result = append(result, imports...)
	return result, nil
}

// collectImports scans content for @path/to/file references and loads each
// imported file (plus its transitive imports). The original content is NOT modified.
// Returns the list of imported loadedFile entries in encounter order.
// seen is shared across the entire load call to avoid duplicate reads.
// Non-fatal errors (file not found, depth exceeded, circular import) are reported
// via onWarning and skipped. Fatal errors (e.g. I/O) are returned.
func (l *loader) collectImports(ctx context.Context, hostPath, content string, depth int, visited map[string]bool, seen map[string]bool) ([]loadedFile, error) {
	dir := filepath.Dir(hostPath)
	var imports []loadedFile

	matches := importRegex.FindAllStringSubmatch(content, -1)
	for _, match := range matches {
		rawPath := match[1]

		// Only treat as import if path contains "/" or ends with an allowed extension.
		// This avoids false positives on email addresses and social mentions.
		if !strings.Contains(rawPath, "/") && !allowedImportExts[filepath.Ext(rawPath)] {
			continue
		}

		// If budget is exhausted, skip further imports.
		if l.maxBytes > 0 && l.totalBytes > l.maxBytes {
			break
		}

		importPath := rawPath
		if !filepath.IsAbs(importPath) {
			importPath = filepath.Join(dir, importPath)
		}

		if seen[importPath] {
			continue
		}

		files, err := l.loadFile(ctx, importPath, depth+1, visited, seen)
		if err != nil {
			return nil, fmt.Errorf("failed to import %q from %q: %w", rawPath, hostPath, err)
		}

		imports = append(imports, files...)
	}

	return imports, nil
}

type loadedFile struct {
	path    string
	content string
}

const formatHeaderEn = `<system-reminder>
As you answer the user's questions, you can use the following context:
Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.
`

const formatHeaderCn = `<system-reminder>
在回答用户问题时，你可以使用以下上下文：
代码库和用户指令如下。请务必遵守这些指令。重要提示：这些指令会覆盖任何默认行为，你必须严格按照要求执行。
`

const formatFileHeaderEn = "\nContents of "

const formatFileHeaderCn = "\n文件内容："

const formatFileLabelEn = " (instructions):\n\n"

const formatFileLabelCn = "（指令）：\n\n"

const formatFooterEn = `IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
</system-reminder>`

const formatFooterCn = `重要提示：此上下文可能与你的任务相关，也可能不相关。除非此上下文与你的任务高度相关，否则不要响应此上下文。
</system-reminder>`

func formatContent(files []loadedFile) string {
	if len(files) == 0 {
		return ""
	}

	header := internal.SelectPrompt(internal.I18nPrompts{
		English: formatHeaderEn,
		Chinese: formatHeaderCn,
	})
	fileHeader := internal.SelectPrompt(internal.I18nPrompts{
		English: formatFileHeaderEn,
		Chinese: formatFileHeaderCn,
	})
	fileLabel := internal.SelectPrompt(internal.I18nPrompts{
		English: formatFileLabelEn,
		Chinese: formatFileLabelCn,
	})
	footer := internal.SelectPrompt(internal.I18nPrompts{
		English: formatFooterEn,
		Chinese: formatFooterCn,
	})

	var sb strings.Builder
	sb.WriteString(header)

	for _, f := range files {
		sb.WriteString(fileHeader)
		sb.WriteString(f.path)
		sb.WriteString(fileLabel)
		sb.WriteString(f.content)
		sb.WriteString("\n")
	}
	sb.WriteString(footer)
	return sb.String()
}
