//
//  Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
//  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 schema

import (
	"fmt"
	"math"
	"strconv"
	"strings"
)

// ChunkerFromUpstream is the shared upstream payload consumed by all four
// chunker variants (TokenChunker, TitleChunker, GroupTitleChunker,
// HierarchyTitleChunker).
//
// It mirrors the wire shape defined across two equivalent Pydantic
// schemas in the Python codebase:
//
//	rag/flow/chunker/schema.py:TokenChunkerFromUpstream
//	rag/flow/chunker/title_chunker/schema.py:TitleChunkerFromUpstream
//
// Both classes are field-identical (apart from the class name) — the
// variants share one upstream payload; their *Param structs differ.
//
// Wire shape (Pydantic):
//
//	created_time: float | None  (alias _created_time)
//	elapsed_time: float | None  (alias _elapsed_time)
//	name: str                   (required)
//	file: dict | None
//	chunks: list[dict] | None
//	output_format: Literal["json","markdown","text","html","chunks"] | None
//	json_result:      list[dict] | None  (alias "json")
//	markdown_result:  str        | None  (alias "markdown")
//	text_result:      str        | None  (alias "text")
//	html_result:      str        | None  (alias "html")
type ChunkerFromUpstream struct {
	CreatedTime *float64 `json:"_created_time,omitempty"`
	ElapsedTime *float64 `json:"_elapsed_time,omitempty"`

	// Name is the source document name. Required.
	Name string `json:"name"`

	// DocID is the originating document ID. When set, the chunker can
	// re-resolve the source PDF from storage to crop section images on
	// demand, instead of carrying the binary across the component
	// boundary.
	DocID string `json:"doc_id,omitempty"`

	// Bucket and Path are the explicit storage location of the source
	// PDF. They take precedence over DocID-driven resolution.
	Bucket string `json:"bucket,omitempty"`
	Path   string `json:"path,omitempty"`

	// File is the optional upstream file descriptor.
	File *ChunkerFileMeta `json:"file,omitempty"`

	// Chunks is the upstream chunk list, set when output_format == "chunks".
	Chunks []ChunkDoc `json:"chunks,omitempty"`

	// OutputFormat controls which of the *Result fields below is the
	// active payload. Allowed values:
	//   "json"     -> JSONResult
	//   "markdown" -> MarkdownResult
	//   "text"     -> TextResult
	//   "html"     -> HTMLResult
	//   "chunks"   -> Chunks
	OutputFormat PayloadFormat `json:"output_format,omitempty"`

	// JSONResult is the upstream structured JSON list (alias "json" in
	// Python). Set when OutputFormat == "json".
	JSONResult []ChunkDoc `json:"json,omitempty"`

	// MarkdownResult is the upstream markdown payload (alias "markdown").
	// Set when OutputFormat == "markdown".
	MarkdownResult *string `json:"markdown,omitempty"`

	// TextResult is the upstream plain-text payload (alias "text").
	// Set when OutputFormat == "text".
	TextResult *string `json:"text,omitempty"`

	// HTMLResult is the upstream HTML payload (alias "html").
	// Set when OutputFormat == "html".
	HTMLResult *string `json:"html,omitempty"`
}

// Validate enforces the only required field in ChunkerFromUpstream: Name.
// OutputFormat is not strictly required (defaults to "" and the
// component decides what to do with an empty payload), but the
// combination of `OutputFormat == "chunks"` with a non-nil Chunks is the
// happy path.
func (c *ChunkerFromUpstream) Validate() error {
	if c.Name == "" {
		return errRequiredField{Field: "name"}
	}
	if !c.OutputFormat.isKnown() {
		return errInvalidValue{Field: "output_format", Value: string(c.OutputFormat)}
	}
	switch c.OutputFormat {
	case PayloadFormatChunks:
		return nil
	case PayloadFormatJSON:
		if c.JSONResult == nil {
			return errRequiredField{Field: "json"}
		}
	case PayloadFormatMarkdown:
		if c.MarkdownResult == nil {
			return errRequiredField{Field: "markdown"}
		}
	case PayloadFormatText:
		if c.TextResult == nil {
			return errRequiredField{Field: "text"}
		}
	case PayloadFormatHTML:
		if c.HTMLResult == nil {
			return errRequiredField{Field: "html"}
		}
	}
	return nil
}

// ChunkerOutputs is the result of invoking any chunker variant. All
// four chunker components emit the same shape: a list of chunk maps
// under the "chunks" key, plus a marker output_format = "chunks".
//
// Mirrors what each chunker sets at the end of _invoke:
//
//	self.set_output("output_format", "chunks")
//	self.set_output("chunks", chunks)
//
// Unlike the Python runtime (which auto-merges every input kwarg into the
// output via ProcessBase.invoke), the Go runtime only forwards the explicit
// component output to the next node. The run-level metadata that downstream
// consumers still need (e.g. Tokenizer reads `name` for title embedding, and
// tenant_id/kb_id for embedding-model resolution) therefore lives in the
// workflow-wide CanvasState.Globals bag — seeded at pipeline start and
// published by the File component — and is read directly from ctx, not
// re-emitted by each chunker. The fields below are the chunker-owned outputs.
type ChunkerOutputs struct {
	// OutputFormat is always "chunks" on success.
	OutputFormat PayloadFormat `json:"output_format,omitempty"`

	// Chunks is the produced chunk list. Each entry is a free-form map
	// mirroring the dict shape the Python code builds (text, doc_type_kwd,
	// tk_nums, PDF_POSITIONS_KEY, mom, img_id, etc.).
	Chunks []ChunkDoc `json:"chunks,omitempty"`

	// Error is set when the component short-circuits with an error
	// message (Python: set_output("_ERROR", ...)).
	Error string `json:"_ERROR,omitempty"`
}

// ---------------------------------------------------------------------------
// TokenChunkerParam
// ---------------------------------------------------------------------------
//
// Mirrors rag/flow/chunker/token_chunker.py:TokenChunkerParam.__init__.
// All fields are user-tunable; defaults match the Python values.

type TokenChunkerParam struct {
	// DelimiterMode selects the chunking strategy.
	// Allowed values: "token_size", "delimiter".
	// The single-chunk "one" behavior is provided by the separate
	// OneChunker component.
	DelimiterMode string `json:"delimiter_mode"`

	// ChunkTokenSize is the target chunk size in tokens.
	ChunkTokenSize int `json:"chunk_token_size"`

	// Delimiters is the list of split tokens. Strings wrapped in
	// backticks (e.g., "`\\n`") denote user-defined regex split points.
	Delimiters []string `json:"delimiters"`

	// OverlappedPercent is the overlap percentage in [0, 90]. Mirrors
	// Python common/float_utils.py:50-58 — an integer-like float in the
	// same range so DSL templates written for the Python pipeline work
	// out of the box  A [0,1) fraction input is also
	// accepted and normalized to this scale by tokenChunkerParam.Update
	// (via normalizeOverlappedPercent).
	OverlappedPercent float64 `json:"overlapped_percent"`

	// ChildrenDelimiters is the secondary split applied to text chunks.
	ChildrenDelimiters []string `json:"children_delimiters"`

	// TableContextSize is the number of surrounding tokens to attach
	// to table chunks. 0 disables.
	TableContextSize int `json:"table_context_size"`

	// ImageContextSize is the number of surrounding tokens to attach
	// to image chunks. 0 disables.
	ImageContextSize int `json:"image_context_size"`
}

// Defaults returns the Python default TokenChunkerParam.
func (TokenChunkerParam) Defaults() TokenChunkerParam {
	return TokenChunkerParam{
		DelimiterMode:      "token_size",
		ChunkTokenSize:     512,
		Delimiters:         []string{"\n"},
		OverlappedPercent:  0,
		ChildrenDelimiters: []string{},
		TableContextSize:   0,
		ImageContextSize:   0,
	}
}

// NumericFromAny normalises JSON-decoded ints to float64 so the
// schema-defaults-vs-Param-Update convention doesn't depend on the
// encoding source (yaml/toml/json all behave the same). It is shared by
// the chunker config decoders and NormalizeOverlappedPercent.
func NumericFromAny(v any) (float64, bool) {
	switch x := v.(type) {
	case float64:
		return x, true
	case float32:
		return float64(x), true
	case int:
		return float64(x), true
	case int32:
		return float64(x), true
	case int64:
		return float64(x), true
	case uint:
		return float64(x), true
	case uint32:
		return float64(x), true
	case uint64:
		return float64(x), true
	}
	return 0, false
}

// NormalizeOverlappedPercent mirrors Python common/float_utils.py:50-58
// (normalize_overlapped_percent). Python's user-facing input is a [0,1)
// fraction (token_chunker.py validates "[0, 1)"); this converts it to the
// [0,90] integer-percentage scale that the merge math (token.go:705,
// `(100-x)/100`) expects, matching Python's canonical norm. Diff Chunker-2.6.
//
// Behaviour matches Python exactly:
//   - parse like float(): numbers, or numeric strings (Python also accepts
//     strings via float()); on any failure / NaN / Inf, return 0
//     (Python: except (TypeError, ValueError, OverflowError) -> 0).
//   - 0 < v < 1 -> v *= 100   (fraction -> percentage)
//   - v = int(v)             (truncation toward zero, matches Python for
//     non-negatives)
//   - clamp to [0, 90]        (Python: max(0, min(v, 90)))
//
// Exported so TokenChunkerParam.Validate can normalize directly-constructed
// structs (not just the config-path Update), keeping both paths identical.
func NormalizeOverlappedPercent(v any) float64 {
	value, ok := NumericFromAny(v)
	if !ok {
		if s, isStr := v.(string); isStr {
			if f, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
				value, ok = f, true
			}
		}
	}
	if !ok || math.IsNaN(value) || math.IsInf(value, 0) {
		return 0
	}
	return clampOverlappedPercent(value)
}

// clampOverlappedPercent applies the [0,1) fraction -> [0,90] percent
// conversion, clamps to [0,90], then truncates to an integer (Python
// faithfulness). Clamping MUST happen before the int conversion: Go's
// float->int is implementation-defined when the value is out of int's range
// (e.g. 1e300), whereas Python's int() is arbitrary-precision, so clamping
// first keeps us aligned with Python's max(0, min(int(v), 90)). Used by the
// lenient config path (NormalizeOverlappedPercent).
func clampOverlappedPercent(value float64) float64 {
	if 0 < value && value < 1 {
		value *= 100
	}
	if value < 0 {
		value = 0
	}
	if value > 90 {
		value = 90
	}
	// Round rather than truncate so fraction inputs like 0.29 normalize
	// to 29 (user expectation) rather than 28. Mirrors Python's
	// common/float_utils.py:50-58 fix for #17418. We still clamp
	// before rounding so out-of-int-range values (e.g. 1e300) land on
	// 90, not an implementation-defined int.
	value = math.Round(value)
	return value
}

// Validate enforces the same enum/range checks the runtime component expects
// at construction time, keeping the schema and component decoder aligned.
func (p *TokenChunkerParam) Validate() error {
	// Strict overlap normalization for directly-constructed structs: scale a
	// [0,1) fraction to a [0,90] percent (matching the config path's
	// semantics) and truncate toward zero, then REJECT anything still outside
	// [0,90]. The config path (Update) clamps instead, so a fraction like 0.3
	// means 30% here too, while an out-of-range value like 95 or -5 is caught
	// rather than silently clamped — eliminating the latent footgun where a
	// fraction passed to a struct bypassed normalization.
	if v := p.OverlappedPercent; 0 < v && v < 1 {
		// Round rather than truncate so fraction inputs like 0.29 normalize
		// to 29 (user expectation) rather than 28. Mirrors Python's
		// common/float_utils.py:50-58 fix for #17418.
		p.OverlappedPercent = math.Round(v * 100)
	}
	switch p.DelimiterMode {
	case "token_size", "delimiter":
	default:
		return errInvalidValue{Field: "delimiter_mode", Value: p.DelimiterMode}
	}
	if p.ChunkTokenSize <= 0 {
		return errInvalidValue{Field: "chunk_token_size", Value: fmt.Sprintf("%d", p.ChunkTokenSize)}
	}
	if p.OverlappedPercent < 0 || p.OverlappedPercent > 90 {
		return errInvalidValue{Field: "overlapped_percent", Value: fmt.Sprintf("%v", p.OverlappedPercent)}
	}
	if p.TableContextSize < 0 {
		return errInvalidValue{Field: "table_context_size", Value: fmt.Sprintf("%d", p.TableContextSize)}
	}
	if p.ImageContextSize < 0 {
		return errInvalidValue{Field: "image_context_size", Value: fmt.Sprintf("%d", p.ImageContextSize)}
	}
	return nil
}

// ---------------------------------------------------------------------------
// TitleChunkerParam
// ---------------------------------------------------------------------------
//
// Mirrors rag/flow/chunker/title_chunker/common.py:TitleChunkerParam.
// The class also reads `self.method` (set externally — see Python
// title_chunker.py:31-37 routing on `self._param.method`). The Go port
// captures it explicitly here. The component's `check()` enforces enum
// values.

type TitleChunkerParam struct {
	// Method routes to the right title-chunker strategy.
	// Allowed values: "hierarchy", "group".
	Method string `json:"method,omitempty"`

	// Levels is a list of regex-list groups, one per hierarchy level.
	// Each group is a list of regex strings; the component picks the
	// best-matching group at runtime.
	Levels [][]string `json:"levels"`

	// Hierarchy is the heading depth used by HierarchyTitleChunker.
	// Stored as a pointer to distinguish nil (unset) from 0.
	Hierarchy *int `json:"hierarchy,omitempty"`

	// IncludeHeadingContent, when true, makes the heading text part
	// of each emitted chunk.
	IncludeHeadingContent bool `json:"include_heading_content"`

	// RootChunkAsHeading, when true, prepends the root chunk's text
	// to every emitted chunk (and drops the root chunk itself).
	RootChunkAsHeading bool `json:"root_chunk_as_heading"`
}

// Defaults returns the Python default TitleChunkerParam. `Method` is
// not initialized in the Python `__init__` (it is set externally); the
// default is left as the empty string and the component must supply it.
func (TitleChunkerParam) Defaults() TitleChunkerParam {
	return TitleChunkerParam{
		Levels:                [][]string{},
		Hierarchy:             nil,
		IncludeHeadingContent: false,
		RootChunkAsHeading:    false,
	}
}

// Validate enforces the Python `check()` invariants that are also
// expressible in pure-data terms: when Method == "hierarchy" the
// hierarchy depth and level config must be present.
func (p *TitleChunkerParam) Validate() error {
	switch p.Method {
	case "hierarchy", "group":
	case "":
		return nil
	default:
		return errInvalidValue{Field: "method", Value: p.Method}
	}
	switch p.Method {
	case "hierarchy", "group":
		if len(p.Levels) == 0 {
			return errRequiredField{Field: "levels"}
		}
	}
	if p.Method == "hierarchy" && (p.Hierarchy == nil || *p.Hierarchy <= 0) {
		return errRequiredField{Field: "hierarchy"}
	}
	return nil
}

// ---------------------------------------------------------------------------
// GroupTitleChunkerParam / HierarchyTitleChunkerParam
// ---------------------------------------------------------------------------
//
// In the Python codebase, both variants share the SAME
// `TitleChunkerParam` class — there is no per-variant param. The
// dispatch happens in title_chunker.py:31-37 by reading
// `self._param.method`.
//
// In Go we model the shared class as `TitleChunkerParam` and expose
// type aliases so component files can name the param type they
// actually use. This keeps the wire schema faithful to Python while
// giving each variant a self-documenting entry point in the registry.

type GroupTitleChunkerParam = TitleChunkerParam
type HierarchyTitleChunkerParam = TitleChunkerParam
