#!/usr/bin/env python3
"""
Hunger Jolt Uptime Cracker (Dart Spark 2026, based on code by repeater64)

This script cracks and returns all HUD ticks up to 8 hours that could have
produced a given jolt pattern.

Jolt code input:    "<foodLevel>[H] <pattern>"
Example:            "18H -000+0--+0"

Food level is measured in half points, so a full hunger bar is then 10*2=20 food points.
Add the optional `H` suffix when the player was on low health. That tells the cracker
to account for the 10 heart-shake RNG calls that happen before the hunger jolt.
The pattern is how each hunger slot icon moves during the jolt/shake
+ for up
0 for doesn't move
- for down

Uptime output:      "<hours>:<minutes>:<seconds> (<ticks> ticks)"
Example:            "0:7:20 (8800 ticks)"
"""

from __future__ import annotations

import sys
from dataclasses import dataclass
from typing import List


# Java's Random uses a 48-bit linear congruential generator. These constants
# match the JDK implementation exactly: https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/Random.java
JAVA_RANDOM_MULTIPLIER = 0x5DEECE66D
JAVA_RANDOM_ADDEND = 0xB
JAVA_RANDOM_MASK = (1 << 48) - 1

# Minecraft hunger jolt uses `hudTick * 312871` as the external seed: https://cocalc.com/github/minecraftforge/minecraftforge/blob/1.20.x/src/main/java/net/minecraftforge/client/gui/overlay/ForgeGui.java
HUD_SEED_MULTIPLIER = 312871

# A jolt pattern always describes the 10 hunger slots.
ICON_COUNT = 10
MAX_UPTIME_HOURS = 8
MAX_UPTIME_TICKS = MAX_UPTIME_HOURS * 60 * 60 * 20


def int32(value: int) -> int:
    """
    Force a Python integer through signed 32-bit overflow semantics.

    This matters because the original Kotlin/Java-side code multiplies
    `tickCount * 312871` as an `Int` before it becomes a `Long`.
    That means values beyond the 32-bit signed range wrap around first.

    Python integers do not overflow on their own, so we must emulate that
    explicitly or the generated patterns will diverge from the real game.
    """
    value &= 0xFFFFFFFF
    if value >= 0x80000000:
        value -= 0x100000000
    return value


class JavaRandom:
    """
    Minimal emulation of `java.util.Random`.

    Only the parts we need are implemented:
    - `set_seed`
    - `next_bits`
    - `next_int(bound)`

    This is intentionally written to look close to the JDK algorithm so that
    the mapping to Java/Kotlin/C++ code stays obvious.
    """

    def __init__(self) -> None:
        self._seed = 0

    def set_seed(self, external_seed: int) -> None:
        """
        Set the internal 48-bit LCG state exactly like Java Random.

        Java does not store the external seed directly. It first xors with the
        multiplier constant and then masks down to 48 bits.
        """
        self._seed = (external_seed ^ JAVA_RANDOM_MULTIPLIER) & JAVA_RANDOM_MASK

    def next_bits(self, bit_count: int) -> int:
        """
        Advance the LCG once and return the high `bit_count` bits.

        This is the primitive every higher-level Java Random method uses.
        """
        self._seed = (
            (self._seed * JAVA_RANDOM_MULTIPLIER + JAVA_RANDOM_ADDEND)
            & JAVA_RANDOM_MASK
        )
        return self._seed >> (48 - bit_count)

    def next_int(self, bound: int) -> int:
        """
        Java-compatible `nextInt(bound)`.

        The rejection loop is important. Using a simpler modulo-based helper
        would bias the distribution and produce incorrect hunger patterns.
        """
        if bound <= 0:
            raise ValueError("bound must be positive")

        while True:
            bits = self.next_bits(31)
            value = bits % bound
            if bits - value + (bound - 1) >= 0:
                return value


@dataclass(frozen=True)
class JoltPattern:
    """
    Internal representation of the vertical offsets for the 10 hunger icons.

    Each offset is one of:
    - `-1` : icon rendered one pixel up
    - ` 0` : icon at baseline
    - `+1` : icon rendered one pixel down

    Note that screen coordinates have the y axis pointing down, so lower down means higher y.

    The internal order used here matches the generator order, not
    the left-to-right visual order the user sees on screen.
    """

    offsets: tuple[int, ...]

    def __post_init__(self) -> None:
        if len(self.offsets) != ICON_COUNT:
            raise ValueError(f"jolt pattern must contain {ICON_COUNT} offsets")
        for value in self.offsets:
            if value not in (-1, 0, 1):
                raise ValueError("offsets must only contain -1, 0, or 1")

    @classmethod
    def from_visual_code(cls, visual_code: str) -> "JoltPattern":
        """
        Parse the visual pattern string used by MCV Studio.

        Visual code mapping:
        - `+` means the icon is high in the video, which corresponds to offset -1
        - `0` means baseline, offset 0
        - `-` means low in the video, offset +1

        The visual string is left-to-right for the user, but the generator order
        is mirrored relative to that visual order. We therefore reverse the
        parsed sequence to convert Dart's jolt code convention.
        """
        visual_code = visual_code.strip()
        if len(visual_code) != ICON_COUNT:
            raise ValueError(f"pattern must be exactly {ICON_COUNT} characters")

        visual_offsets: List[int] = []
        for char in visual_code:
            if char == "+":
                visual_offsets.append(-1)
            elif char == "0":
                visual_offsets.append(0)
            elif char == "-":
                visual_offsets.append(1)
            else:
                raise ValueError(
                    "pattern may only contain '+', '0', and '-' characters"
                )

        # Reverse into generator order.
        return cls(tuple(reversed(visual_offsets)))

    def to_visual_code(self) -> str:
        """
        Convert the internal pattern back to the user-facing visual code.
        """
        chars: List[str] = []
        for offset in reversed(self.offsets):
            if offset < 0:
                chars.append("+")
            elif offset > 0:
                chars.append("-")
            else:
                chars.append("0")
        return "".join(chars)


@dataclass(frozen=True)
class JoltCode:
    foodLevel: int
    lowHealth: bool
    pattern: JoltPattern

    @classmethod
    def parse(cls, raw_text: str) -> "JoltCode":
        """
        Parse the interactive user input into a structured jolt code.

        Supported input form:
            "<foodLevel>[H] <pattern>"

        The hunger points must be a non-negative integer because cracking uses
        the hunger rule:

            interval = foodLevel * 3 + 1
        """
        pieces = raw_text.strip().split()
        if len(pieces) != 2:
            raise ValueError("expected format: '<foodLevel>[H] <pattern>'")

        food_text, pattern_text = pieces
        if food_text == "?":
            raise ValueError("cannot crack a jolt code with unknown hunger points")

        low_health = False
        if food_text.upper().endswith("H"):
            low_health = True
            food_text = food_text[:-1]

        try:
            foodLevel = int(food_text)
        except ValueError as exc:
            raise ValueError("hunger points must be an integer") from exc

        if foodLevel < 0:
            raise ValueError("hunger points must be non-negative")

        pattern = JoltPattern.from_visual_code(pattern_text)
        return cls(foodLevel=foodLevel, lowHealth=low_health, pattern=pattern)

    def to_string(self) -> str:
        return f"{self.foodLevel}{'H' if self.lowHealth else ''} {self.pattern.to_visual_code()}"

    @property
    def interval(self) -> int:
        """
        Hunger jolt interval in HUD ticks.

        Minecraft's hunger jolt only occurs when:

            hudTick mod (foodLevel * 3 + 1) == 0
        """
        return self.foodLevel * 3 + 1


@dataclass(frozen=True)
class JoltCrackMatch:
    """
    A solved match: a single HUD tick value that reproduces the input pattern.
    """

    tick: int

    def uptime_string(self) -> str:
        """
        Convert HUD ticks to a timecode.

        Minecraft client logic advances at 20 ticks per second.
        """
        total_seconds = self.tick // 20
        seconds = total_seconds % 60
        total_minutes = total_seconds // 60
        minutes = total_minutes % 60
        hours = total_minutes // 60
        return f"{hours}:{minutes}:{seconds}"

    def to_display_string(self) -> str:
        """
        Produce the user-facing copy/export format.

        Example:
            "0:7:20 (8800 ticks)"
        """
        return f"{self.uptime_string()} ({self.tick} ticks)"


def jolt_at_tick(hud_tick: int, low_health: bool = False) -> JoltPattern:
    """
    Reproduce Minecraft's hunger-jolt pattern for a specific HUD tick.

    This mirrors the in-app and Kotlin reference logic:

        seed = int32(hudTick * 312871)
        rng.setSeed(seed)
        repeat 10 times:
            nextInt(3) - 1
    """
    rng = JavaRandom()
    rng.set_seed(int32(hud_tick * HUD_SEED_MULTIPLIER))
    if low_health:
        for _ in range(ICON_COUNT):
            rng.next_int(2)

    offsets = tuple(rng.next_int(3) - 1 for _ in range(ICON_COUNT))
    return JoltPattern(offsets)


def find_all_matches_up_to(code: JoltCode, upper_bound_tick: int) -> List[JoltCrackMatch]:
    """
    Return every valid HUD tick match up to an inclusive upper bound.
    """
    if upper_bound_tick < 0:
        return []

    matches: List[JoltCrackMatch] = []
    for tick in range(0, upper_bound_tick + 1, code.interval):
        if jolt_at_tick(tick, code.lowHealth) == code.pattern:
            matches.append(JoltCrackMatch(tick=tick))
    return matches

def find_first_match(code: JoltCode) -> JoltCrackMatch:
    """
    Return the first HUD tick that reproduces the given jolt code.

    We only test valid hunger-jolt ticks, stepping by the interval derived
    from the hunger value instead of scanning every tick.
    """
    tick = 0
    while True:
        if jolt_at_tick(tick, code.lowHealth) == code.pattern:
            return JoltCrackMatch(tick=tick)
        tick += code.interval

def prompt_for_jolt_code() -> JoltCode:
    raw = input("Jolt Code: ")
    return JoltCode.parse(raw)


def parse_cli_jolt_code(argv: List[str]) -> JoltCode:
    """
    Allow either:
        python jolt_cracker.py
    or:
        python jolt_cracker.py 18H -000+0--+0
    """
    if len(argv) <= 1:
        return prompt_for_jolt_code()

    if len(argv) != 3:
        raise ValueError(
            "expected either no arguments or: python jolt_cracker.py <foodLevel>[H] <pattern>"
        )

    return JoltCode.parse(f"{argv[1]} {argv[2]}")


def main() -> None:
    """
    Entry point.

    Behavior:
    - if no arguments are given, prompt for one jolt code
    - if arguments are given, parse them as <foodLevel>[H] <pattern>
    - crack all matches up to 8 hours
    - print one resolved uptime per line
    """
    try:
        code = parse_cli_jolt_code(sys.argv)
        matches = find_all_matches_up_to(code, MAX_UPTIME_TICKS)
        for match in matches:
            print(match.to_display_string())
    except KeyboardInterrupt:
        print()
    except Exception as exc:
        print(f"Error: {exc}")


if __name__ == "__main__":
    main()