package json

import (
	"bytes"
	"context"
	stdjson "encoding/json"
	"fmt"
	"io"
	"mime"
	"regexp"
	"sort"
	"strings"

	"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)

const Key = "json"

const contentTypeJSON = "application/json"

const chunkMapFileName = "chunk-map.json"

const evidenceContextFileName = "evidence-context.json"

const (
	warningsSchemaVersion    = "notarius.warnings.v2"
	diagnosticsSchemaVersion = "notarius.diagnostics.v1"
)

var safeOutputFileChar = regexp.MustCompile(`[^A-Za-z0-9._-]`)

var _ contracts.OutputEncoder = (*Encoder)(nil)

type Options struct {
	IncludeChunkMap bool
	EvidenceContext pipeline.EvidenceContextPolicy
}

type Encoder struct {
	options Options
}

func New() *Encoder {
	return NewWithOptions(Options{})
}

func NewWithOptions(options Options) *Encoder {
	options.EvidenceContext.LaneIDs = append([]string(nil), options.EvidenceContext.LaneIDs...)
	return &Encoder{options: options}
}

func (e *Encoder) Key() string {
	return Key
}

func (e *Encoder) EvidenceContextPolicy() pipeline.EvidenceContextPolicy {
	if e == nil {
		return pipeline.EvidenceContextPolicy{}
	}
	policy := e.options.EvidenceContext
	policy.LaneIDs = append([]string(nil), policy.LaneIDs...)
	return policy
}

func (e *Encoder) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
	if e == nil {
		return contracts.OutputResult{}, encoderErrorf("encoder must not be nil")
	}
	if ctx == nil {
		return contracts.OutputResult{}, encoderErrorf("context must not be nil")
	}
	if err := ctx.Err(); err != nil {
		return contracts.OutputResult{}, encoderErrorf("context error before encoding: %w", err)
	}

	files, err := logicalFiles(req, e.options)
	if err != nil {
		return contracts.OutputResult{}, err
	}
	return contracts.OutputResult{Files: files}, nil
}

func ModuleSpec() pipeline.ModuleSpec {
	return pipeline.ModuleSpec{
		Key:            Key,
		Stage:          pipeline.StageOutput,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"normalized"},
		Provides:       []string{"encoded"},
	}
}

func Register(registry *pipeline.OutputEncoderRegistry) error {
	return registry.RegisterBuilderWithProfileValidation(ModuleSpec(), validateOptions, validateProfileOptions, func(request pipeline.BuildRequest) (contracts.OutputEncoder, error) {
		options, err := DecodeOptions(request.Options)
		if err != nil {
			return nil, err
		}
		return &Encoder{options: options}, nil
	})
}

func validateOptions(options map[string]any) error {
	_, err := DecodeOptions(options)
	return err
}

func DecodeOptions(options map[string]any) (Options, error) {
	if err := pipeline.RejectUnknownOptions(options, "include_chunk_map", "evidence_context"); err != nil {
		return Options{}, encoderErrorf("%w", err)
	}
	decoded := Options{}
	if value, ok := options["include_chunk_map"]; ok {
		enabled, ok := value.(bool)
		if !ok {
			return Options{}, encoderErrorf("option %q must be a boolean", "include_chunk_map")
		}
		decoded.IncludeChunkMap = enabled
	}
	if value, ok := options["evidence_context"]; ok {
		policy, err := decodeEvidenceContextPolicy(value)
		if err != nil {
			return Options{}, err
		}
		decoded.EvidenceContext = policy
	}
	return decoded, nil
}

func validateProfileOptions(context pipeline.OutputProfileOptionContext, options map[string]any) error {
	decoded, err := DecodeOptions(options)
	if err != nil || !decoded.EvidenceContext.Enabled {
		return err
	}
	configured := make(map[string]struct{}, len(context.LaneIDs))
	for _, laneID := range context.LaneIDs {
		configured[laneID] = struct{}{}
	}
	for _, laneID := range decoded.EvidenceContext.LaneIDs {
		if _, ok := configured[laneID]; !ok {
			return encoderErrorf("evidence_context lane %q is not configured", laneID)
		}
	}
	return nil
}

func decodeEvidenceContextPolicy(value any) (pipeline.EvidenceContextPolicy, error) {
	object, ok := value.(map[string]any)
	if !ok {
		return pipeline.EvidenceContextPolicy{}, encoderErrorf("option %q must be an object", "evidence_context")
	}
	if err := pipeline.RejectUnknownOptions(object, "enabled", "lanes", "window_units"); err != nil {
		return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context: %w", err)
	}
	enabledValue, ok := object["enabled"]
	if !ok {
		return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context option %q is required", "enabled")
	}
	enabled, ok := enabledValue.(bool)
	if !ok {
		return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context option %q must be a boolean", "enabled")
	}
	if !enabled {
		if _, ok := object["lanes"]; ok {
			return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context option %q is not allowed when disabled", "lanes")
		}
		if _, ok := object["window_units"]; ok {
			return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context option %q is not allowed when disabled", "window_units")
		}
		return pipeline.EvidenceContextPolicy{}, nil
	}
	rawLanes, ok := object["lanes"]
	if !ok {
		return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context option %q is required when enabled", "lanes")
	}
	lanes, err := decodeEvidenceLaneIDs(rawLanes)
	if err != nil {
		return pipeline.EvidenceContextPolicy{}, err
	}
	windowUnits := 3
	if rawWindow, ok := object["window_units"]; ok {
		value, ok := rawWindow.(int)
		if !ok {
			return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context option %q must be an integer", "window_units")
		}
		if value < 0 {
			return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context option %q must not be negative", "window_units")
		}
		windowUnits = value
	}
	return pipeline.EvidenceContextPolicy{Enabled: true, WindowUnits: windowUnits, LaneIDs: lanes}, nil
}

func decodeEvidenceLaneIDs(value any) ([]string, error) {
	var raw []any
	switch typed := value.(type) {
	case []any:
		raw = typed
	case []string:
		raw = make([]any, len(typed))
		for i := range typed {
			raw[i] = typed[i]
		}
	default:
		return nil, encoderErrorf("evidence_context option %q must be an array", "lanes")
	}
	if len(raw) == 0 {
		return nil, encoderErrorf("evidence_context option %q must not be empty", "lanes")
	}
	seen := make(map[string]struct{}, len(raw))
	lanes := make([]string, 0, len(raw))
	for _, value := range raw {
		lane, ok := value.(string)
		if !ok {
			return nil, encoderErrorf("evidence_context lane values must be strings")
		}
		lane = strings.TrimSpace(lane)
		if lane == "" {
			return nil, encoderErrorf("evidence_context lane values must not be empty")
		}
		if _, ok := seen[lane]; ok {
			return nil, encoderErrorf("evidence_context lane %q is duplicated", lane)
		}
		seen[lane] = struct{}{}
		lanes = append(lanes, lane)
	}
	sort.Strings(lanes)
	return lanes, nil
}

type indexFile struct {
	ManifestFile    string            `json:"manifest_file"`
	OutputFiles     []outputFileIndex `json:"output_files"`
	RejectedFile    string            `json:"rejected_file"`
	WarningsFile    string            `json:"warnings_file"`
	DiagnosticsFile string            `json:"diagnostics_file"`
	ChunkMap        *artifactIndex    `json:"chunk_map,omitempty"`
	EvidenceContext *artifactIndex    `json:"evidence_context,omitempty"`
}

type artifactIndex struct {
	ArtifactKind  contracts.ArtifactKind `json:"artifact_kind"`
	File          string                 `json:"file"`
	MediaType     string                 `json:"media_type"`
	SchemaID      string                 `json:"schema_id"`
	SchemaName    string                 `json:"schema_name"`
	SchemaVersion string                 `json:"schema_version"`
}

type outputFileIndex struct {
	LaneID     string `json:"lane_id"`
	MediaType  string `json:"media_type,omitempty"`
	File       string `json:"file"`
	ModuleKey  string `json:"module_key,omitempty"`
	SchemaID   string `json:"schema_id,omitempty"`
	SchemaName string `json:"schema_name,omitempty"`
	SchemaVer  string `json:"schema_version,omitempty"`
}

type rejectedFile struct {
	Rejected []contracts.RejectedOutput `json:"rejected"`
}

type warningsFile struct {
	SchemaVersion   string                      `json:"schema_version"`
	GroupCount      int                         `json:"group_count"`
	OccurrenceCount int                         `json:"occurrence_count"`
	Groups          []contracts.DiagnosticGroup `json:"groups"`
}

type diagnosticsFile struct {
	SchemaVersion                string                      `json:"schema_version"`
	GroupCount                   int                         `json:"group_count"`
	OccurrenceCount              int                         `json:"occurrence_count"`
	Truncated                    bool                        `json:"truncated"`
	UnrepresentedOccurrenceCount int                         `json:"unrepresented_occurrence_count"`
	Groups                       []contracts.DiagnosticGroup `json:"groups"`
}

func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.OutputFile, error) {
	diagnosticProjection, err := contracts.ProjectDiagnosticCollection(req.Diagnostics)
	if err != nil {
		return nil, encoderErrorf("diagnostics: %w", err)
	}
	outputs := cloneNormalizeOutputs(req.NormalizeOutputs)
	sort.SliceStable(outputs, func(i, j int) bool {
		return outputs[i].LaneID < outputs[j].LaneID
	})

	outputIndexes := make([]outputFileIndex, 0, len(outputs))
	files := make([]contracts.OutputFile, 0, len(outputs)+6)
	manifestFile, err := jsonFile("manifest.json", req.Manifest)
	if err != nil {
		return nil, err
	}
	files = append(files, manifestFile)

	usedOutputFiles := make(map[string]string, len(outputs))
	for _, output := range outputs {
		name, err := outputFileName(output.LaneID)
		if err != nil {
			return nil, err
		}
		if existingLane, ok := usedOutputFiles[name]; ok {
			return nil, encoderErrorf("lanes %q and %q produce duplicate output file %q", existingLane, output.LaneID, name)
		}
		usedOutputFiles[name] = output.LaneID
		outputIndexes = append(outputIndexes, outputFileIndex{
			LaneID:     output.LaneID,
			MediaType:  output.Artifact.MediaType,
			File:       name,
			ModuleKey:  output.NormalizerKey,
			SchemaID:   output.Artifact.Schema.ID,
			SchemaName: output.Artifact.Schema.Name,
			SchemaVer:  output.Artifact.Schema.Version,
		})
		file, err := serializedOutputFile(name, output.Artifact)
		if err != nil {
			return nil, err
		}
		files = append(files, file)
	}

	index := indexFile{
		ManifestFile:    "manifest.json",
		OutputFiles:     outputIndexes,
		RejectedFile:    "rejected.json",
		WarningsFile:    "warnings.json",
		DiagnosticsFile: "diagnostics.json",
	}
	if options.IncludeChunkMap && req.ChunkMap != nil {
		chunkMapOutput, chunkMapDescriptor, err := serializedChunkMapFile(*req.ChunkMap)
		if err != nil {
			return nil, err
		}
		files = append(files, chunkMapOutput)
		index.ChunkMap = &chunkMapDescriptor
	}
	if req.EvidenceContext != nil {
		evidenceOutput, evidenceDescriptor, err := serializedEvidenceContextFile(*req.EvidenceContext)
		if err != nil {
			return nil, err
		}
		files = append(files, evidenceOutput)
		index.EvidenceContext = &evidenceDescriptor
	}
	indexOutput, err := jsonFile("index.json", index)
	if err != nil {
		return nil, err
	}
	rejectedOutput, err := jsonFile("rejected.json", rejectedFile{Rejected: cloneRejected(req.Rejected)})
	if err != nil {
		return nil, err
	}
	warningsOutput, err := jsonFile("warnings.json", newWarningsFile(diagnosticProjection))
	if err != nil {
		return nil, err
	}
	diagnosticsOutput, err := jsonFile("diagnostics.json", newDiagnosticsFile(req.Diagnostics, diagnosticProjection))
	if err != nil {
		return nil, err
	}
	files = append(files, indexOutput, rejectedOutput, warningsOutput, diagnosticsOutput)
	sort.Slice(files, func(i, j int) bool {
		return files[i].Name < files[j].Name
	})
	return files, nil
}

func serializedChunkMapFile(artifact contracts.SerializedArtifact) (contracts.OutputFile, artifactIndex, error) {
	if artifact.Kind != chunkmap.ArtifactKind {
		return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("chunk map has unexpected artifact kind %q", artifact.Kind)
	}
	if artifact.Schema.ID != chunkmap.SchemaID || artifact.Schema.Name != chunkmap.SchemaName || artifact.Schema.Version != chunkmap.SchemaVersion {
		return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("chunk map has unexpected schema identity")
	}
	if strings.TrimSpace(artifact.MediaType) != chunkmap.MediaType {
		return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("chunk map has unsupported media type %q", artifact.MediaType)
	}
	if _, err := chunkmap.New().Decode(artifact.Content); err != nil {
		return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("decode chunk map: %w", err)
	}
	file, err := serializedOutputFile(chunkMapFileName, artifact)
	if err != nil {
		return contracts.OutputFile{}, artifactIndex{}, err
	}
	return file, artifactIndex{
		ArtifactKind:  artifact.Kind,
		File:          chunkMapFileName,
		MediaType:     chunkmap.MediaType,
		SchemaID:      artifact.Schema.ID,
		SchemaName:    artifact.Schema.Name,
		SchemaVersion: artifact.Schema.Version,
	}, nil
}

func serializedEvidenceContextFile(artifact contracts.SerializedArtifact) (contracts.OutputFile, artifactIndex, error) {
	codec := evidencecontext.New()
	expected := codec.Schema()
	if artifact.Kind != evidencecontext.ArtifactKind ||
		artifact.Schema.ID != expected.ID || artifact.Schema.Name != expected.Name || artifact.Schema.Version != expected.Version ||
		contracts.DigestArtifactSchema(artifact.Schema) != contracts.DigestArtifactSchema(expected) ||
		strings.TrimSpace(artifact.MediaType) != evidencecontext.MediaType {
		return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("evidence context artifact is invalid")
	}
	if _, err := codec.Decode(artifact.Content); err != nil {
		return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("evidence context artifact is invalid")
	}
	file, err := serializedOutputFile(evidenceContextFileName, artifact)
	if err != nil {
		return contracts.OutputFile{}, artifactIndex{}, encoderErrorf("evidence context artifact is invalid")
	}
	return file, artifactIndex{
		ArtifactKind:  evidencecontext.ArtifactKind,
		File:          evidenceContextFileName,
		MediaType:     evidencecontext.MediaType,
		SchemaID:      expected.ID,
		SchemaName:    expected.Name,
		SchemaVersion: expected.Version,
	}, nil
}

func serializedOutputFile(name string, artifact contracts.SerializedArtifact) (contracts.OutputFile, error) {
	content := append([]byte(nil), artifact.Content...)
	if len(content) == 0 {
		content = []byte("null")
	}
	mediaType := strings.TrimSpace(artifact.MediaType)
	if mediaType == "" {
		mediaType = "application/octet-stream"
	}
	if !isJSONMediaType(mediaType) {
		return contracts.OutputFile{}, encoderErrorf("normalized output %q has unsupported media type %q", name, mediaType)
	}
	decoder := stdjson.NewDecoder(bytes.NewReader(content))
	decoder.UseNumber()
	var decoded any
	if err := decoder.Decode(&decoded); err != nil {
		return contracts.OutputFile{}, encoderErrorf("normalized output %q contains invalid JSON: %w", name, err)
	}
	var trailing any
	if err := decoder.Decode(&trailing); err != io.EOF {
		return contracts.OutputFile{}, encoderErrorf("normalized output %q contains multiple JSON values", name)
	}
	pretty, err := marshalPretty(decoded)
	if err != nil {
		return contracts.OutputFile{}, err
	}
	return contracts.OutputFile{
		Name:        name,
		ContentType: mediaType,
		Bytes:       pretty,
	}, nil
}

func isJSONMediaType(mediaType string) bool {
	base, _, err := mime.ParseMediaType(strings.TrimSpace(mediaType))
	if err != nil {
		base = strings.TrimSpace(mediaType)
	}
	return strings.EqualFold(base, contentTypeJSON)
}

func jsonFile(name string, value any) (contracts.OutputFile, error) {
	data, err := marshalPretty(value)
	if err != nil {
		return contracts.OutputFile{}, encoderErrorf("encode %s: %w", name, err)
	}
	return contracts.OutputFile{
		Name:        name,
		ContentType: contentTypeJSON,
		Bytes:       data,
	}, nil
}

func marshalPretty(value any) ([]byte, error) {
	data, err := stdjson.MarshalIndent(value, "", "  ")
	if err != nil {
		return nil, err
	}
	return append(data, '\n'), nil
}

func outputFileName(laneID string) (string, error) {
	sanitized := safeOutputFileChar.ReplaceAllString(strings.TrimSpace(laneID), "_")
	for strings.Contains(sanitized, "..") {
		sanitized = strings.ReplaceAll(sanitized, "..", "__")
	}
	sanitized = strings.Trim(sanitized, "._")
	if sanitized == "" {
		return "", encoderErrorf("lane id %q cannot produce a safe file name", laneID)
	}
	return "lanes/" + sanitized + ".json", nil
}

func cloneNormalizeOutputs(outputs []contracts.SerializedOutput) []contracts.SerializedOutput {
	if len(outputs) == 0 {
		return nil
	}
	out := make([]contracts.SerializedOutput, 0, len(outputs))
	for _, output := range outputs {
		out = append(out, contracts.CloneSerializedOutput(output))
	}
	return out
}

func cloneRejected(rejected []contracts.RejectedOutput) []contracts.RejectedOutput {
	if len(rejected) == 0 {
		return []contracts.RejectedOutput{}
	}
	return append([]contracts.RejectedOutput(nil), rejected...)
}

func newWarningsFile(projection contracts.DiagnosticProjection) warningsFile {
	return warningsFile{
		SchemaVersion:   warningsSchemaVersion,
		GroupCount:      len(projection.Warnings),
		OccurrenceCount: projection.WarningOccurrenceCount,
		Groups:          projection.Warnings,
	}
}

func newDiagnosticsFile(collection contracts.DiagnosticCollection, projection contracts.DiagnosticProjection) diagnosticsFile {
	return diagnosticsFile{
		SchemaVersion:                diagnosticsSchemaVersion,
		GroupCount:                   len(projection.Diagnostics),
		OccurrenceCount:              projection.DiagnosticOccurrenceCount,
		Truncated:                    collection.Truncated,
		UnrepresentedOccurrenceCount: collection.UnrepresentedOccurrenceCount,
		Groups:                       projection.Diagnostics,
	}
}

func encoderErrorf(format string, args ...any) error {
	return fmt.Errorf("json output encoder: "+format, args...)
}
