"""Task 07 - Convert the CSV corpus to RDF.

Streams the corpus into ./out/rdf/dataset.nt.gz (N-Triples, gzip) and
./out/rdf/dataset.ttl (Turtle) with the task 04 vocabulary (tribo# + PROV-O
for started_at), then bulk-loads the N-Triples into an embedded on-disk
oxigraph store (./out/rdf/oxigraph_store/) and smoke-tests it with SPARQL
COUNT queries. Bulk points (cycles, loop points, map points) carry no
rdf:type - the containing predicate types them (documented decision per
spec 07), which is why the triple count lands below the spec's 25-45M
sketch. Appends serialization and store sizes to storage_sizes.csv and
writes ./out/.done/07.ok. Spec: docs/specs/07_convert_rdf.md.
"""

from __future__ import annotations

import gzip
import io
import logging
import shutil
import sys
import time
from pathlib import Path

from pyoxigraph import Store

try:
	from pyoxigraph import RdfFormat
	NT_FORMAT = RdfFormat.N_TRIPLES
except ImportError:  # pyoxigraph < 0.5 uses MIME strings
	NT_FORMAT = "application/n-triples"

from common.corpus import CorpusReader, ValidationError
from common.pipeline import (
	check_dependencies,
	load_lab_config,
	parse_task_args,
	process_metrics,
	remove_stale_marker,
	write_marker,
)
from common.storage_sizes import update_storage_sizes

logger = logging.getLogger(__name__)

TASK_ID = "07"
DEPENDS_ON = ["01", "03"]

VOCAB = "https://sandia.gov/ontology/tribology#"
ID_BASE = "https://sandia.gov/ontology/tribology/id/"
XSD = "http://www.w3.org/2001/XMLSchema#"

PREFIXES = {
	"tribo": VOCAB,
	"batch": ID_BASE + "batch/",
	"wafer": ID_BASE + "wafer/",
	"coupon": ID_BASE + "coupon/",
	"track": ID_BASE + "track/",
	"run": ID_BASE + "run/",
	"instrument": ID_BASE + "instrument/",
	"simtra": ID_BASE + "simtra/",
	"prov": "http://www.w3.org/ns/prov#",
	"xsd": XSD,
}

INT_COLS = {
	"cycle", "grid_x", "grid_y", "indent_id", "plates", "plate", "probe", "square",
	"row_no", "wafer_index", "coupons", "friction_coupons", "reserve_coupons",
	"pt_gun_tilt_deg", "au_gun_tilt_deg", "pt_power_w", "au_power_w",
	"pt_discharge_v", "au_discharge_v",
}
DOUBLE_COLS = {
	"cof", "position_um", "friction_force_mn", "pt_wtpct", "au_wtpct", "thickness_um",
	"x_um", "y_um", "hardness_gpa", "reduced_modulus_gpa", "max_load_mn", "ra_nm",
	"rq_nm", "wear_volume_um3", "k_archard", "sliding_distance_m", "load_mn",
	"stroke_mm", "speed_mm_s", "angle_deg", "energy_ev", "pt_flux", "au_flux",
	"au_wtpct_mean",
}
DATE_COLS = {"date", "deposition_date"}

MEASUREMENTS = [
	("xrf_map.csv", "xrf", "CompositionMeasurement", "m4_tornado"),
	("profilometry.csv", "profilometry", "ProfilometryMeasurement", "profilometer"),
	("nanoindentation.csv", "nanoindentation", "NanoindentationMeasurement", "ti980"),
]


def lit(col: str, token: str) -> tuple:
	if col in INT_COLS:
		return ("l", token, "integer")
	if col in DOUBLE_COLS:
		return ("l", token, "double")
	if col in DATE_COLS:
		return ("l", token, "date")
	if col == "started_at":
		return ("l", token, "dateTime")
	return ("l", token, None)


def iri(curie: str) -> tuple:
	return ("i", curie)


def node(pairs: list) -> tuple:
	return ("n", pairs)


class RdfEmitter:
	"""Writes the same subject blocks to N-Triples and Turtle streams."""

	def __init__(self, nt_fh, ttl_fh) -> None:
		self.nt = nt_fh
		self.ttl = ttl_fh
		self.triples = 0
		self._blank = 0
		for prefix, base in PREFIXES.items():
			self.ttl.write(f"@prefix {prefix}: <{base}> .\n")
		self.ttl.write("\n")

	def _full(self, curie: str) -> str:
		prefix, local = curie.split(":", 1)
		return f"<{PREFIXES[prefix]}{local}>"

	def _pred_nt(self, pred: str) -> str:
		if pred == "a":
			return "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>"
		return self._full(pred)

	@staticmethod
	def _escape(text: str) -> str:
		return text.replace("\\", "\\\\").replace('"', '\\"')

	def _obj_nt(self, obj: tuple) -> str:
		kind = obj[0]
		if kind == "i":
			return self._full(obj[1])
		if kind == "l":
			_, token, dtype = obj
			if dtype is None:
				return f'"{self._escape(token)}"'
			return f'"{token}"^^<{XSD}{dtype}>'
		self._blank += 1
		label = f"_:b{self._blank}"
		for pred, sub_obj in obj[1]:
			self.nt.write(f"{label} {self._pred_nt(pred)} {self._obj_nt(sub_obj)} .\n")
			self.triples += 1
		return label

	def _obj_ttl(self, obj: tuple) -> str:
		kind = obj[0]
		if kind == "i":
			return obj[1]
		if kind == "l":
			_, token, dtype = obj
			if dtype is None:
				return f'"{self._escape(token)}"'
			if dtype == "integer":
				return token
			return f'"{token}"^^xsd:{dtype}'
		inner = " ; ".join(f"{p} {self._obj_ttl(o)}" for p, o in obj[1])
		return f"[ {inner} ]"

	def emit(self, subject: str, pairs: list) -> None:
		subj_nt = self._full(subject)
		ttl_parts = []
		for pred, obj in pairs:
			# N-Triples: nested nodes expand to labelled blanks first, so the
			# referencing triple is written after the node's own triples.
			obj_nt = self._obj_nt(obj)
			self.nt.write(f"{subj_nt} {self._pred_nt(pred)} {obj_nt} .\n")
			self.triples += 1
			ttl_parts.append(f"{pred} {self._obj_ttl(obj)}")
		self.ttl.write(subject + " " + " ;\n  ".join(ttl_parts) + " .\n")


class RdfConverter:
	def __init__(self, cfg: dict, csv_root: Path, emitter: RdfEmitter) -> None:
		self.cfg = cfg
		self.reader = CorpusReader(csv_root)
		self.em = emitter
		self.counts = {"coupons": 0, "tracks": 0, "cycle_nodes": 0, "loop_nodes": 0, "xrf_nodes": 0}

	def raw_rows(self, relpath: str) -> list[dict[str, str]]:
		header, rows = self.reader.read(relpath)
		keys = [h.lower() for h in header]
		return [dict(zip(keys, row)) for row in rows]

	def prop_pairs(self, row: dict[str, str], skip: tuple = ()) -> list:
		return [
			("prov:startedAtTime" if col == "started_at" else f"tribo:{col}", lit(col, token))
			for col, token in row.items()
			if col not in skip and token != ""
		]

	def convert(self) -> None:
		for inst in self.cfg["instruments"]:
			self.em.emit(f"instrument:{inst['instrument_id']}", [
				("a", iri("tribo:Instrument")),
				("tribo:name", ("l", inst["name"], None)),
				("tribo:role", ("l", inst["role"], None)),
			])
		for row in self.raw_rows("batches.csv"):
			code = row["batch_code"]
			self.em.emit(f"batch:{code}", [("a", iri("tribo:Batch"))] + self.prop_pairs(row))
			points = [
				("tribo:hasPoint", node([("tribo:row_no", ("l", str(no), "integer"))] + self.prop_pairs(p)))
				for no, p in enumerate(self.raw_rows(f"simtra/simtra_profile_{code}.csv"), 1)
			]
			self.em.emit(f"simtra:{code}", [
				("a", iri("tribo:SimtraProfile")),
				("tribo:performedOn", iri(f"batch:{code}")),
				("tribo:performedBy", iri("instrument:simtra")),
			] + points)
		for row in self.raw_rows("runs.csv"):
			self.em.emit(f"run:{row['run_code']}", [("a", iri("tribo:Run"))] + self.prop_pairs(row))

		h = self.cfg["hierarchy"]
		for batch in self.cfg["deposition_matrix"]:
			b_code = batch["batch_code"]
			for w in range(1, h["wafers_per_batch"] + 1):
				wafer_dir = f"batch_{b_code}/wafer_W{w}"
				info = self.raw_rows(f"{wafer_dir}/wafer_info.csv")[0]
				self.em.emit(f"wafer:{info['wafer_code']}", [
					("a", iri("tribo:Wafer")),
					("tribo:partOf", iri(f"batch:{b_code}")),
				] + self.prop_pairs(info, skip=("batch_code",)))
				for c in range(1, h["coupons_per_wafer"] + 1):
					self.convert_coupon(f"{wafer_dir}/coupon_C{c:02d}")
			logger.info("batch %s converted (%d triples so far)", b_code, self.em.triples)

	def convert_coupon(self, rel_dir: str) -> None:
		info = self.raw_rows(f"{rel_dir}/coupon_info.csv")[0]
		code = info["coupon_code"]
		subject = f"coupon:{code}"
		is_friction = info["run_code"] != "RESERVE"
		pairs = [("a", iri("tribo:Coupon")), ("tribo:cutFrom", iri(f"wafer:{info['wafer_code']}"))]
		pairs += self.prop_pairs(info, skip=("batch_code", "wafer_code", "run_code"))
		pairs.append(("tribo:run_code", ("l", info["run_code"], None)))
		if is_friction:
			pairs.append(("tribo:duringRun", iri(f"run:{info['run_code']}")))

		for csv_name, key, cls, inst in MEASUREMENTS:
			points = [
				("tribo:hasPoint" if key != "nanoindentation" else "tribo:hasIndent", node(self.prop_pairs(p)))
				for p in self.raw_rows(f"{rel_dir}/{csv_name}")
			]
			if key == "xrf":
				self.counts["xrf_nodes"] += len(points)
			pairs.append((f"tribo:{key}", node([
				("a", iri(f"tribo:{cls}")),
				("tribo:performedOn", iri(subject)),
				("tribo:performedBy", iri(f"instrument:{inst}")),
			] + points)))
		afm = self.raw_rows(f"{rel_dir}/afm.csv")[0]
		pairs.append(("tribo:afm", node([
			("a", iri("tribo:AFMMeasurement")),
			("tribo:performedOn", iri(subject)),
			("tribo:performedBy", iri("instrument:afm")),
		] + self.prop_pairs(afm))))
		self.em.emit(subject, pairs)
		self.counts["coupons"] += 1

		if is_friction:
			for t in range(1, self.cfg["friction_assignment"]["tracks_per_friction_coupon"] + 1):
				self.convert_track(f"{rel_dir}/track_T{t}", subject)

	def convert_track(self, rel_dir: str, coupon_subject: str) -> None:
		info = self.raw_rows(f"{rel_dir}/track_info.csv")[0]
		pairs = [
			("a", iri("tribo:FrictionTest")),
			("tribo:partOf", iri(coupon_subject)),
			("tribo:duringRun", iri(f"run:{info['run_code']}")),
			("tribo:performedBy", iri("instrument:rapid")),
		] + self.prop_pairs(info, skip=("track_code", "run_code"))
		pairs.insert(1, ("tribo:track_code", ("l", info["track_code"], None)))

		cycles = self.raw_rows(f"{rel_dir}/cof_vs_cycle.csv")
		pairs += [("tribo:hasCycle", node(self.prop_pairs(row))) for row in cycles]
		self.counts["cycle_nodes"] += len(cycles)
		loops = self.raw_rows(f"{rel_dir}/friction_loops.csv")
		pairs += [("tribo:hasLoopPoint", node(self.prop_pairs(row))) for row in loops]
		self.counts["loop_nodes"] += len(loops)
		wear = self.raw_rows(f"{rel_dir}/wear.csv")[0]
		pairs.append(("tribo:wear", node([("a", iri("tribo:WearMeasurement"))] + self.prop_pairs(wear))))
		self.em.emit(f"track:{info['track_code']}", pairs)
		self.counts["tracks"] += 1

	def validate_counts(self) -> None:
		v = self.cfg["volumes"]
		expected = {
			"coupons": v["coupons_total"],
			"tracks": v["tracks_total"],
			"cycle_nodes": v["cycle_rows_total"],
			"loop_nodes": v["loop_points_total"],
			"xrf_nodes": v["xrf_points_total"],
		}
		for key, exp in expected.items():
			if self.counts[key] != exp:
				raise ValidationError(f"count mismatch: {key}: converted {self.counts[key]} != expected {exp}")
		logger.info("all converted counts match lab_config volumes: %s", self.counts)


def bulk_load(store: Store, nt_gz: Path) -> None:
	with gzip.open(nt_gz, "rb") as fh:
		try:
			store.bulk_load(fh, NT_FORMAT)
		except TypeError:
			store.bulk_load(input=fh, format=NT_FORMAT)


def sparql_count(store: Store, query: str) -> int:
	solutions = store.query(query)
	return int(next(iter(solutions))[0].value)


def main() -> int:
	args = parse_task_args("Task 07: convert the CSV corpus to RDF")
	out_root: Path = args.out_root
	rdf_root = out_root / "rdf"
	nt_gz = rdf_root / "dataset.nt.gz"
	ttl_path = rdf_root / "dataset.ttl"
	store_dir = rdf_root / "oxigraph_store"
	try:
		check_dependencies(out_root, DEPENDS_ON)
		remove_stale_marker(out_root, TASK_ID)
		if rdf_root.exists():
			logger.info("re-run: removing previous output %s", rdf_root)
			shutil.rmtree(rdf_root)
		rdf_root.mkdir(parents=True)
		cfg = load_lab_config(out_root)

		# mtime=0 keeps the gzip byte-identical across re-runs (data-determinism)
		with open(nt_gz, "wb") as raw:
			gz = gzip.GzipFile(filename="", mode="wb", fileobj=raw, compresslevel=6, mtime=0)
			with io.TextIOWrapper(gz, encoding="ascii", newline="\n") as nt_fh, \
					open(ttl_path, "w", encoding="ascii", newline="\n") as ttl_fh:
				emitter = RdfEmitter(nt_fh, ttl_fh)
				conv = RdfConverter(cfg, out_root / "csv", emitter)
				conv.convert()
				conv.validate_counts()
				triples = emitter.triples
		nt_bytes = nt_gz.stat().st_size
		ttl_bytes = ttl_path.stat().st_size
		logger.info("serialized %d triples: nt.gz %.1f MiB, ttl %.1f MiB",
			triples, nt_bytes / 1048576, ttl_bytes / 1048576)

		t0 = time.time()
		store = Store(str(store_dir))
		bulk_load(store, nt_gz)
		store.flush()
		load_seconds = round(time.time() - t0, 1)

		total = sparql_count(store, "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }")
		if total != triples:
			raise ValidationError(f"store holds {total} triples, serialized {triples}")
		q1 = sparql_count(
			store,
			f"SELECT (COUNT(?c) AS ?n) WHERE {{ <{ID_BASE}track/B722-W2-C13-T2> <{VOCAB}hasCycle> ?c }}",
		)
		if q1 != cfg["volumes"]["cycles_per_track"]:
			raise ValidationError(f"SPARQL Q1 smoke test returned {q1} cycles")
		logger.info("store smoke tests ok: %d triples, Q1 track has %d cycles (load %.1f s)", total, q1, load_seconds)
		del store
		store_bytes = sum(f.stat().st_size for f in store_dir.rglob("*") if f.is_file())

		update_storage_sizes(out_root / "bench", "rdf", [
			("nt_gz", nt_bytes), ("ttl", ttl_bytes), ("store", store_bytes),
		])
		entries = {
			"triples": triples,
			"nt_gz_bytes": nt_bytes,
			"ttl_bytes": ttl_bytes,
			"store_bytes": store_bytes,
			"store_load_seconds": load_seconds,
			"coupons": conv.counts["coupons"],
			"tracks": conv.counts["tracks"],
			"cycle_nodes": conv.counts["cycle_nodes"],
			"loop_nodes": conv.counts["loop_nodes"],
		}
		entries.update(process_metrics())
		marker_path = write_marker(out_root, TASK_ID, entries)
	except Exception:
		logger.critical("task %s failed", TASK_ID, exc_info=True)
		return 1

	print(f"task 07 ok: {triples} triples; nt.gz {nt_bytes / 1048576:.1f} MiB, "
		f"ttl {ttl_bytes / 1048576:.1f} MiB, store {store_bytes / 1048576:.1f} MiB "
		f"(bulk load {load_seconds} s)")
	print(f"entities: coupons={conv.counts['coupons']} tracks={conv.counts['tracks']} "
		f"cycles={conv.counts['cycle_nodes']} loop_points={conv.counts['loop_nodes']}")
	print(f"marker: {marker_path}")
	return 0


if __name__ == "__main__":
	sys.exit(main())
