package hackernews

// Input and output JSON Schema builders for the Hacker News catalog. Schemas
// are plain map literals mirroring the ported OpenConnector action contracts;
// upstream declares no description for many fields, so description-bearing
// helpers only emit the key when the ported description is non-empty.

const schemaDialect = "https://json-schema.org/draft/2020-12/schema"

// maxSafeInteger mirrors upstream's Number.MAX_SAFE_INTEGER bounds, shared by
// every Firebase ID and counter schema.
const maxSafeInteger = 9007199254740991

// itemIDStringPattern is the one accepted spelling of an item ID string,
// shared verbatim by the handler guard: one to sixteen digits with no leading
// zero, so neither "0" (Hacker News IDs start at 1) nor "01" validates. The
// length bound cannot also express the safe-integer ceiling — a handful of
// 16-digit values sit above MAX_SAFE_INTEGER — so the handler guard remains
// the authority for that edge and rejects them as validation.
const itemIDStringPattern = "^[1-9][0-9]{0,15}$"

// firebaseItemTypes is the closed Firebase item type enum.
var firebaseItemTypes = []string{"job", "story", "comment", "poll", "pollopt"}

func objectSchema(properties map[string]any, required []string, description string) map[string]any {
	schema := baseObjectSchema(properties, required, description, false)
	return schema
}

func openObjectSchema(properties map[string]any, required []string, description string) map[string]any {
	schema := baseObjectSchema(properties, required, description, true)
	return schema
}

func baseObjectSchema(
	properties map[string]any,
	required []string,
	description string,
	additionalProperties bool,
) map[string]any {
	schema := map[string]any{
		"$schema":              schemaDialect,
		"type":                 "object",
		"additionalProperties": additionalProperties,
		"properties":           properties,
	}
	if description != "" {
		schema["description"] = description
	}
	if len(required) > 0 {
		anyRequired := make([]any, 0, len(required))
		for _, name := range required {
			anyRequired = append(anyRequired, name)
		}
		schema["required"] = anyRequired
	}
	return schema
}

func stringSchema(description string) map[string]any {
	schema := map[string]any{"type": "string"}
	describe(schema, description)
	return schema
}

// nullableStringSchema admits the null the fixed origins really return for
// unset optional strings (a user without an about, for example); the field
// stays optional, so an omitted key also validates.
func nullableStringSchema(description string) map[string]any {
	nullVariant := map[string]any{"type": "null"}
	return unionSchema(stringSchema(description), nullVariant)
}

func booleanSchema(description string) map[string]any {
	schema := map[string]any{"type": "boolean"}
	describe(schema, description)
	return schema
}

func describe(schema map[string]any, description string) {
	if description != "" {
		schema["description"] = description
	}
}

func literalStringSchema(value, description string) map[string]any {
	schema := map[string]any{"const": value, "type": "string"}
	describe(schema, description)
	return schema
}

func stringEnumSchema(description string, values ...string) map[string]any {
	anyValues := make([]any, 0, len(values))
	for _, value := range values {
		anyValues = append(anyValues, value)
	}
	schema := map[string]any{"type": "string", "enum": anyValues}
	describe(schema, description)
	return schema
}

func stringPatternSchema(pattern, description string) map[string]any {
	schema := map[string]any{"type": "string", "pattern": pattern}
	describe(schema, description)
	return schema
}

func arraySchema(items map[string]any, description string) map[string]any {
	schema := map[string]any{"type": "array", "items": items}
	describe(schema, description)
	return schema
}

// nullableSchema wraps one schema as nullable; the anyOf root still declares
// the Draft 2020-12 dialect because the catalog validator requires it on
// document roots.
func nullableSchema(schema map[string]any) map[string]any {
	return map[string]any{
		"$schema": schemaDialect,
		"anyOf":   []any{schema, map[string]any{"type": "null"}},
	}
}

func unionSchema(variants ...map[string]any) map[string]any {
	anyVariants := make([]any, 0, len(variants))
	for _, variant := range variants {
		anyVariants = append(anyVariants, variant)
	}
	return map[string]any{"anyOf": anyVariants}
}

func refSchema(ref string) map[string]any {
	return map[string]any{"$ref": ref}
}

// safeIntegerSchema bounds Firebase IDs and counters to the JavaScript safe
// integer range, mirroring upstream's shared safeInteger schema.
func safeIntegerSchema() map[string]any {
	return map[string]any{
		"type":    "integer",
		"minimum": -maxSafeInteger,
		"maximum": maxSafeInteger,
	}
}

// positiveIDSchema accepts only numeric IDs above zero.
func positiveIDSchema() map[string]any {
	return map[string]any{
		"type":             "integer",
		"exclusiveMinimum": 0,
		"maximum":          maxSafeInteger,
	}
}

func defaultedIntegerSchema(description string, minimum, maximum, defaultValue int) map[string]any {
	schema := map[string]any{
		"type":    "integer",
		"minimum": minimum,
		"maximum": maximum,
		"default": defaultValue,
	}
	describe(schema, description)
	return schema
}

func defaultedBooleanSchema(description string, defaultValue bool) map[string]any {
	schema := map[string]any{"type": "boolean", "default": defaultValue}
	describe(schema, description)
	return schema
}

// prettyInputSchema is the shared Firebase request-options input.
func prettyInputSchema() map[string]any {
	return objectSchema(map[string]any{
		"print": literalStringSchema("pretty", "The pretty-print flag for Firebase responses."),
	}, nil, "Optional Firebase request options.")
}

func getItemInputSchema() map[string]any {
	return objectSchema(map[string]any{
		"id":    positiveIDSchema(),
		"print": literalStringSchema("pretty", ""),
	}, []string{"id"}, "Item lookup input.")
}

func getItemWithIDInputSchema() map[string]any {
	return objectSchema(map[string]any{
		// The numeric-string branch deliberately narrows the baseline's
		// any-length "^\d+$" to itemIDStringPattern, the same set the handler
		// guard enforces. The 16-digit bound still admits a handful of values
		// above MAX_SAFE_INTEGER — the documented safe-integer divergence from
		// the baseline pattern, with the handler guard kept as the authority.
		"item_id": unionSchema(
			positiveIDSchema(),
			stringPatternSchema(itemIDStringPattern, "A numeric item ID as a string."),
		),
		"max_depth": defaultedIntegerSchema(
			"The maximum depth of the nested comment tree.", 0, 10, 2),
		"max_children": defaultedIntegerSchema(
			"The maximum number of children per node.", 0, 100, 10),
		"truncate_text": defaultedBooleanSchema(
			"Whether to truncate long text content.", true),
	}, []string{"item_id", "max_depth", "max_children", "truncate_text"},
		"Nested item lookup input.")
}

func usernameInputSchema() map[string]any {
	return objectSchema(map[string]any{
		"username": stringSchema("The username to look up."),
		"print":    literalStringSchema("pretty", ""),
	}, []string{"username"}, "Hacker News user lookup input.")
}

// searchInputSchema is the shared Algolia search input; search-posts also
// requires the query text.
func searchInputSchema(requireQuery bool) map[string]any {
	properties := map[string]any{
		"page": defaultedIntegerSchema("The page number to fetch.", 0, maxSafeInteger, 0),
		"size": defaultedIntegerSchema("The number of results per page.", 0, 20, 5),
		"tags": arraySchema(stringSchema(""), "Algolia filter tags."),
	}
	required := []string{"page", "size"}
	if requireQuery {
		properties["query"] = stringSchema("The search query text.")
		required = []string{"query", "page", "size"}
	}
	return objectSchema(properties, required, "Algolia search input.")
}

// firebaseItemOutputSchema is the open Firebase item passthrough shape. The
// get-item Action returns the upstream body verbatim, so the human-readable
// optional strings are nullable: the API omits them for deleted or
// type-mismatched items, and a fixed origin may send explicit nulls. id and
// type stay required — every real Firebase item carries both.
func firebaseItemOutputSchema() map[string]any {
	return openObjectSchema(map[string]any{
		"by":          nullableStringSchema("The username of the item author."),
		"descendants": safeIntegerSchema(),
		"id":          safeIntegerSchema(),
		"kids":        arraySchema(safeIntegerSchema(), ""),
		"parent":      safeIntegerSchema(),
		"parts":       arraySchema(safeIntegerSchema(), ""),
		"poll":        safeIntegerSchema(),
		"score":       safeIntegerSchema(),
		"text":        nullableStringSchema(""),
		"time":        safeIntegerSchema(),
		"title":       nullableStringSchema(""),
		"type":        stringEnumSchema("", firebaseItemTypes...),
		"url":         nullableStringSchema(""),
		"dead":        booleanSchema(""),
		"deleted":     booleanSchema(""),
	}, []string{"id", "type"}, "A Hacker News item from the Firebase API.")
}

// treeItemOutputSchema is the recursive nested comment-tree node. id and type
// stay required because the assembly guards them in code — an item is only
// admitted as a node when it carries a safe-integral id and one of the
// declared types, and copied passthrough fields are copied only when their
// declared type matches. Under the AgentTeam-side assembly budget,
// children_truncated is true both when the per-node max_children cut the kid
// list and when the aggregate budget stopped expansion at that node.
func treeItemOutputSchema() map[string]any {
	return openObjectSchema(map[string]any{
		"id":                   safeIntegerSchema(),
		"url":                  stringSchema(""),
		"text":                 stringSchema(""),
		"type":                 stringEnumSchema("", firebaseItemTypes...),
		"title":                stringSchema(""),
		"author":               stringSchema(""),
		"points":               safeIntegerSchema(),
		"options":              arraySchema(safeIntegerSchema(), ""),
		"children":             arraySchema(refSchema("#/$defs/treeItem"), ""),
		"story_id":             safeIntegerSchema(),
		"parent_id":            safeIntegerSchema(),
		"created_at":           stringSchema(""),
		"created_at_i":         safeIntegerSchema(),
		"children_shown":       safeIntegerSchema(),
		"max_depth_reached":    booleanSchema(""),
		"children_truncated":   booleanSchema(""),
		"total_children_count": safeIntegerSchema(),
	}, []string{"id", "type"}, "A Hacker News item with bounded nested children.")
}

// budgetExhaustedSchema declares the AgentTeam-side truncation flag on the
// get-item-with-id envelope; upstream has no aggregate assembly budget and so
// no such flag.
func budgetExhaustedSchema() map[string]any {
	return booleanSchema(
		"Whether the AgentTeam-side assembly budget stopped the tree expansion early.")
}

// nestedItemOutputSchema is the get-item-with-id result envelope.
func nestedItemOutputSchema() map[string]any {
	schema := objectSchema(map[string]any{
		"found":            booleanSchema(""),
		"item":             refSchema("#/$defs/treeItem"),
		"error_message":    stringSchema(""),
		"budget_exhausted": budgetExhaustedSchema(),
	}, []string{"found"}, "Nested item lookup result.")
	schema["$defs"] = map[string]any{"treeItem": treeItemOutputSchema()}
	return schema
}

// searchOutputSchema declares the Algolia envelope as a loose passthrough
// object. This is a deliberate divergence from the stricter upstream schema
// (which declared hits/nbHits/page/nbPages/hitsPerPage and closed the hit
// object): the real Algolia envelope carries additional keys (params,
// exhaustive*, processingTimeMS, serverTimeMS, ...) and hit fields that are
// null for mismatched hit types, so only the outer object shape is pinned.
// The handler enforces exactly that shape and returns the body verbatim.
func searchOutputSchema(description string) map[string]any {
	return map[string]any{
		"$schema":              schemaDialect,
		"type":                 "object",
		"additionalProperties": true,
		"description":          description,
	}
}

func maxItemIDOutputSchema() map[string]any {
	return objectSchema(map[string]any{
		"max_item_id": safeIntegerSchema(),
	}, []string{"max_item_id"}, "Current largest Hacker News item ID.")
}

// storyIDsOutputSchema is the shared story-feed output: the verbatim
// story_ids list, plus the returned-ID count for the feeds upstream declares
// counted (best, new, top).
func storyIDsOutputSchema(includeCount bool) map[string]any {
	properties := map[string]any{
		"story_ids": arraySchema(safeIntegerSchema(), "Hacker News item IDs."),
	}
	required := []string{"story_ids"}
	description := "Story ID list returned by the Hacker News Firebase API."
	if includeCount {
		properties["count"] = safeIntegerSchema()
		required = []string{"story_ids", "count"}
		description = "Story ID list with the number of returned IDs."
	}
	return objectSchema(properties, required, description)
}

func updatesOutputSchema() map[string]any {
	return objectSchema(map[string]any{
		"items":    arraySchema(safeIntegerSchema(), ""),
		"profiles": arraySchema(stringSchema(""), ""),
	}, []string{"items", "profiles"}, "Recently changed Hacker News items and profiles.")
}

func userSummaryOutputSchema() map[string]any {
	return objectSchema(map[string]any{
		"username": stringSchema(""),
		"karma":    safeIntegerSchema(),
		// The API omits about for users without one and can send an explicit
		// null; the summary passes it through verbatim.
		"about": nullableStringSchema(""),
	}, []string{"username", "karma"}, "A Hacker News user profile summary.")
}

func userDetailOutputSchema() map[string]any {
	return objectSchema(map[string]any{
		"id":        stringSchema(""),
		"about":     nullableStringSchema(""),
		"karma":     safeIntegerSchema(),
		"created":   safeIntegerSchema(),
		"submitted": arraySchema(safeIntegerSchema(), ""),
	}, []string{"id", "karma", "created"}, "A Hacker News user profile from the Firebase API.")
}
