# Make each common expect/actual mistake in a copy of reading-list-2, compile every target, and record what happens:
# which compilations fail, the compiler's exact messages, or, for mistakes that compile, what a test sees at run time.
#   python scripts/kmp/expect_mistakes.py WORKDIR   ->  content/Kotlin Multiplatform/demo/kmp-mistakes.json
# Needs JDK 21 (JAVA_HOME) and the Android SDK; the iOS compilation works without Xcode (linking does not).
import json, os, re, shutil, subprocess, sys, time, glob

HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "reading-list-2")
WORK = os.path.abspath(sys.argv[1])
OUT = os.path.join(HERE, "..", "..", "content", "Kotlin Multiplatform", "demo", "kmp-mistakes.json")
PKG = "shared/src/{}/kotlin/dev/fauzisho/readinglist/"
COMPILE = {   # one compilation per target, plus the two shared (metadata) compilations
    "common (metadata)": ":shared:compileCommonMainKotlinMetadata",
    "nonAndroid (metadata)": ":shared:compileNonAndroidMainKotlinMetadata",
    "Android": ":shared:compileAndroidMain",
    "desktop JVM": ":shared:compileKotlinJvm",
    "web (Wasm)": ":shared:compileKotlinWasmJs",
    "iOS": ":shared:compileKotlinIosSimulatorArm64",
}

def edit(path, old, new):
    def f():
        p = os.path.join(WORK, path); s = open(p).read()
        assert old in s, (path, old)
        open(p, "w").write(s.replace(old, new, 1))
    return f

def write(path, text):
    def f():
        p = os.path.join(WORK, path); os.makedirs(os.path.dirname(p), exist_ok=True); open(p, "w").write(text)
    return f

def delete(path):
    return lambda: os.remove(os.path.join(WORK, path))

H = "package dev.fauzisho.readinglist\n\n"
CASES = [
    dict(id="missing-actual", title="An actual is missing on one target",
         change="Delete the web actual of platformName().", show="// wasmJsMain/Platform.wasmJs.kt: deleted",
         steps=[delete(PKG.format("wasmJsMain") + "Platform.wasmJs.kt")]),
    dict(id="no-actual-keyword", title="The actual keyword is forgotten",
         change="Write the desktop implementation as a plain function.",
         show='// jvmMain\nfun platformName(): String = "desktop JVM …"',
         steps=[edit(PKG.format("jvmMain") + "Platform.jvm.kt", "actual fun platformName", "fun platformName")]),
    dict(id="wrong-type", title="The signatures do not match",
         change="The desktop actual returns an Int.", show="// jvmMain\nactual fun platformName(): Int = 21",
         steps=[write(PKG.format("jvmMain") + "Platform.jvm.kt", H + "actual fun platformName(): Int = 21\n")]),
    dict(id="wrong-package", title="The actual is in another package",
         change="Move the Android actual to dev.fauzisho.readinglist.platform.",
         show="// androidMain\npackage dev.fauzisho.readinglist.platform\nactual fun platformName(): String = …",
         steps=[edit(PKG.format("androidMain") + "Platform.android.kt", "package dev.fauzisho.readinglist", "package dev.fauzisho.readinglist.platform")]),
    dict(id="default-on-actual", title="A default argument on the actual",
         change="Add a parameter to platformName(); give it a default value on the expect and again on the desktop actual.",
         show="// commonMain\nexpect fun platformName(verbose: Boolean = false): String\n// jvmMain\nactual fun platformName(verbose: Boolean = true): String = …",
         steps=[edit(PKG.format("commonMain") + "Platform.kt", "expect fun platformName()", "expect fun platformName(verbose: Boolean = false)")]
               + [edit(PKG.format(s) + f"Platform.{n}.kt", "actual fun platformName()", "actual fun platformName(verbose: Boolean)")
                  for s, n in (("androidMain", "android"), ("wasmJsMain", "wasmJs"), ("iosMain", "ios"))]
               + [edit(PKG.format("jvmMain") + "Platform.jvm.kt", "actual fun platformName()", "actual fun platformName(verbose: Boolean = true)")]),
    dict(id="two-actuals", title="Two actuals on one target's path",
         change="Add a desktop-specific appColorScheme() although nonAndroidMain already has one.",
         show="// jvmMain, in addition to the one in nonAndroidMain\n@Composable actual fun appColorScheme(): ColorScheme = darkColorScheme()",
         steps=[write(PKG.format("jvmMain") + "Theme.jvm.kt", H + "import androidx.compose.material3.ColorScheme\nimport androidx.compose.material3.darkColorScheme\nimport androidx.compose.runtime.Composable\n\n@Composable\nactual fun appColorScheme(): ColorScheme = darkColorScheme()\n")]),
    dict(id="android-in-common", title="An Android API in commonMain",
         change="Read the Android version directly in shared code.",
         show="// commonMain\nfun sdkLevel(): Int = android.os.Build.VERSION.SDK_INT",
         steps=[write(PKG.format("commonMain") + "Sdk.kt", H + "fun sdkLevel(): Int = android.os.Build.VERSION.SDK_INT\n")]),
    dict(id="jvm-in-nonandroid", title="A JVM API in nonAndroidMain",
         change="Use java.io.File in the source set shared by desktop, web and iOS.",
         show="// nonAndroidMain\nfun home(): String = java.io.File(\".\").absolutePath",
         steps=[write(PKG.format("nonAndroidMain") + "Home.kt", H + "fun home(): String = java.io.File(\".\").absolutePath\n")]),
    dict(id="unchecked-capability", title="A missing capability used without a check",
         change="Call the sharer as if every platform had one.",
         show="// commonMain, App.kt\nTextButton(onClick = { services.sharer.share(…) }) { Text(\"Share\") }",
         steps=[edit(PKG.format("commonMain") + "App.kt", "services.sharer?.let { sharer ->", "services.sharer.let { sharer ->")]),
    dict(id="interface-member", title="A platform class misses an interface member",
         change="Add remove() to KeyValueStore and implement it everywhere except the desktop FileStore.",
         show="// commonMain\ninterface KeyValueStore { …; fun remove(key: String) }\n// jvmMain: FileStore unchanged",
         steps=[edit(PKG.format("commonMain") + "Services.kt", "    fun put(key: String, value: String)\n}", "    fun put(key: String, value: String)\n    fun remove(key: String)\n}"),
                edit(PKG.format("androidMain") + "Services.android.kt", "prefs.edit().putString(key, value).apply()\n", "prefs.edit().putString(key, value).apply()\n    override fun remove(key: String) = prefs.edit().remove(key).apply()\n"),
                edit(PKG.format("wasmJsMain") + "Services.wasmJs.kt", "storageSet(key, value)\n", "storageSet(key, value)\n    override fun remove(key: String) = Unit\n"),
                edit(PKG.format("iosMain") + "Services.ios.kt", "forKey = key)\n", "forKey = key)\n    override fun remove(key: String) = defaults.removeObjectForKey(key)\n"),
                edit("shared/src/commonTest/kotlin/dev/fauzisho/readinglist/ReadingListTest.kt", "values[key] = value }\n", "values[key] = value }\n    override fun remove(key: String) { values.remove(key) }\n")]),
    dict(id="expect-class", title="An expect class",
         change="Declare the store as an expect class instead of an interface.",
         show="// commonMain\nexpect class Settings() { fun get(key: String): String? }\n// one actual class per target",
         steps=[write(PKG.format("commonMain") + "Settings.kt", H + "expect class Settings() {\n    fun get(key: String): String?\n}\n")]
               + [write(PKG.format(s) + f"Settings.{n}.kt", H + "actual class Settings actual constructor() {\n    actual fun get(key: String): String? = null\n}\n")
                  for s, n in (("androidMain", "android"), ("jvmMain", "jvm"), ("wasmJsMain", "wasmJs"), ("iosMain", "ios"))]),
    dict(id="todo-actual", title="An actual that is a TODO()",
         change="Leave the desktop actual unimplemented.",
         show='// jvmMain\nactual fun platformName(): String = TODO("not on desktop yet")',
         steps=[write(PKG.format("jvmMain") + "Platform.jvm.kt", H + 'actual fun platformName(): String = TODO("not on desktop yet")\n'),
                write("shared/src/jvmTest/kotlin/dev/fauzisho/readinglist/RunTest.kt", H + "import kotlin.test.Test\n\nclass RunTest {\n    @Test fun header() { println(platformName()) }\n}\n")],
         run=(":shared:jvmTest", "desktop JVM")),
    dict(id="global-context", title="A global Context behind an expect fun",
         change="Make the store an expect fun; on Android it reads a lateinit global Context that the app is supposed to set first.",
         show="// commonMain\nexpect fun createStore(): KeyValueStore\n// androidMain\nlateinit var appContext: Context\nactual fun createStore(): KeyValueStore = PrefsStore(appContext)",
         steps=[write(PKG.format("commonMain") + "CreateStore.kt", H + "expect fun createStore(): KeyValueStore\n"),
                write(PKG.format("androidMain") + "CreateStore.android.kt", H + "import android.content.Context\n\nlateinit var appContext: Context\n\nactual fun createStore(): KeyValueStore = PrefsStore(appContext)\n")]
               + [write(PKG.format(s) + f"CreateStore.{n}.kt", H + f"actual fun createStore(): KeyValueStore = {impl}\n")
                  for s, n, impl in (("jvmMain", "jvm", 'FileStore(java.io.File("build/store"))'), ("wasmJsMain", "wasmJs", "LocalStorageStore()"), ("iosMain", "ios", "UserDefaultsStore()"))]
               + [write("shared/src/androidHostTest/kotlin/dev/fauzisho/readinglist/RunTest.kt", H + "import kotlin.test.Test\n\nclass RunTest {\n    @Test fun open() { ReadingList(createStore()) }\n}\n")],
         run=(":shared:testAndroidHostTest", "Android")),
]

def gradle(tasks):
    env = dict(os.environ)
    t = time.time()
    r = subprocess.run(["./gradlew", "-Pkmp.ios=true", "--continue", "--console=plain", *tasks], cwd=WORK, env=env, capture_output=True, text=True)
    return r.stdout + r.stderr, time.time() - t

def clean_msg(line):
    line = re.sub(r"file://\S*/shared/src/", "", line)
    return re.sub(r"\s+", " ", line).strip()

def messages(log, prefix):
    """Compiler messages, with their continuation lines (e.g. the list after 'Conflicting overloads:')."""
    out, cur = [], None
    for l in log.splitlines():
        if l.startswith(prefix):
            cur = [l[len(prefix):]]; out.append(cur)
        elif cur is not None and l.strip() and not l.startswith(("e: ", "w: ", "> ", "FAILURE", "* ")):
            cur.append(l.strip())
        else:
            cur = None
    return list(dict.fromkeys(clean_msg(" ".join(m)) for m in out))

def reset():
    for d in ("shared/src", "androidApp/src"):
        shutil.rmtree(os.path.join(WORK, d), ignore_errors=True)
        shutil.copytree(os.path.join(SRC, d), os.path.join(WORK, d))

if not os.path.exists(os.path.join(WORK, "settings.gradle.kts")):
    shutil.copytree(SRC, WORK, ignore=shutil.ignore_patterns("build", ".gradle", ".kotlin"))
reset()
log, _ = gradle(list(COMPILE.values()))     # warm up: the unchanged project must compile everywhere
assert "BUILD SUCCESSFUL" in log, log[-3000:]
base_warn = set(messages(log, "w: "))

results = []
for c in CASES:
    reset()
    for s in c["steps"]: s()
    log, secs = gradle(list(COMPILE.values()))
    open(os.path.join(WORK, f"{c['id']}.log"), "w").write(log)
    failed = [name for name, task in COMPILE.items() if re.search(r"Execution failed for task '" + re.escape(task) + "'", log)]
    errors = messages(log, "e: ")
    warns = [w for w in messages(log, "w: ") if w not in base_warn]
    r = dict(id=c["id"], title=c["title"], change=c["change"], code=c["show"], failed=failed, errors=errors, warnings=warns)
    if not failed and "run" in c:
        task, target = c["run"]
        for x in glob.glob(os.path.join(WORK, "shared/build/test-results/*/TEST-*RunTest.xml")): os.remove(x)
        out, _ = gradle([task, "--tests", "dev.fauzisho.readinglist.RunTest"])
        xml = "".join(open(x).read() for x in glob.glob(os.path.join(WORK, "shared/build/test-results/*/TEST-*RunTest.xml")))
        m = re.search(r'<failure message="([^"]*)" type="([^"]*)"', xml)
        r["runtime"] = dict(target=target, crashed=bool(m), exception=m and m.group(2), message=m and m.group(1).replace("&quot;", '"').replace("&apos;", "'"))
    r["caught"] = "compile" if failed else ("runtime" if r.get("runtime", {}).get("crashed") else ("warning" if warns else "nothing"))
    results.append(r)
    print(f"{c['id']:24} {r['caught']:8} failed={failed} {len(errors)} errors {len(warns)} warnings {secs:.0f}s")
    for e in errors[:3] + warns[:2]: print("    ", e[:200])
    if "runtime" in r: print("    runtime:", r["runtime"])

reset()
os.makedirs(os.path.dirname(OUT), exist_ok=True)
json.dump(dict(targets=list(COMPILE), kotlin="2.4.21", cases=results), open(OUT, "w"), indent=1, ensure_ascii=False)
print("wrote", os.path.relpath(OUT))
