"""Convert CommonJS JavaScript to TypeScript ES-module syntax."""
from __future__ import annotations
import re


def convert_js_to_ts(content: str) -> tuple[str, list[str]]:
    """
    Convert a JS file to TS.
    Returns (transformed_content, list_of_changes).
    """
    lines   = content.split("\n")
    result  = []
    changes: list[str] = []

    for line in lines:
        line = _transform_line(line, changes)
        result.append(line)

    joined = "\n".join(result)
    joined, rt_changes = _add_return_types(joined)
    changes.extend(rt_changes)
    joined, cls_changes = _add_class_field_decls(joined)
    changes.extend(cls_changes)
    joined, ctor_changes = _annotate_constructor_params(joined)
    changes.extend(ctor_changes)
    joined, dup_changes = _rename_duplicate_methods(joined)
    changes.extend(dup_changes)
    joined, intl_changes = _cast_intl_format_options(joined)
    changes.extend(intl_changes)
    joined, tobe_changes = _fix_tobe_typo(joined)
    changes.extend(tobe_changes)
    return joined, changes


# ---------------------------------------------------------------------------
# `.tobe.<value>(<msg>)` → `.toBe(<value>)`
#
# JS doesn't catch this — `expect(x).tobe` evaluates to undefined and only
# throws at runtime when `.<value>(...)` is called. TS rejects it at compile
# time. The clear intent is `.toBe(<value>)`; any trailing args were going
# to fail anyway so we drop them with a MIGRATION TODO.
# ---------------------------------------------------------------------------

def _fix_tobe_typo(content: str) -> tuple[str, list[str]]:
    changes: list[str] = []

    def _repl(m: re.Match) -> str:
        value = m.group(1)
        args  = m.group(2).strip()
        changes.append(f".tobe.{value}({args}) → .toBe({value}) (dropped invalid args)")
        if args:
            return f".toBe({value}) /* MIGRATION TODO: original second arg was {args!r} */"
        return f".toBe({value})"

    content = re.sub(
        r"\.tobe\.(\w+)\(([^)]*)\)",
        _repl,
        content,
    )
    return content, changes


# ---------------------------------------------------------------------------
# Duplicate-method renaming
#
# JS allows two methods with the same name in a class — the second silently
# overrides the first. TypeScript flags this with TS2393 "Duplicate function
# implementation". Rename the second (and later) occurrences with a numeric
# suffix so the user can see both and decide which to keep.
# ---------------------------------------------------------------------------

def _rename_duplicate_methods(content: str) -> tuple[str, list[str]]:
    changes: list[str] = []
    out: list[str] = []
    cursor = 0
    class_re = re.compile(
        r"\bclass\s+\w+(?:\s+extends\s+[\w.<>\[\]\s,]+?)?\s*\{",
    )
    for m in class_re.finditer(content):
        body_open = m.end() - 1
        body_close = _find_matching_brace(content, body_open)
        if body_close < 0:
            continue
        body = content[body_open + 1 : body_close]

        # Find every method definition: `[async] name(args) {`
        method_re = re.compile(
            r"(^\s+)(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[\w.<>\[\]\s|&,]+)?\s*\{",
            re.MULTILINE,
        )
        # Track name → count of occurrences so far
        seen: dict[str, int] = {}
        # Build replacements as a list of (span_in_body, new_text)
        replacements: list[tuple[int, int, str]] = []
        for mm in method_re.finditer(body):
            name = mm.group(2)
            if name in {"constructor", "if", "for", "while", "switch", "return", "function", "async", "catch"}:
                continue
            seen[name] = seen.get(name, 0) + 1
            if seen[name] == 1:
                continue
            new_name = f"{name}_{seen[name]}"
            # Find the position of the method name within the match.
            name_start = mm.start() + mm.group(0).find(name)
            name_end = name_start + len(name)
            replacements.append((name_start, name_end, new_name))
            changes.append(
                f"Renamed duplicate method {name} → {new_name} "
                f"(JS allowed; TS does not)"
            )

        if not replacements:
            continue
        # Apply replacements right-to-left so indices stay valid.
        new_body = body
        for start, end, repl in sorted(replacements, key=lambda t: t[0], reverse=True):
            new_body = new_body[:start] + repl + new_body[end:]

        out.append(content[cursor : body_open + 1])
        out.append(new_body)
        cursor = body_close
    out.append(content[cursor:])
    return "".join(out), changes


# ---------------------------------------------------------------------------
# Intl.DateTimeFormatOptions cast
#
# `date.toLocaleDateString("en-US", options)` — JS accepts any object; TS
# requires `Intl.DateTimeFormatOptions` whose string fields are literal types.
# Casting the second arg with `as Intl.DateTimeFormatOptions` keeps the
# runtime identical while satisfying tsc.
# ---------------------------------------------------------------------------

def _cast_intl_format_options(content: str) -> tuple[str, list[str]]:
    changes: list[str] = []

    def _repl(m: re.Match) -> str:
        full = m.group(0)
        # Skip if already cast.
        if "as Intl.DateTimeFormatOptions" in full:
            return full
        receiver, locale, opts = m.group(1), m.group(2), m.group(3).strip()
        if not opts:
            return full
        changes.append(f"Cast toLocaleDateString options as Intl.DateTimeFormatOptions")
        return f"{receiver}.toLocaleDateString({locale}, {opts} as Intl.DateTimeFormatOptions)"

    content = re.sub(
        r"([\w.\[\]'\"]+)\s*\.\s*toLocaleDateString\(\s*([^,)]+)\s*,\s*([^)]+)\)",
        _repl,
        content,
    )
    return content, changes


# ---------------------------------------------------------------------------
# Class field declarations — surface `this.X = ...` constructor assignments
# as `X: any;` declarations so TypeScript doesn't complain that "Property X
# does not exist on type Foo". JS classes never need declarations; TS does.
# ---------------------------------------------------------------------------

def _add_class_field_decls(content: str) -> tuple[str, list[str]]:
    changes: list[str] = []
    out: list[str] = []
    cursor = 0
    # Find each `class Foo { ... }` opening. Handles `class Foo extends Bar` too.
    class_re = re.compile(
        r"\bclass\s+\w+(?:\s+extends\s+[\w.<>\[\]\s,]+?)?\s*\{",
    )
    for m in class_re.finditer(content):
        body_open = m.end() - 1  # index of `{`
        body_close = _find_matching_brace(content, body_open)
        if body_close < 0:
            continue
        body = content[body_open + 1 : body_close]
        # Find constructor and collect `this.X = ...` names.
        names = _extract_constructor_this_assignments(body)
        if not names:
            continue
        # Avoid re-declaring fields the class already has.
        existing = set(re.findall(r"^\s*(?:private|public|protected|readonly)?\s*(\w+)[!:?=\s]", body, re.MULTILINE))
        new_decls = [n for n in names if n not in existing]
        if not new_decls:
            continue
        # Indent: match the existing body indentation if any (default 2 spaces).
        indent_match = re.search(r"\n(\s+)", body)
        indent = indent_match.group(1) if indent_match else "  "
        decls = "".join(f"\n{indent}{n}: any;" for n in new_decls)
        # Splice the declarations just after the class opening `{`.
        out.append(content[cursor : body_open + 1])
        out.append(decls)
        cursor = body_open + 1
        changes.extend(f"Added class field declaration: {n}: any" for n in new_decls)
    out.append(content[cursor:])
    return "".join(out), changes


def _extract_constructor_this_assignments(class_body: str) -> list[str]:
    """Return ordered, deduplicated list of `X` names from `this.X = ...`."""
    m = re.search(r"\bconstructor\s*\(", class_body)
    if not m:
        return []
    paren_open = m.end() - 1
    paren_close = _find_matching_paren(class_body, paren_open)
    if paren_close < 0:
        return []
    # Find the `{` that opens the constructor body.
    brace_open = class_body.find("{", paren_close)
    if brace_open < 0:
        return []
    brace_close = _find_matching_brace(class_body, brace_open)
    if brace_close < 0:
        return []
    ctor_body = class_body[brace_open + 1 : brace_close]
    names: list[str] = []
    seen: set[str] = set()
    for am in re.finditer(r"\bthis\.(\w+)\s*=", ctor_body):
        n = am.group(1)
        if n in seen:
            continue
        # Skip already-declared private healer fields injected later — they
        # follow a `_` prefix convention.
        if n.startswith("_"):
            continue
        names.append(n)
        seen.add(n)
    return names


def _annotate_constructor_params(content: str) -> tuple[str, list[str]]:
    """Add `: any` to untyped constructor parameters."""
    changes: list[str] = []

    def _do(m: re.Match) -> str:
        params = m.group(1)
        if not params.strip():
            return m.group(0)
        annotated = []
        for p in [p.strip() for p in params.split(",")]:
            if not p:
                continue
            if ":" in p:                # already typed
                annotated.append(p)
            else:
                annotated.append(f"{p}: any")
        if annotated == [p.strip() for p in params.split(",") if p.strip()]:
            return m.group(0)
        changes.append("Annotated constructor params as any")
        return f"constructor({', '.join(annotated)})"

    content = re.sub(r"\bconstructor\(([^)]*)\)", _do, content)
    return content, changes


def _find_matching_brace(text: str, open_idx: int) -> int:
    depth = 1
    i = open_idx + 1
    in_str: str | None = None
    while i < len(text):
        c = text[i]
        if in_str:
            if c == "\\":
                i += 2
                continue
            if c == in_str:
                in_str = None
            i += 1
            continue
        if c in ("'", '"', "`"):
            in_str = c
            i += 1
            continue
        if c == "/" and i + 1 < len(text):
            if text[i + 1] == "/":
                nl = text.find("\n", i)
                i = nl + 1 if nl >= 0 else len(text)
                continue
            if text[i + 1] == "*":
                end = text.find("*/", i + 2)
                i = end + 2 if end >= 0 else len(text)
                continue
        if c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return -1


def _find_matching_paren(text: str, open_idx: int) -> int:
    depth = 1
    i = open_idx + 1
    while i < len(text):
        c = text[i]
        if c == "(":
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return -1


# ---------------------------------------------------------------------------
# Line-level transformations
# ---------------------------------------------------------------------------

def _transform_line(line: str, changes: list[str]) -> str:
    # const { X, Y } = require('Z')  →  import { X, Y } from 'Z'
    m = re.match(
        r"^(\s*)const\s*\{([^}]+)\}\s*=\s*require\(['\"]([^'\"]+)['\"]\)\s*;?\s*$",
        line,
    )
    if m:
        indent, names, module = m.groups()
        module = _drop_js_ext(module)
        changes.append(f"require('{module}') → import {{ ... }}")
        return f"{indent}import {{ {names.strip()} }} from '{module}';"

    # const X = require('Z')  →  import X from 'Z'
    m = re.match(
        r"^(\s*)const\s+(\w+)\s*=\s*require\(['\"]([^'\"]+)['\"]\)\s*;?\s*$",
        line,
    )
    if m:
        indent, name, module = m.groups()
        module = _drop_js_ext(module)
        changes.append(f"require('{module}') → import ... from ...")
        return f"{indent}import {name} from '{module}';"

    # module.exports.X = Y  →  export const X = Y
    m = re.match(r"^(\s*)module\.exports\.(\w+)\s*=(.*)$", line)
    if m:
        indent, name, rest = m.groups()
        changes.append(f"module.exports.{name} → export const {name}")
        return f"{indent}export const {name} ={rest}"

    # module.exports = X  →  export default X
    m = re.match(r"^(\s*)module\.exports\s*=(.*)$", line)
    if m:
        indent, rest = m.groups()
        changes.append("module.exports → export default")
        return f"{indent}export default{rest}"

    # exports.default = X  →  export default X
    m = re.match(r"^(\s*)exports\.default\s*=(.*)$", line)
    if m:
        indent, rest = m.groups()
        changes.append("exports.default → export default")
        return f"{indent}export default{rest}"

    # exports.X = Y  →  export const X = Y
    m = re.match(r"^(\s*)exports\.(\w+)\s*=(.*)$", line)
    if m:
        indent, name, rest = m.groups()
        changes.append(f"exports.{name} → export const {name}")
        return f"{indent}export const {name} ={rest}"

    # Strip .js from import paths
    if "from " in line:
        new_line = re.sub(
            r"(from\s+['\"])([^'\"]+)\.js(['\"])",
            lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}",
            line,
        )
        if new_line != line:
            changes.append("Removed .js extension from import path")
            return new_line

    return line


def _drop_js_ext(module: str) -> str:
    return module[:-3] if module.endswith(".js") else module


# ---------------------------------------------------------------------------
# Return-type annotations
# ---------------------------------------------------------------------------

def _add_return_types(content: str) -> tuple[str, list[str]]:
    changes: list[str] = []

    # async function foo(params) {  →  async function foo(params): Promise<any> {
    def _annotate_fn(m: re.Match) -> str:
        full = m.group(0)
        if ": Promise" in full or "): void" in full:
            return full
        indent, name, params = m.group(1), m.group(2), m.group(3)
        changes.append(f"Added Promise<any> to async function {name}")
        return f"{indent}async function {name}({params}): Promise<any> {{"

    content = re.sub(
        r"^(\s*)async\s+function\s+(\w+)\s*\(([^)]*)\)\s*\{",
        _annotate_fn,
        content,
        flags=re.MULTILINE,
    )

    # async methodName(params) {  in class bodies
    def _annotate_method(m: re.Match) -> str:
        full = m.group(0)
        if ": Promise" in full or "): void" in full:
            return full
        indent, name, params = m.group(1), m.group(2), m.group(3)
        changes.append(f"Added Promise<any> to async method {name}")
        return f"{indent}async {name}({params}): Promise<any> {{"

    content = re.sub(
        r"^(\s+)async\s+(\w+)\s*\(([^)]*)\)\s*\{",
        _annotate_method,
        content,
        flags=re.MULTILINE,
    )

    return content, changes
