/*
 * SPDX-License-Identifier: GPL-3.0-or-later
 */
package com.runicgateway.app.data.api.dto

import kotlinx.serialization.Serializable

/**
 * DTOs for the four shard-content surfaces Protocol 3.0 added (PLAN.md §9 M11):
 * the ruleset, the points leaderboards, the player-vendor marketplace, and the spawn
 * atlas. Shapes mirror the website's `public/shard.controller.js` + `public/atlas.
 * controller.js` responses; see `docs/link/v3.md` §5–§8.
 *
 * Every field is nullable-with-a-default, which is load-bearing rather than merely
 * defensive here: an admin can gate individual fields away per audience rung
 * (`ownerName`, `location`, a board's `name`), so a response legitimately arrives
 * with them missing and must still decode.
 */

// ── Ruleset (§5) ────────────────────────────────────────────────────────────

/**
 * `GET /public/shard/ruleset` — what this shard's world is configured to do.
 *
 * Every block is optional and omitted when its system is off, so a null block means
 * "not applicable here", not "unknown". A `null` BODY (rather than an empty object)
 * means the shard has never published a ruleset — distinct from the feature being
 * switched off, which is a 404.
 */
@Serializable
data class RulesetDto(
    val shard: String? = null,
    val expansion: String? = null,
    /**
     * The public connect address, published only when the operator set one. It is
     * also the ruleset's one admin-configurable field, so it can be present for a
     * signed-in viewer and absent for an anonymous one.
     */
    val connect: String? = null,
    /** A flat bag of on/off flags — `cityLoyalty`, `vvv`, `siege`, `chat`, … */
    val systems: Map<String, Boolean> = emptyMap(),
    val caps: RulesetCapsDto? = null,
    val accounts: RulesetAccountsDto? = null,
    val housing: RulesetHousingDto? = null,
    val vetRewards: RulesetVetRewardsDto? = null,
    val vendors: RulesetVendorsDto? = null,
    val vvv: RulesetVvvDto? = null,
    val store: RulesetStoreDto? = null,
    val schedule: RulesetScheduleDto? = null,
    val updatedAt: String? = null,
)

/**
 * Skill and stat caps.
 *
 * **[skill] and [totalSkill] are in TENTHS** — 1000 is 100.0 — the way ServUO stores
 * them, and the raw number is actively misleading rather than merely unhelpful (a
 * "1000 skill cap" reads as a shard with ten times the usual limit). Use [skillCap]
 * and [totalSkillCap]. The stat caps below them are plain values.
 */
@Serializable
data class RulesetCapsDto(
    val skill: Int? = null,
    val totalSkill: Int? = null,
    val stat: Int? = null,
    val str: Int? = null,
    val dex: Int? = null,
    val int: Int? = null,
    val strMax: Int? = null,
    val dexMax: Int? = null,
    val intMax: Int? = null,
) {
    val skillCap: Double? get() = skill?.let { it / 10.0 }
    val totalSkillCap: Double? get() = totalSkill?.let { it / 10.0 }
}

@Serializable
data class RulesetAccountsDto(
    val perIp: Int? = null,
    val charSlots: Int? = null,
    val autoCreate: Boolean? = null,
)

@Serializable
data class RulesetHousingDto(val accountHouseLimit: Int? = null)

@Serializable
data class RulesetVetRewardsDto(
    val enabled: Boolean? = null,
    val rewardIntervalDays: Int? = null,
)

@Serializable
data class RulesetVendorsDto(
    val restockDelayMinutes: Int? = null,
    val maxSell: Int? = null,
    val economyStockAmount: Int? = null,
)

@Serializable
data class RulesetVvvDto(
    val enabled: Boolean? = null,
    val startSilver: Int? = null,
    val enhancedRules: Boolean? = null,
)

@Serializable
data class RulesetStoreDto(
    val enabled: Boolean? = null,
    val currencyName: String? = null,
)

@Serializable
data class RulesetScheduleDto(
    val autoSaveFrequencyMinutes: Int? = null,
    val autoRestartEnabled: Boolean? = null,
    val autoRestartHour: Int? = null,
    val autoRestartMinute: Int? = null,
)

// ── Leaderboards (§7) ───────────────────────────────────────────────────────

/**
 * One point system's board (`GET /public/shard/points`, `/points/:system`).
 *
 * [maxPoints] `0` means **uncapped** and is the common case, and [nameString] is
 * usually null because most systems name themselves with a cliloc — the same two
 * traps as [CharPointsDto], documented in full there.
 *
 * [players] counts players actually *holding* points, not the entry count: ten of the
 * shard's systems auto-add a zero-point row for every character ever created, so the
 * raw count would report the whole census as one system's participants.
 */
@Serializable
data class PointsBoardDto(
    val system: String? = null,
    val nameString: String? = null,
    val nameNumber: Int? = null,
    val maxPoints: Long? = null,
    val players: Int? = null,
    val showOnGump: Boolean = true,
    val top: List<PointsEntryDto> = emptyList(),
    val t: Long? = null,
    val updatedAt: String? = null,
) {
    /** The cap, or null when the system is uncapped. */
    val cap: Long? get() = maxPoints?.takeIf { it > 0 }
}

/**
 * A ranked character on a board. [name] is admin-configurable (the `leaderboards`
 * feature's one field rule), so a shard can publish standings without naming who
 * holds them — a rank with no name is a valid row, not a broken one.
 */
@Serializable
data class PointsEntryDto(
    val rank: Int? = null,
    val serial: String? = null,
    val name: String? = null,
    val points: Long? = null,
)

// ── Marketplace (§8) ────────────────────────────────────────────────────────

/**
 * Where a shop stands. **Nested, not flattened**, on the wire and in the read model
 * alike, so that ONE admin rule hides the facet, the coordinates, the region and the
 * house together — five flat keys would be five rules that drift apart (`v3.md` §8.8).
 * A null location means an admin gated it away; render that as an answer, not a blank.
 */
@Serializable
data class MarketLocationDto(
    val map: String? = null,
    val x: Int? = null,
    val y: Int? = null,
    val z: Int? = null,
    val region: String? = null,
    val house: String? = null,
)

/** The shop a listing belongs to, as embedded in a search result. */
@Serializable
data class MarketVendorRefDto(
    val serial: String? = null,
    val shopName: String? = null,
    val ownerName: String? = null,
    val location: MarketLocationDto? = null,
)

/**
 * One item for sale. [displayName] is resolved server-side against the site's cliloc
 * table, preferring a player-set [name]; a shard with no cliloc table configured sends
 * neither and the item renders by id.
 *
 * [child] marks an item priced by an enclosing container rather than itself, exactly
 * as the in-game Vendor Search reports it.
 */
@Serializable
data class MarketListingDto(
    val serial: String? = null,
    val itemId: Int? = null,
    val hue: Int? = null,
    val amount: Int? = null,
    val price: Long? = null,
    val name: String? = null,
    val cliloc: Int? = null,
    val displayName: String? = null,
    val child: Boolean = false,
    val vendor: MarketVendorRefDto? = null,
) {
    /** What to call this item; null when the shard publishes no name for it. */
    val label: String? get() = name ?: displayName
}

/**
 * A page of search results (`GET /public/shard/market`).
 *
 * Returns **listings, not vendors**: "who sells a vanquishing kryss and for how much"
 * is the question, and a vendor-shaped result would make every caller flatten the
 * shops back out.
 *
 * [staleAt] is the oldest vendor timestamp in the index and **must be surfaced**. The
 * shard sweeps vendors round-robin, so a listing can legitimately be a full cycle old;
 * a page implying live prices sends someone to an item that sold twenty minutes ago.
 */
@Serializable
data class MarketPageDto(
    val listings: List<MarketListingDto> = emptyList(),
    val total: Int = 0,
    val limit: Int? = null,
    val offset: Int? = null,
    val vendors: Int? = null,
    val staleAt: String? = null,
)

/**
 * One shop and its stock (`GET /public/shard/market/vendors/:serial`).
 *
 * [truncated] means the shard publishes only the first `MarketMaxListings` of a larger
 * inventory — [count] is what is published, [total] what the shop holds. Saying so is
 * the point of this screen: a search result list cannot express it.
 */
@Serializable
data class MarketVendorDto(
    val serial: String? = null,
    val shopName: String? = null,
    val ownerSerial: String? = null,
    val ownerName: String? = null,
    val location: MarketLocationDto? = null,
    val count: Int? = null,
    val total: Int? = null,
    val truncated: Boolean = false,
    val updatedAt: String? = null,
    val items: List<MarketListingDto> = emptyList(),
)

/** Index size, staleness and the filter options that actually hold vendors. */
@Serializable
data class MarketMetaDto(
    val vendors: Int = 0,
    val items: Int = 0,
    val staleAt: String? = null,
    val freshAt: String? = null,
    val maps: List<String> = emptyList(),
    val regions: List<String> = emptyList(),
)

// ── Spawn atlas (§6) ────────────────────────────────────────────────────────

/**
 * A creature in the bestiary. Served from `/public/atlas`, **not** `/public/shard`:
 * the atlas is static shard *content* parsed from the server's own data files, not
 * live shard *state*, so it does not go offline with the sidecar — but unlike the
 * shard routes it IS site-mode gated, like posts and the wiki.
 *
 * [points] is a **count** of spawners; [spawners] is the list, and only the
 * single-creature route sends it. The two names are one letter apart in meaning and
 * were deliberately separated (`v3.md` §6.3) — do not reuse one for the other.
 */
@Serializable
data class AtlasCreatureDto(
    val slug: String? = null,
    val name: String? = null,
    /** How many can be alive at once, summed across every spawner. */
    val total: Int? = null,
    /** How many spawners mention this creature. */
    val points: Int? = null,
    /** Spawner count per facet. */
    val facets: Map<String, Int> = emptyMap(),
    /**
     * Where it appears, aggregated per named place — the detail route only, and the
     * answer the whole screen exists to give. **Objects, not strings:** the server
     * sends `{facet, label, spawners, maxAlive}`, and typing this `List<String>`
     * made the detail route fail to decode entirely.
     */
    val places: List<AtlasPlaceDto> = emptyList(),
    /**
     * Operator-supplied sprite file name under `/uploads/atlas/`, or null — which is
     * the normal state, since no artwork ships. Neither client renders it yet; the
     * field is carried so a decode never depends on that staying true.
     */
    val art: String? = null,
    val spawners: List<AtlasSpawnerDto> = emptyList(),
    val spawnersTruncated: Boolean = false,
    /** Creatures sharing its spawners — the detail route only. */
    val alsoHere: List<AtlasCreatureDto> = emptyList(),
)

/**
 * One named place a creature spawns in, already aggregated across its spawners.
 *
 * [label] is the server's point-in-rect resolution of raw coordinates ("Shrines",
 * "Isamu-Jima", "Yew"), falling back to the nearest landmark and finally
 * "Wilderness" — turning a list of coordinates into an answer.
 */
@Serializable
data class AtlasPlaceDto(
    val facet: String? = null,
    val label: String? = null,
    /** Spawners in this place. */
    val spawners: Int? = null,
    /** How many can be alive at once here, summed across those spawners. */
    val maxAlive: Int? = null,
)

/**
 * One spawn point.
 *
 * **[minDelay] / [maxDelay] are SECONDS**, normalised by the server's parser.
 * XmlSpawner writes them in minutes *except* when a delay doesn't divide into whole
 * minutes, flagging that per record — so the raw file has `5` meaning five minutes on
 * one spawner and five seconds on the next, both plausible. The API and this client
 * carry seconds throughout.
 */
@Serializable
data class AtlasSpawnerDto(
    val id: Long? = null,
    val facet: String? = null,
    val name: String? = null,
    val x: Int? = null,
    val y: Int? = null,
    val maxCount: Int? = null,
    val minDelay: Int? = null,
    val maxDelay: Int? = null,
    val region: String? = null,
    val landmark: String? = null,
    /** The server's own "Despise, Felucca" style placement label. */
    val label: String? = null,
)

/** A page of creature search results (`GET /public/atlas/creatures`). */
@Serializable
data class AtlasCreaturePageDto(
    val creatures: List<AtlasCreatureDto> = emptyList(),
    val total: Int = 0,
    val limit: Int? = null,
    val offset: Int? = null,
)

/** When the atlas was last derived from the shard's data files, and what it holds. */
@Serializable
data class AtlasMetaDto(
    val importedAt: String? = null,
    val generatedAt: String? = null,
    val counts: Map<String, Int> = emptyMap(),
    val facets: List<String> = emptyList(),
)
