import ast
import json
import re
import sys
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
WORKSPACE = ROOT.parent
INFORM_ROOT = WORKSPACE / "Flexible-Survival"
MANIFEST = ROOT / "docs" / "inform_source_manifest.json"
LOCATION_ROOT = ROOT / "game" / "scripts" / "Content" / "Locations"
MANUAL_SOURCE_BY_TARGET = {
    "core mechanics/apartments.rpy": [INFORM_ROOT / "Core Mechanics" / "Basic Locations.i7x"],
    "core mechanics/bunker.rpy": [INFORM_ROOT / "Core Mechanics" / "Basic Locations.i7x"],
    "core mechanics/city_streets.rpy": [INFORM_ROOT / "Core Mechanics" / "Basic Locations.i7x"],
    "core mechanics/park.rpy": [INFORM_ROOT / "Core Mechanics" / "Basic Locations.i7x"],
    "core mechanics/plains.rpy": [INFORM_ROOT / "Core Mechanics" / "Basic Locations.i7x"],
    "core mechanics/trevor_labs.rpy": [INFORM_ROOT / "Core Mechanics" / "Basic Locations.i7x"],
}
KNOWN_SOURCE_CONFLICTS = {
    (
        "Tiger Den",
        "TDen_LockerRoom",
        "east",
        "TDen_FFMainArea",
        "TDen_Showers",
    ): "Inform gives TDen_LockerRoom two east destinations; room prose says west to main area and east to showers, matching Ren'Py.",
    (
        "campus",
        "College Walkway Northeast",
        "northwest",
        "College Administration Building",
        "College Administration buildings",
    ): "Inform alternates between singular and plural names for the same administration building.",
    (
        "Dragon Temple Map",
        "Dragon's Causeway",
        "west",
        "Cultist's Chambers",
        "Cultist's Chamber",
    ): "Inform alternates between singular and plural names for the same cultists' chambers.",
}

DIR_INVERSE = {
    "north": "south",
    "south": "north",
    "east": "west",
    "west": "east",
    "northeast": "southwest",
    "southwest": "northeast",
    "northwest": "southeast",
    "southeast": "northwest",
    "inside": "outside",
    "outside": "inside",
    "up": "down",
    "down": "up",
    "above": "down",
    "below": "up",
}

INFORM_TO_RENPY_DIR = {
    "above": "up",
    "below": "down",
}
PORTAL_DIRECTIONS = ("inside", "outside")
PORTAL_PLAYABLE_DIRECTIONS = (
    "north",
    "south",
    "east",
    "west",
    "northeast",
    "southwest",
    "northwest",
    "southeast",
)
PORTAL_DIRECTION_ORDER = {
    "inside": ("east", "north", "south", "west", "northeast", "southeast", "northwest", "southwest"),
    "outside": ("west", "south", "north", "east", "southwest", "northwest", "southeast", "northeast"),
}

REL_DIRS = sorted(DIR_INVERSE, key=len, reverse=True)
REL_DIR_PATTERN = "|".join(re.escape(d) for d in REL_DIRS)


def resolve_repo_path(path_text):
    path = Path(path_text)
    if path.is_absolute():
        return path
    candidate = ROOT / path_text
    if candidate.exists():
        return candidate
    parts = Path(path_text).parts
    current = ROOT
    for part in parts:
        matches = [p for p in current.iterdir() if p.name.lower() == part.lower()]
        if not matches:
            return candidate
        current = matches[0]
    return current


def strip_inform_comments(text):
    out = []
    in_string = False
    in_comment = 0
    i = 0
    while i < len(text):
        ch = text[i]
        if ch == '"':
            if in_comment == 0:
                in_string = not in_string
                out.append(ch)
            i += 1
            continue
        if not in_string and ch == "[":
            in_comment += 1
            i += 1
            continue
        if not in_string and ch == "]" and in_comment:
            in_comment -= 1
            i += 1
            continue
        if in_comment == 0:
            out.append(ch)
        elif ch in "\r\n":
            out.append(ch)
        i += 1
    return "".join(out)


def normalize_name(name):
    name = name.strip()
    previous = None
    while previous != name:
        previous = name
        name = re.sub(r"^(?:the|a|an)\s+", "", name, flags=re.IGNORECASE)
    return " ".join(name.strip().strip(".").split())


def canonical_name(name):
    return normalize_name(name).lower()


def slug(text):
    text = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", text)
    return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")


def compact_slug(text):
    return slug(text).replace("_", "")


def blank_inform_strings(text):
    out = []
    in_string = False
    escape = False
    for ch in text:
        if in_string:
            if escape:
                escape = False
            elif ch == "\\":
                escape = True
            elif ch == '"':
                in_string = False
                out.append(ch)
            elif ch in "\r\n":
                out.append(ch)
            else:
                out.append(" ")
            continue
        out.append(ch)
        if ch == '"':
            in_string = True
    return "".join(out)


def renpy_dir(direction):
    return INFORM_TO_RENPY_DIR.get(direction, direction)


def inverse_dir(direction):
    return renpy_dir(DIR_INVERSE[direction])


def same_location(left, right):
    return canonical_name(left or "") == canonical_name(right or "")


def normalize_portal_edges(edges):
    normalized = {source: dict(connections) for source, connections in edges.items()}

    def find_source(name):
        return next((source for source in normalized if same_location(source, name)), None)

    def pick_direction(source, connections, portal_direction, destination):
        destination_key = find_source(destination)
        if destination_key:
            destination_connections = normalized[destination_key]
            for destination_direction, destination_target in destination_connections.items():
                if destination_direction in PORTAL_PLAYABLE_DIRECTIONS and same_location(destination_target, source):
                    candidate = inverse_dir(destination_direction)
                    if candidate not in connections:
                        return candidate

            inverse_portal = inverse_dir(portal_direction)
            if same_location(destination_connections.get(inverse_portal), source):
                for candidate in PORTAL_DIRECTION_ORDER[portal_direction]:
                    reverse = inverse_dir(candidate)
                    if candidate not in connections and reverse not in destination_connections:
                        destination_connections.pop(inverse_portal, None)
                        destination_connections[reverse] = source
                        return candidate

        for candidate in PORTAL_DIRECTION_ORDER[portal_direction]:
            if candidate not in connections:
                return candidate
        return None

    for source in list(normalized):
        connections = normalized[source]
        for portal_direction in PORTAL_DIRECTIONS:
            if portal_direction not in connections:
                continue
            destination = connections.pop(portal_direction)
            replacement = pick_direction(source, connections, portal_direction, destination)
            if replacement:
                connections[replacement] = destination
            else:
                connections[portal_direction] = destination

    return normalized


def looks_like_rule_text(text):
    return bool(re.search(r"\b(?:while|when|if|unless|otherwise|say|try|move)\b|:", text, re.IGNORECASE))


def add_edge(edges, source, direction, dest, conflicts=None, alternate_edges=None):
    if looks_like_rule_text(source) or looks_like_rule_text(dest):
        return
    source = normalize_name(source)
    dest = normalize_name(dest)
    direction = renpy_dir(direction)
    if not source or not dest or source.lower() in {"it", "now", "nowhere"}:
        return
    source_key = next((key for key in edges if canonical_name(key) == canonical_name(source)), source)
    connections = edges.setdefault(source_key, {})
    if dest.lower() in {"now", "nowhere"}:
        connections.pop(direction, None)
        return
    if dest:
        existing = connections.get(direction)
        if existing and canonical_name(existing) != canonical_name(dest):
            if conflicts is not None:
                conflicts.append(
                    {
                        "source": source_key,
                        "direction": direction,
                        "first": existing,
                        "alternate": dest,
                    }
                )
            if alternate_edges is not None:
                alternate_edges.setdefault(source_key, {}).setdefault(direction, set()).add(dest)
            return
        connections.setdefault(direction, dest)


def remove_description_statements(text):
    return re.sub(
        r"\b(?:description|usedesc|scent|the scent|printed name) of [^.]+? is \"[^\"]*\"\.?",
        "",
        text,
        flags=re.IGNORECASE,
    )


def expand_inform_pronoun_relations(text):
    return re.sub(
        r"\b(?P<object>[A-Z][^.]+?) is (?:a |an )?door\. It is ",
        r"\g<object> is ",
        text,
        flags=re.IGNORECASE,
    )


def parse_inform_edges(source_path, conflicts=None, alternate_edges=None):
    text = strip_inform_comments(source_path.read_text(encoding="utf-8", errors="replace"))
    text = remove_description_statements(text)
    text = expand_inform_pronoun_relations(text)
    text = blank_inform_strings(text)
    text = re.sub(r"\s+", " ", text)
    edges = {}

    # Down of Gator Pit is Sewer Exploration.
    absolute_pat = re.compile(
        rf"\b(?P<dir>{REL_DIR_PATTERN}) (?:of|from) (?P<source>[^.]+?) is (?P<dest>[^.;]+)",
        re.IGNORECASE,
    )
    for match in absolute_pat.finditer(text):
        direction = match.group("dir").lower()
        if direction not in ("up", "down", "inside", "outside"):
            continue
        add_edge(edges, match.group("source"), direction, match.group("dest"), conflicts, alternate_edges)
        add_edge(edges, match.group("dest"), inverse_dir(direction), match.group("source"), conflicts, alternate_edges)

    # Skyscrapers is south of Financial Sector and north of Entrance...
    subject_pat = re.compile(
        rf"(?P<subject>[A-Z][^.;:]*?) (?:is|are) (?P<body>(?:(?:the )?(?:{REL_DIR_PATTERN})(?: of| from)? [^.;]+))",
        re.IGNORECASE,
    )
    relation_pat = re.compile(
        rf"\b(?:the )?(?P<dir>{REL_DIR_PATTERN})(?: of| from)? (?P<target>[^.;]+?)(?=\s+and\s+(?:the )?(?:{REL_DIR_PATTERN})(?: of| from)?|$)",
        re.IGNORECASE,
    )
    for match in subject_pat.finditer(text):
        subject = normalize_name(match.group("subject"))
        if re.search(r"\b(?:while|when|if|unless|otherwise)\b", match.group("subject"), re.IGNORECASE):
            continue
        if subject.lower() in {"description of", "usedesc of", "the scent of"}:
            continue
        for relation in relation_pat.finditer(match.group("body")):
            direction = relation.group("dir").lower()
            target = normalize_name(relation.group("target"))
            add_edge(edges, target, direction, subject, conflicts, alternate_edges)
            add_edge(edges, subject, inverse_dir(direction), target, conflicts, alternate_edges)

    return normalize_portal_edges(edges)


def parse_inform_dynamic_edges(source_path):
    text = strip_inform_comments(source_path.read_text(encoding="utf-8", errors="replace"))
    text = blank_inform_strings(text)
    edges = {}

    change_pat = re.compile(
        rf"\bchange the (?P<dir>{REL_DIR_PATTERN}) exit of (?P<source>[^.;]+?) to (?P<dest>[^.;]+)",
        re.IGNORECASE,
    )
    for match in change_pat.finditer(text):
        add_edge(edges, match.group("source"), match.group("dir").lower(), match.group("dest"))

    lines = text.splitlines()
    instead_pat = re.compile(
        rf"^\s*instead of going (?P<dir>{REL_DIR_PATTERN}) from (?P<source>[^:]+):",
        re.IGNORECASE,
    )
    move_pat = re.compile(r"\bmove player to (?P<dest>[^.;]+)", re.IGNORECASE)
    for index, line in enumerate(lines):
        match = instead_pat.match(line)
        if not match:
            continue
        block = "\n".join(lines[index + 1 : index + 8])
        move = move_pat.search(block)
        if move:
            add_edge(edges, match.group("source"), match.group("dir").lower(), move.group("dest"))

    return normalize_portal_edges(edges)


def extract_braced_block(text, start):
    open_at = text.index("{", start)
    depth = 0
    in_string = None
    escape = False
    for i in range(open_at, len(text)):
        ch = text[i]
        if in_string:
            if escape:
                escape = False
            elif ch == "\\":
                escape = True
            elif ch == in_string:
                in_string = None
            continue
        if ch in ("'", '"'):
            in_string = ch
            continue
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return text[open_at : i + 1]
    raise ValueError("unterminated dictionary")


def parse_renpy_locations(target_path):
    text = target_path.read_text(encoding="utf-8", errors="replace")
    locations = {}
    for match in re.finditer(r"\bLOC_[A-Z0-9_]+\s*=\s*\{", text):
        block = extract_braced_block(text, match.start())
        try:
            data = ast.literal_eval(block)
        except Exception:
            continue
        loc_id = data.get("id")
        if not loc_id:
            continue
        locations[loc_id] = data.get("connections", {}) or {}
    return normalize_portal_edges(locations)


def edge_matches_inform(source, direction, dest, inform_edges, renpy_canon, alternate_edges=None):
    source_key = next((key for key in inform_edges if canonical_name(key) == canonical_name(source)), source)
    expected = inform_edges.get(source_key, {}).get(direction)
    if canonical_name(expected or "") == canonical_name(dest):
        return True
    expected_actual = renpy_canon.get(canonical_name(expected or ""))
    if canonical_name(expected_actual or "") == canonical_name(dest):
        return True

    # Ren'Py often omits Inform door pseudo-locations and links the two rooms
    # directly. Treat source -> door -> destination as source-backed parity.
    if expected:
        expected_key = next((key for key in inform_edges if canonical_name(key) == canonical_name(expected)), expected)
        bypass = inform_edges.get(expected_key, {}).get(direction)
        bypass_actual = renpy_canon.get(canonical_name(bypass or ""))
        if canonical_name(bypass_actual or bypass or "") == canonical_name(dest):
            return True
    alternate_key = next((key for key in (alternate_edges or {}) if canonical_name(key) == canonical_name(source)), source)
    for alternate in (alternate_edges or {}).get(alternate_key, {}).get(direction, set()):
        alternate_actual = renpy_canon.get(canonical_name(alternate or ""))
        if canonical_name(alternate_actual or alternate or "") == canonical_name(dest):
            return True
    return False


def compare_edges(inform_edges, dynamic_edges, alternate_edges, renpy_edges):
    renpy_names = set(renpy_edges)
    renpy_canon = {canonical_name(name): name for name in renpy_names}
    missing = []
    extra = []
    for source, connections in sorted(inform_edges.items()):
        source_actual = renpy_canon.get(canonical_name(source))
        if not source_actual:
            continue
        for direction, dest in sorted(connections.items()):
            dest_actual = renpy_canon.get(canonical_name(dest))
            if not dest_actual:
                continue
            actual = renpy_edges.get(source_actual, {}).get(direction)
            if canonical_name(actual or "") != canonical_name(dest_actual) and not edge_matches_inform(
                source_actual,
                direction,
                actual or "",
                inform_edges,
                renpy_canon,
                alternate_edges,
            ):
                missing.append(
                    {
                        "source": source_actual,
                        "direction": direction,
                        "expected": dest_actual,
                        "actual": actual,
                    }
                )
    for source, connections in sorted(renpy_edges.items()):
        for direction, dest in sorted(connections.items()):
            dest_actual = renpy_canon.get(canonical_name(dest))
            if not dest_actual:
                continue
            if edge_matches_inform(source, direction, dest_actual, inform_edges, renpy_canon, alternate_edges):
                continue
            if edge_matches_inform(source, direction, dest_actual, dynamic_edges, renpy_canon):
                continue
            expected = inform_edges.get(source, {}).get(direction)
            expected_actual = renpy_canon.get(canonical_name(expected or ""))
            if canonical_name(expected_actual or "") != canonical_name(dest_actual):
                extra.append(
                    {
                        "source": source,
                        "direction": direction,
                        "actual": dest_actual,
                        "expected": expected_actual or expected,
                    }
                )
    return missing, extra


def filter_known_source_conflicts(include_name, conflicts):
    remaining = []
    known = []
    for conflict in conflicts:
        forward = (
            include_name,
            conflict["source"],
            conflict["direction"],
            conflict["first"],
            conflict["alternate"],
        )
        reverse = (
            include_name,
            conflict["source"],
            conflict["direction"],
            conflict["alternate"],
            conflict["first"],
        )
        note = KNOWN_SOURCE_CONFLICTS.get(forward) or KNOWN_SOURCE_CONFLICTS.get(reverse)
        if note:
            conflict = dict(conflict)
            conflict["note"] = note
            known.append(conflict)
        else:
            remaining.append(conflict)
    return remaining, known


def entry_sources(entry):
    return [
        Path(src)
        for src in entry.get("source_matches", [])
        if src.lower().endswith(".i7x") and Path(src).exists()
    ]


def fallback_sources_for_target(target):
    rel_key = target.relative_to(LOCATION_ROOT).as_posix().lower()
    manual = [path for path in MANUAL_SOURCE_BY_TARGET.get(rel_key, []) if path.exists()]
    if manual:
        return manual
    try:
        rel_parts = target.relative_to(LOCATION_ROOT).parts
    except ValueError:
        return []
    if len(rel_parts) < 2:
        return []
    writer_dir = INFORM_ROOT / rel_parts[0]
    if not writer_dir.exists():
        return []
    target_slug = slug(target.stem)
    matches = [
        path
        for path in writer_dir.glob("*.i7x")
        if slug(path.stem) == target_slug or compact_slug(path.stem) == compact_slug(target.stem)
    ]
    return matches


def iter_location_manifest_entries():
    manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
    entries_by_target = {}
    entries_by_writer_slug = {}
    for entry in manifest:
        sources = entry_sources(entry)
        if not sources:
            continue
        target_path = entry.get("target_path")
        if target_path:
            entries_by_target[str(resolve_repo_path(target_path).resolve()).lower()] = entry
        writer = slug(entry.get("writer_name") or entry.get("writer_slug") or "")
        include = slug(entry.get("include_name") or entry.get("include_slug") or "")
        source_stems = {slug(Path(src).stem) for src in entry.get("source_matches", [])}
        for key_slug in {include, *source_stems}:
            if writer and key_slug:
                entries_by_writer_slug.setdefault((writer, key_slug), []).append(entry)

    seen = set()
    for target in sorted(LOCATION_ROOT.rglob("*.rpy")):
        try:
            text = target.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        if "register_location(" not in text:
            continue
        resolved = str(target.resolve()).lower()
        entry = entries_by_target.get(resolved)
        if not entry:
            rel_parts = target.relative_to(LOCATION_ROOT).parts
            writer = slug(rel_parts[0]) if len(rel_parts) > 1 else ""
            candidates = entries_by_writer_slug.get((writer, slug(target.stem)), [])
            if len(candidates) == 1:
                entry = candidates[0]
        sources = entry_sources(entry) if entry else fallback_sources_for_target(target)
        if not sources:
            continue
        if not entry:
            entry = {
                "include_name": target.stem,
                "target_path": str(target.relative_to(ROOT)),
            }
        key = (resolved, entry.get("include_name"))
        if key in seen:
            continue
        seen.add(key)
        yield entry, target, sources


def main():
    show_source_conflicts = "--show-source-conflicts" in sys.argv[1:]
    report = []
    for entry, target, sources in iter_location_manifest_entries():
        renpy_edges = parse_renpy_locations(target)
        inform_edges = {}
        dynamic_edges = {}
        alternate_edges = {}
        conflicts = []
        for source in sources:
            for loc, connections in parse_inform_edges(source, conflicts, alternate_edges).items():
                inform_edges.setdefault(loc, {}).update(connections)
            for loc, connections in parse_inform_dynamic_edges(source).items():
                dynamic_edges.setdefault(loc, {}).update(connections)
        missing, extra = compare_edges(inform_edges, dynamic_edges, alternate_edges, renpy_edges)
        conflicts, known_conflicts = filter_known_source_conflicts(entry["include_name"], conflicts)
        if missing or extra or conflicts or (show_source_conflicts and known_conflicts):
            report.append(
                {
                    "include": entry["include_name"],
                    "target": str(target.relative_to(ROOT)),
                    "sources": [str(src) for src in sources],
                    "renpy_locations": len(renpy_edges),
                    "inform_locations_with_edges": len(inform_edges),
                    "missing": missing,
                    "extra": extra,
                    "inform_conflicts": conflicts,
                    "known_source_conflicts": known_conflicts if show_source_conflicts else [],
                }
            )
    print(json.dumps(report, indent=2))


if __name__ == "__main__":
    main()
