from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
import re
import tomllib
from typing import Literal, TypeAlias, cast

from ._text import split_lines_with_endings


class PackTomlError(ValueError):
    """Raised when pack.toml does not match the supported manifest structure."""


@dataclass(frozen=True)
class PackTomlBlankLine:
    raw_text: str
    line_ending: str = ""
    kind: Literal["blank"] = "blank"


@dataclass(frozen=True)
class PackTomlCommentLine:
    raw_text: str
    line_ending: str = ""
    kind: Literal["comment"] = "comment"


@dataclass(frozen=True)
class PackTomlTableLine:
    raw_text: str
    name: str
    line_ending: str = ""
    kind: Literal["table"] = "table"


@dataclass(frozen=True)
class PackTomlKeyValueLine:
    raw_lines: tuple[str, ...]
    line_endings: tuple[str, ...]
    table_name: str
    key: str
    value: object
    kind: Literal["key_value"] = "key_value"


PackTomlLine: TypeAlias = (
    PackTomlBlankLine | PackTomlCommentLine | PackTomlTableLine | PackTomlKeyValueLine
)


@dataclass(frozen=True)
class PackInfo:
    name: str
    rimworld_version: str
    pack_format_version: int
    author: str | None = None
    description: str | None = None


@dataclass(frozen=True)
class PackLayout:
    mod_files: tuple[str, ...]


@dataclass(frozen=True)
class PackPaths:
    config_dir: str | None = None
    ordering_rule_files: tuple[str, ...] = ()
    activation_rule_files: tuple[str, ...] = ()
    diagnostics_file: str | None = None
    resolved_load_order_file: str | None = None
    local_mods_dir: str | None = None


@dataclass(frozen=True)
class PackTomlManifest:
    pack: PackInfo
    layout: PackLayout
    paths: PackPaths
    lines: tuple[PackTomlLine, ...] = field(default=(), compare=False)
    source_text: str = field(default="", compare=False)


_SECTION_RE = re.compile(r"^\s*\[(?P<name>[A-Za-z0-9_.-]+)\]\s*(?:#.*)?$")
_KEY_VALUE_RE = re.compile(r"^\s*(?P<key>[A-Za-z0-9_.-]+)\s*=\s*(?P<value>.*)$")


def parse_pack_toml(toml_text: str) -> PackTomlManifest:
    parsed_lines = split_lines_with_endings(toml_text)
    nodes: list[PackTomlLine] = []
    table_values: dict[str, dict[str, object]] = {}
    seen_assignments: set[tuple[str, str]] = set()
    seen_tables: set[str] = set()

    current_table: str | None = None
    index = 0
    while index < len(parsed_lines):
        raw_text, line_ending = parsed_lines[index]

        if raw_text.strip() == "":
            nodes.append(PackTomlBlankLine(raw_text=raw_text, line_ending=line_ending))
            index += 1
            continue

        if raw_text.lstrip().startswith("#"):
            nodes.append(PackTomlCommentLine(raw_text=raw_text, line_ending=line_ending))
            index += 1
            continue

        section_match = _SECTION_RE.fullmatch(raw_text)
        if section_match is not None:
            section_name = section_match.group("name")
            if section_name in seen_tables:
                raise PackTomlError(f"duplicate table [{section_name}]")
            seen_tables.add(section_name)
            current_table = section_name
            nodes.append(
                PackTomlTableLine(
                    raw_text=raw_text,
                    name=section_name,
                    line_ending=line_ending,
                ),
            )
            index += 1
            continue

        if current_table is None:
            raise PackTomlError(
                f"top-level assignments are not supported (line {index + 1})",
            )

        key_value_match = _KEY_VALUE_RE.fullmatch(raw_text)
        if key_value_match is None:
            raise PackTomlError(f"unsupported TOML syntax at line {index + 1}")

        raw_assignment_lines, assignment_line_endings, next_index = _collect_assignment_lines(
            parsed_lines,
            start_index=index,
            initial_value_text=key_value_match.group("value"),
        )
        key = key_value_match.group("key")
        assignment_key = (current_table, key)
        if assignment_key in seen_assignments:
            raise PackTomlError(f"duplicate field [{current_table}].{key}")
        seen_assignments.add(assignment_key)

        value = _parse_assignment_value(
            raw_assignment_lines,
            start_line=index + 1,
            key=key,
        )
        table_values.setdefault(current_table, {})[key] = value
        nodes.append(
            PackTomlKeyValueLine(
                raw_lines=raw_assignment_lines,
                line_endings=assignment_line_endings,
                table_name=current_table,
                key=key,
                value=value,
            ),
        )
        index = next_index

    return PackTomlManifest(
        pack=PackInfo(
            name=_require_non_empty_string(table_values, "pack", "name"),
            rimworld_version=_require_non_empty_string(
                table_values,
                "pack",
                "rimworld_version",
            ),
            pack_format_version=_require_supported_pack_format_version(table_values),
            author=_optional_non_empty_string(table_values, "pack", "author"),
            description=_optional_non_empty_string(
                table_values,
                "pack",
                "description",
            ),
        ),
        layout=PackLayout(
            mod_files=_require_relative_path_array(
                table_values,
                "layout",
                "mod_files",
                require_non_empty=True,
            ),
        ),
        paths=PackPaths(
            config_dir=_optional_relative_path(table_values, "paths", "config_dir"),
            ordering_rule_files=_optional_relative_path_array(
                table_values,
                "paths",
                "ordering_rule_files",
            ),
            activation_rule_files=_optional_relative_path_array(
                table_values,
                "paths",
                "activation_rule_files",
            ),
            diagnostics_file=_optional_relative_path(
                table_values,
                "paths",
                "diagnostics_file",
            ),
            resolved_load_order_file=_optional_relative_path(
                table_values,
                "paths",
                "resolved_load_order_file",
            ),
            local_mods_dir=_optional_relative_path(
                table_values,
                "paths",
                "local_mods_dir",
            ),
        ),
        lines=tuple(nodes),
        source_text=toml_text,
    )


def load_pack_toml(path: Path) -> PackTomlManifest:
    return parse_pack_toml(path.read_text(encoding="utf-8"))


def render_pack_toml(manifest: PackTomlManifest) -> str:
    if manifest.lines:
        return "".join(render_pack_toml_line(line) for line in manifest.lines)

    lines: list[PackTomlLine] = [
        PackTomlTableLine(raw_text="[pack]", name="pack", line_ending="\n"),
        _new_string_key_value_line("pack", "name", manifest.pack.name, line_ending="\n"),
        _new_string_key_value_line(
            "pack",
            "rimworld_version",
            manifest.pack.rimworld_version,
            line_ending="\n",
        ),
        _new_scalar_key_value_line(
            "pack",
            "pack_format_version",
            manifest.pack.pack_format_version,
            line_ending="\n",
        ),
    ]

    if manifest.pack.author is not None:
        lines.append(
            _new_string_key_value_line(
                "pack",
                "author",
                manifest.pack.author,
                line_ending="\n",
            ),
        )
    if manifest.pack.description is not None:
        lines.append(
            _new_string_key_value_line(
                "pack",
                "description",
                manifest.pack.description,
                line_ending="\n",
            ),
        )

    lines.extend(
        (
            PackTomlBlankLine(raw_text="", line_ending="\n"),
            PackTomlTableLine(raw_text="[layout]", name="layout", line_ending="\n"),
            _new_array_key_value_line(
                "layout",
                "mod_files",
                manifest.layout.mod_files,
                line_ending="\n",
            ),
        ),
    )

    path_lines = _render_paths_section(manifest.paths)
    if path_lines:
        lines.extend(
            (
                PackTomlBlankLine(raw_text="", line_ending="\n"),
                PackTomlTableLine(raw_text="[paths]", name="paths", line_ending="\n"),
                *path_lines,
            ),
        )

    return "".join(render_pack_toml_line(line) for line in lines)


def render_pack_toml_line(line: PackTomlLine) -> str:
    if isinstance(line, PackTomlBlankLine | PackTomlCommentLine | PackTomlTableLine):
        return f"{line.raw_text}{line.line_ending}"
    return "".join(
        f"{raw_line}{line_ending}"
        for raw_line, line_ending in zip(line.raw_lines, line.line_endings, strict=True)
    )


def new_pack_toml_manifest(
    *,
    pack: PackInfo,
    layout: PackLayout,
    paths: PackPaths,
) -> PackTomlManifest:
    manifest = PackTomlManifest(pack=pack, layout=layout, paths=paths)
    return PackTomlManifest(
        pack=pack,
        layout=layout,
        paths=paths,
        source_text=render_pack_toml(manifest),
    )


def with_added_mod_file(
    manifest: PackTomlManifest,
    *,
    relative_path: str,
) -> PackTomlManifest:
    normalized_path = _validate_relative_path_value(
        relative_path,
        table_name="layout",
        key="mod_files",
    )
    if normalized_path in manifest.layout.mod_files:
        return manifest

    updated_layout = PackLayout(mod_files=manifest.layout.mod_files + (normalized_path,))
    if not manifest.lines:
        updated_manifest = PackTomlManifest(
            pack=manifest.pack,
            layout=updated_layout,
            paths=manifest.paths,
        )
        return PackTomlManifest(
            pack=updated_manifest.pack,
            layout=updated_manifest.layout,
            paths=updated_manifest.paths,
            source_text=render_pack_toml(updated_manifest),
        )

    updated_lines = list(manifest.lines)
    replacement_line = _new_array_key_value_line(
        "layout",
        "mod_files",
        updated_layout.mod_files,
        line_ending="\n",
    )
    for index, line in enumerate(updated_lines):
        if (
            isinstance(line, PackTomlKeyValueLine)
            and line.table_name == "layout"
            and line.key == "mod_files"
        ):
            updated_lines[index] = replacement_line
            break
    else:
        raise PackTomlError("missing required field [layout].mod_files")

    updated_manifest = PackTomlManifest(
        pack=manifest.pack,
        layout=updated_layout,
        paths=manifest.paths,
        lines=tuple(updated_lines),
    )
    return PackTomlManifest(
        pack=updated_manifest.pack,
        layout=updated_manifest.layout,
        paths=updated_manifest.paths,
        lines=updated_manifest.lines,
        source_text=render_pack_toml(updated_manifest),
    )


def with_added_ordering_rule_file(
    manifest: PackTomlManifest,
    *,
    relative_path: str,
) -> PackTomlManifest:
    normalized_path = _validate_relative_path_value(
        relative_path,
        table_name="paths",
        key="ordering_rule_files",
    )
    if normalized_path in manifest.paths.ordering_rule_files:
        return manifest

    updated_paths = PackPaths(
        config_dir=manifest.paths.config_dir,
        ordering_rule_files=manifest.paths.ordering_rule_files + (normalized_path,),
        activation_rule_files=manifest.paths.activation_rule_files,
        diagnostics_file=manifest.paths.diagnostics_file,
        resolved_load_order_file=manifest.paths.resolved_load_order_file,
        local_mods_dir=manifest.paths.local_mods_dir,
    )
    if not manifest.lines:
        updated_manifest = PackTomlManifest(
            pack=manifest.pack,
            layout=manifest.layout,
            paths=updated_paths,
        )
        return PackTomlManifest(
            pack=updated_manifest.pack,
            layout=updated_manifest.layout,
            paths=updated_manifest.paths,
            source_text=render_pack_toml(updated_manifest),
        )

    updated_lines = list(manifest.lines)
    replacement_line = _new_array_key_value_line(
        "paths",
        "ordering_rule_files",
        updated_paths.ordering_rule_files,
        line_ending="\n",
    )
    for index, line in enumerate(updated_lines):
        if (
            isinstance(line, PackTomlKeyValueLine)
            and line.table_name == "paths"
            and line.key == "ordering_rule_files"
        ):
            updated_lines[index] = replacement_line
            break
    else:
        insertion_index = _paths_section_insertion_index(updated_lines)
        if insertion_index is None:
            if updated_lines and not isinstance(updated_lines[-1], PackTomlBlankLine):
                updated_lines.append(PackTomlBlankLine(raw_text="", line_ending="\n"))
            updated_lines.append(
                PackTomlTableLine(raw_text="[paths]", name="paths", line_ending="\n")
            )
            updated_lines.append(replacement_line)
        else:
            updated_lines.insert(insertion_index, replacement_line)

    updated_manifest = PackTomlManifest(
        pack=manifest.pack,
        layout=manifest.layout,
        paths=updated_paths,
        lines=tuple(updated_lines),
    )
    return PackTomlManifest(
        pack=updated_manifest.pack,
        layout=updated_manifest.layout,
        paths=updated_manifest.paths,
        lines=updated_manifest.lines,
        source_text=render_pack_toml(updated_manifest),
    )


def _collect_assignment_lines(
    parsed_lines: tuple[tuple[str, str], ...],
    *,
    start_index: int,
    initial_value_text: str,
) -> tuple[tuple[str, ...], tuple[str, ...], int]:
    raw_lines = [parsed_lines[start_index][0]]
    line_endings = [parsed_lines[start_index][1]]

    if not _starts_multiline_array(initial_value_text):
        return tuple(raw_lines), tuple(line_endings), start_index + 1

    bracket_depth = _array_bracket_delta(initial_value_text)
    index = start_index + 1
    while bracket_depth > 0:
        if index >= len(parsed_lines):
            raise PackTomlError(
                f"unterminated array value starting at line {start_index + 1}",
            )
        raw_text, line_ending = parsed_lines[index]
        raw_lines.append(raw_text)
        line_endings.append(line_ending)
        bracket_depth += _array_bracket_delta(raw_text)
        index += 1

    return tuple(raw_lines), tuple(line_endings), index


def _starts_multiline_array(value_text: str) -> bool:
    return value_text.lstrip().startswith("[") and _array_bracket_delta(value_text) > 0


def _array_bracket_delta(text: str) -> int:
    depth = 0
    in_basic_string = False
    in_literal_string = False
    escaped = False

    for character in text:
        if escaped:
            escaped = False
            continue

        if in_basic_string:
            if character == "\\":
                escaped = True
            elif character == '"':
                in_basic_string = False
            continue

        if in_literal_string:
            if character == "'":
                in_literal_string = False
            continue

        if character == "#":
            break
        if character == '"':
            in_basic_string = True
            continue
        if character == "'":
            in_literal_string = True
            continue
        if character == "[":
            depth += 1
            continue
        if character == "]":
            depth -= 1

    return depth


def _parse_assignment_value(
    raw_lines: tuple[str, ...],
    *,
    start_line: int,
    key: str,
) -> object:
    assignment_text = "\n".join(raw_lines)
    try:
        parsed = tomllib.loads(assignment_text)
    except tomllib.TOMLDecodeError as exc:
        raise PackTomlError(
            f"invalid TOML assignment for {key!r} starting at line {start_line}: {exc}",
        ) from exc

    return cast(object, parsed[key])


def _require_non_empty_string(
    table_values: dict[str, dict[str, object]],
    table_name: str,
    key: str,
) -> str:
    value = _require_field(table_values, table_name, key)
    if not isinstance(value, str):
        raise PackTomlError(f"field [{table_name}].{key} must be a string")
    if value == "":
        raise PackTomlError(f"field [{table_name}].{key} must not be empty")
    return value


def _optional_non_empty_string(
    table_values: dict[str, dict[str, object]],
    table_name: str,
    key: str,
) -> str | None:
    value = _optional_field(table_values, table_name, key)
    if value is None:
        return None
    if not isinstance(value, str):
        raise PackTomlError(f"field [{table_name}].{key} must be a string")
    if value == "":
        raise PackTomlError(f"field [{table_name}].{key} must not be empty")
    return value


def _require_supported_pack_format_version(
    table_values: dict[str, dict[str, object]],
) -> int:
    value = _require_field(table_values, "pack", "pack_format_version")
    if not isinstance(value, int):
        raise PackTomlError("field [pack].pack_format_version must be an integer")
    if value != 1:
        raise PackTomlError(
            f"unsupported [pack].pack_format_version: {value}; expected 1",
        )
    return value


def _require_relative_path_array(
    table_values: dict[str, dict[str, object]],
    table_name: str,
    key: str,
    *,
    require_non_empty: bool,
) -> tuple[str, ...]:
    value = _require_field(table_values, table_name, key)
    return _validate_relative_path_array(
        value,
        table_name=table_name,
        key=key,
        require_non_empty=require_non_empty,
    )


def _optional_relative_path_array(
    table_values: dict[str, dict[str, object]],
    table_name: str,
    key: str,
) -> tuple[str, ...]:
    value = _optional_field(table_values, table_name, key)
    if value is None:
        return ()
    return _validate_relative_path_array(
        value,
        table_name=table_name,
        key=key,
        require_non_empty=False,
    )


def _validate_relative_path_array(
    value: object,
    *,
    table_name: str,
    key: str,
    require_non_empty: bool,
) -> tuple[str, ...]:
    if not isinstance(value, list):
        raise PackTomlError(f"field [{table_name}].{key} must be an array of strings")
    if require_non_empty and not value:
        raise PackTomlError(f"field [{table_name}].{key} must not be empty")

    result: list[str] = []
    seen: set[str] = set()
    for item in cast(list[object], value):
        if not isinstance(item, str):
            raise PackTomlError(
                f"field [{table_name}].{key} must be an array of strings",
            )
        normalized = _validate_relative_path_value(
            item,
            table_name=table_name,
            key=key,
        )
        if normalized in seen:
            raise PackTomlError(f"field [{table_name}].{key} must not contain duplicates")
        seen.add(normalized)
        result.append(normalized)

    return tuple(result)


def _optional_relative_path(
    table_values: dict[str, dict[str, object]],
    table_name: str,
    key: str,
) -> str | None:
    value = _optional_field(table_values, table_name, key)
    if value is None:
        return None
    if not isinstance(value, str):
        raise PackTomlError(f"field [{table_name}].{key} must be a string")
    return _validate_relative_path_value(value, table_name=table_name, key=key)


def _validate_relative_path_value(
    value: str,
    *,
    table_name: str,
    key: str,
) -> str:
    if value == "":
        raise PackTomlError(f"field [{table_name}].{key} must not be empty")
    if "\\" in value:
        raise PackTomlError(f"field [{table_name}].{key} must use forward slashes")

    posix_path = PurePosixPath(value)
    if posix_path.is_absolute():
        raise PackTomlError(f"field [{table_name}].{key} must be a relative path")
    if posix_path.parts and posix_path.parts[0].endswith(":"):
        raise PackTomlError(f"field [{table_name}].{key} must be a relative path")

    return value


def _require_field(
    table_values: dict[str, dict[str, object]],
    table_name: str,
    key: str,
) -> object:
    table = table_values.get(table_name)
    if table is None:
        raise PackTomlError(f"missing required table [{table_name}]")
    if key not in table:
        raise PackTomlError(f"missing required field [{table_name}].{key}")
    return table[key]


def _optional_field(
    table_values: dict[str, dict[str, object]],
    table_name: str,
    key: str,
) -> object | None:
    table = table_values.get(table_name)
    if table is None:
        return None
    return table.get(key)


def _render_paths_section(paths: PackPaths) -> tuple[PackTomlKeyValueLine, ...]:
    lines: list[PackTomlKeyValueLine] = []
    if paths.config_dir is not None:
        lines.append(
            _new_string_key_value_line(
                "paths",
                "config_dir",
                paths.config_dir,
                line_ending="\n",
            ),
        )
    if paths.ordering_rule_files:
        lines.append(
            _new_array_key_value_line(
                "paths",
                "ordering_rule_files",
                paths.ordering_rule_files,
                line_ending="\n",
            ),
        )
    if paths.activation_rule_files:
        lines.append(
            _new_array_key_value_line(
                "paths",
                "activation_rule_files",
                paths.activation_rule_files,
                line_ending="\n",
            ),
        )
    if paths.diagnostics_file is not None:
        lines.append(
            _new_string_key_value_line(
                "paths",
                "diagnostics_file",
                paths.diagnostics_file,
                line_ending="\n",
            ),
        )
    if paths.resolved_load_order_file is not None:
        lines.append(
            _new_string_key_value_line(
                "paths",
                "resolved_load_order_file",
                paths.resolved_load_order_file,
                line_ending="\n",
            ),
        )
    if paths.local_mods_dir is not None:
        lines.append(
            _new_string_key_value_line(
                "paths",
                "local_mods_dir",
                paths.local_mods_dir,
                line_ending="\n",
            ),
        )
    return tuple(lines)


def _paths_section_insertion_index(lines: list[PackTomlLine]) -> int | None:
    in_paths_section = False
    insert_at: int | None = None
    for index, line in enumerate(lines):
        if isinstance(line, PackTomlTableLine):
            if line.name == "paths":
                in_paths_section = True
                insert_at = index + 1
                continue
            if in_paths_section:
                return insert_at
        if in_paths_section:
            insert_at = index + 1
    return insert_at


def _new_string_key_value_line(
    table_name: str,
    key: str,
    value: str,
    *,
    line_ending: str,
) -> PackTomlKeyValueLine:
    escaped_value = value.replace("\\", "\\\\").replace('"', '\\"')
    return PackTomlKeyValueLine(
        raw_lines=(f'{key} = "{escaped_value}"',),
        line_endings=(line_ending,),
        table_name=table_name,
        key=key,
        value=value,
    )


def _new_scalar_key_value_line(
    table_name: str,
    key: str,
    value: int | bool,
    *,
    line_ending: str,
) -> PackTomlKeyValueLine:
    raw_value = str(value).lower() if isinstance(value, bool) else str(value)
    return PackTomlKeyValueLine(
        raw_lines=(f"{key} = {raw_value}",),
        line_endings=(line_ending,),
        table_name=table_name,
        key=key,
        value=value,
    )


def _new_array_key_value_line(
    table_name: str,
    key: str,
    values: tuple[str, ...],
    *,
    line_ending: str,
) -> PackTomlKeyValueLine:
    raw_lines = (
        f"{key} = [",
        *(f"  {_quote_toml_string(value)}," for value in values),
        "]",
    )
    return PackTomlKeyValueLine(
        raw_lines=raw_lines,
        line_endings=tuple(line_ending for _ in raw_lines),
        table_name=table_name,
        key=key,
        value=list(values),
    )


def _quote_toml_string(value: str) -> str:
    escaped_value = value.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{escaped_value}"'
