"""Source-preserving text-box extraction and sparse overrides; layout contract v2."""
import hashlib
import pathlib
import posixpath
import zipfile
from lxml import etree as ET
from renderer import fromstring, A, P

NS = {"a": A[1:-1], "p": P[1:-1]}
R = "{http://schemas.openxmlformats.org/package/2006/relationships}"

def related(z, part, kind):
    entry = posixpath.dirname(part) + "/_rels/" + posixpath.basename(part) + ".rels"
    if entry not in z.namelist():
        return None
    for rel in fromstring(z.read(entry)):
        if rel.get("TargetMode") != "External" and rel.get("Type", "").endswith("/" + kind):
            path = posixpath.normpath(posixpath.join(posixpath.dirname(part), rel.get("Target", "")))
            if path.startswith("ppt/") and path in z.namelist():
                return path
    return None

def placeholder(shape, root, by_type=False):
    ph = shape.find("p:nvSpPr/p:nvPr/p:ph", NS)
    if ph is None or root is None:
        return None
    for candidate in root.findall(".//" + P + "sp"):
        other = candidate.find("p:nvSpPr/p:nvPr/p:ph", NS)
        if other is not None and (other.get("type", "obj") == ph.get("type", "obj") if by_type else other.get("idx", "0") == ph.get("idx", "0")):
            return candidate
    return None

def effective_properties(text, sources, master, presentation, theme):
    """Resolve each paragraph/run independently; never borrow a sibling's formatting."""
    samples = {}
    paragraphs = []
    def add(key, value, origin):
        samples.setdefault(key, []).append((value, origin))
        return value
    def value(chain, key, default=None):
        return next(((node.get(key), origin) for node, origin in chain if node is not None and node.get(key) is not None), (default, "default" if default is not None else "unresolved"))
    def font_name(name, script):
        if not name or not name.startswith("+"): return name
        if theme is None: return None
        group = "majorFont" if name.startswith("+mj") else "minorFont"
        kind = {"lt":"latin", "ea":"ea", "cs":"cs"}.get(name[-2:])
        if kind is None: return None
        node = theme.find(".//a:"+group+"/a:"+kind, NS)
        result = node.get("typeface") if node is not None else None
        if not result and script == "cs":
            node = theme.find(".//a:"+group+'/a:font[@script="Arab"]', NS)
            result = node.get("typeface") if node is not None else None
        return result or None
    ph = sources[0].find("p:nvSpPr/p:nvPr/p:ph", NS)
    style = "titleStyle" if ph is not None and ph.get("type") in ("title","ctrTitle") else "bodyStyle" if ph is not None and ph.get("type", "obj") in ("body","subTitle","obj") else "otherStyle"
    for paragraph in text.findall(A+"p"):
        direct = paragraph.find(A+"pPr")
        level = int(direct.get("lvl", "0")) if direct is not None else 0
        if level not in range(9): raise ValueError("Invalid paragraph level")
        chain = [(direct, "explicit")]
        for index, source in enumerate(sources):
            if source is None: continue
            origin = "slide" if index == 0 else "layout" if index == 1 else "master"
            if index:
                chain.append((source.find("p:txBody/a:p/a:pPr", NS), origin))
            for tag in ("lvl"+str(level+1)+"pPr", "defPPr"):
                chain.append((source.find("p:txBody/a:lstStyle/a:"+tag, NS), origin))
        if master is not None:
            chain.append((master.find("p:txStyles/p:"+style+"/a:lvl"+str(level+1)+"pPr", NS), "master"))
        for tag in ("lvl"+str(level+1)+"pPr", "defPPr"):
            chain.append((presentation.find("p:defaultTextStyle/a:"+tag, NS), "presentation"))
        alignment, origin = value(chain,"algn","l")
        add("alignment",alignment,origin)
        direction, origin = value(chain,"rtl","0")
        add("direction","rtl" if direction in ("1","true") else "ltr",origin)
        for key, tag, default in [("line_spacing","lnSpc",100),("space_before","spcBef",0),("space_after","spcAft",0)]:
            chosen = next(((n.find(A+tag),o) for n,o in chain if n is not None and n.find(A+tag) is not None),(None,"default"))
            node, origin = chosen
            percent = node.find(A+"spcPct") if node is not None else None
            points = node.find(A+"spcPts") if node is not None else None
            result = int(percent.get("val"))/1000 if percent is not None else int(points.get("val"))/100 if points is not None and key != "line_spacing" else default if node is None else None
            add(key,result,origin if result is not None else "unresolved")
        defaults = [(node.find(A+"defRPr"),origin) for node,origin in chain if node is not None]
        run_values = []
        runs = paragraph.findall(A+"r") + paragraph.findall(A+"fld")
        if not runs: runs = [paragraph]
        for run in runs:
            rpr = run.find(A+"rPr") if run is not paragraph else paragraph.find(A+"endParaRPr")
            run_chain = [(rpr,"explicit"),*defaults]
            size, origin = value(run_chain,"sz")
            size = add("font_size",int(size)/100 if size is not None else None,origin)
            content = "".join(node.text or "" for node in run.iter(A+"t"))
            script = "cs" if any('\u0600' <= ch <= '\u06ff' for ch in content) else "latin"
            family, origin = None,"unresolved"
            for node, candidate_origin in run_chain:
                if node is None: continue
                font = node.find(A+script)
                if font is None: font = node.find(A+"latin")
                if font is not None and font.get("typeface"):
                    family,origin = font_name(font.get("typeface"),script),candidate_origin
                    break
            add("font_family",family,origin if family else "unresolved")
            bold, bo = value(run_chain,"b","0")
            italic, io = value(run_chain,"i","0")
            face = ("Bold Italic" if italic in ("1","true") else "Bold") if bold in ("1","true") else "Italic" if italic in ("1","true") else "Regular"
            add("font_style",face,"explicit" if "explicit" in (bo,io) else bo)
            run_values.append({"text":content,"font_size":size,"font_family":family,"font_style":face})
        paragraphs.append({"level":level,"alignment":alignment,"runs":run_values})
    bodies = [(s.find("p:txBody/a:bodyPr",NS),"explicit" if i==0 else "layout" if i==1 else "master") for i,s in enumerate(sources) if s is not None]
    wrap, origin = value(bodies,"wrap","square")
    add("wrap",wrap!="none",origin)
    for key,attr,default in [("margin_left","lIns",91440),("margin_right","rIns",91440),("margin_top","tIns",45720),("margin_bottom","bIns",45720)]:
        margin, origin = value(bodies,attr,str(default))
        add(key,int(margin),origin)
    properties, states = {}, {}
    for key, entries in samples.items():
        distinct = {v for v,_ in entries}
        state = "unresolved" if None in distinct else "mixed" if len(distinct)>1 else "explicit" if all(o=="explicit" for _,o in entries) else "inherited"
        properties[key] = next(iter(distinct)) if len(distinct)==1 and None not in distinct else None
        states[key] = {"state":state,"origins":sorted({o for _,o in entries}),"values":sorted(distinct,key=str)}
    return properties,states,paragraphs

def extract(source):
    with zipfile.ZipFile(source) as z:
        presentation = fromstring(z.read("ppt/presentation.xml"))
        size = presentation.find(P + "sldSz")
        slides = []
        # Follow presentation order, not numeric filenames.
        rels = fromstring(z.read("ppt/_rels/presentation.xml.rels"))
        targets = {r.get("Id"): posixpath.normpath("ppt/" + r.get("Target", "")) for r in rels if r.get("TargetMode") != "External"}
        rid = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
        for number, reference in enumerate(presentation.findall("p:sldIdLst/p:sldId", NS), 1):
            part = targets[reference.get(rid)]
            root = fromstring(z.read(part))
            layout_part = related(z, part, "slideLayout")
            layout = fromstring(z.read(layout_part)) if layout_part else None
            master_part = related(z, layout_part, "slideMaster") if layout_part else None
            master = fromstring(z.read(master_part)) if master_part else None
            elements = []
            for shape in root.findall(".//" + P + "sp"):
                text = shape.find(P + "txBody")
                if text is None:
                    continue
                identity = shape.find("p:nvSpPr/p:cNvPr", NS)
                layout_shape = placeholder(shape, layout)
                sources = [shape, layout_shape, placeholder(layout_shape, master, True) if layout_shape is not None else None]
                transform = next((s.find("p:spPr/a:xfrm", NS) for s in sources if s is not None and s.find("p:spPr/a:xfrm", NS) is not None), None)
                reason = None
                if shape.getparent().tag != P + "spTree": reason = "Grouped text is not editable."
                if transform is None: reason = "Text-box geometry could not be resolved."
                elif int(transform.get("rot", "0")) or transform.get("flipH") in ("1", "true") or transform.get("flipV") in ("1", "true"): reason = "Rotated or flipped text is not editable."
                bodies = [s.find("p:txBody/a:bodyPr", NS) for s in sources if s is not None]
                vertical = next((body.get("vert") for body in bodies if body is not None and body.get("vert") is not None), "horz")
                if vertical != "horz" or any(body is not None and (int(body.get("rot", "0")) or body.find(A+"prstTxWarp") is not None) for body in bodies): reason = "Transformed text is not editable."
                bounds = {}
                if transform is not None:
                    off, ext = transform.find(A + "off"), transform.find(A + "ext")
                    if off is None or ext is None: reason = "Incomplete text-box geometry."
                    else: bounds = dict(x=int(off.get("x")), y=int(off.get("y")), width=int(ext.get("cx")), height=int(ext.get("cy")))
                theme_part = related(z, master_part, "theme") if master_part else None
                theme = fromstring(z.read(theme_part)) if theme_part else None
                properties,states,paragraphs = effective_properties(text,sources,master,presentation,theme)
                capabilities = {key: reason is None and (key != "line_spacing" or states[key]["state"] != "unresolved") for key in properties}
                capabilities.update({key:reason is None for key in ("x","y","width","height")})
                elements.append({"id": part + "#" + identity.get("id"), "name": identity.get("name"), "text": "\n".join("".join(node.text or "" for node in paragraph.iter(A+"t")) for paragraph in text.findall(A+"p")), "editable": reason is None, "reason": reason,
                    "bounds": bounds, "properties":properties,"property_states":states,"capabilities":capabilities,"paragraphs":paragraphs})
            slides.append({"number": number, "part": part, "elements": elements})
        return {"version": 2, "source_hash": hashlib.sha256(pathlib.Path(source).read_bytes()).hexdigest(), "width": int(size.get("cx")), "height": int(size.get("cy")), "slides": slides}

def apply(source, target, corrections):
    inventory = extract(source)
    allowed = {e["id"]: e for slide in inventory["slides"] for e in slide["elements"]}
    for identity in corrections:
        if identity not in allowed or not allowed[identity]["editable"]:
            raise ValueError("Unsupported correction identity")
    with zipfile.ZipFile(source) as src, zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as dst:
        for entry in src.infolist():
            edits = {identity.split("#")[1]: values for identity, values in corrections.items() if identity.split("#")[0] == entry.filename}
            data = src.read(entry)
            if edits:
                root = fromstring(data)
                for shape in root.findall(".//" + P + "sp"):
                    identity = shape.find("p:nvSpPr/p:cNvPr", NS).get("id")
                    if identity not in edits: continue
                    values = edits[identity]
                    if any(k in values for k in ("x", "y", "width", "height")):
                        bounds = dict(allowed[entry.filename + "#" + identity]["bounds"])
                        bounds.update({k: int(values[k]) for k in bounds if k in values})
                        sppr = shape.find(P + "spPr")
                        xfrm = sppr.find(A + "xfrm")
                        if xfrm is None: xfrm = ET.Element(A + "xfrm"); sppr.insert(0, xfrm)
                        for tag, attrs in [("off", {"x": bounds["x"], "y": bounds["y"]}), ("ext", {"cx": bounds["width"], "cy": bounds["height"]})]:
                            node = xfrm.find(A + tag)
                            if node is None: node = ET.SubElement(xfrm, A + tag)
                            node.attrib.update({k: str(v) for k,v in attrs.items()})
                    text = shape.find(P + "txBody")
                    body = text.find(A + "bodyPr")
                    if "wrap" in values: body.set("wrap", "square" if values["wrap"] else "none")
                    for key, attr in [("margin_left","lIns"),("margin_right","rIns"),("margin_top","tIns"),("margin_bottom","bIns")]:
                        if key in values: body.set(attr, str(int(values[key])))
                    for paragraph in text.findall(A + "p"):
                        if any(k in values for k in ("alignment","direction","line_spacing","space_before","space_after","font_size","font_family","font_style")):
                            ppr = paragraph.find(A + "pPr")
                            if ppr is None: ppr = ET.Element(A+"pPr"); paragraph.insert(0,ppr)
                            if "alignment" in values: ppr.set("algn",values["alignment"])
                            if "direction" in values: ppr.set("rtl","1" if values["direction"] == "rtl" else "0")
                            for key, tag, child, multiplier in [("line_spacing","lnSpc","spcPct",1000),("space_before","spcBef","spcPts",100),("space_after","spcAft","spcPts",100)]:
                                if key in values:
                                    node = ppr.find(A+tag)
                                    if node is None: node = ET.SubElement(ppr,A+tag)
                                    for old in list(node): node.remove(old)
                                    ET.SubElement(node,A+child,val=str(round(values[key]*multiplier)))
                            if "font_size" in values or "font_family" in values or "font_style" in values:
                                runs = paragraph.findall(A+"r") + paragraph.findall(A+"fld")
                                for run in runs:
                                    rpr = run.find(A+"rPr")
                                    if rpr is None: rpr=ET.Element(A+"rPr"); run.insert(0,rpr)
                                    if "font_size" in values: rpr.set("sz",str(round(values["font_size"]*100)))
                                    if "font_style" in values:
                                        rpr.set("b","1" if "bold" in values["font_style"].lower() else "0")
                                        rpr.set("i","1" if any(s in values["font_style"].lower() for s in ("italic","oblique")) else "0")
                                    if "font_family" in values:
                                        for tag in ("latin","ea","cs"):
                                            font = rpr.find(A+tag)
                                            if font is None: font=ET.SubElement(rpr,A+tag)
                                            font.set("typeface",values["font_family"])
                                    order = ["ln","noFill","solidFill","gradFill","blipFill","pattFill","grpFill","effectLst","effectDag","highlight","uLnTx","uLn","uFillTx","uFill","latin","ea","cs","sym","hlinkClick","hlinkMouseOver","rtl","extLst"]
                                    for child in sorted(list(rpr),key=lambda n: order.index(ET.QName(n).localname) if ET.QName(n).localname in order else -1): rpr.append(child)
                            order = ["lnSpc","spcBef","spcAft","buClrTx","buClr","buSzTx","buSzPct","buSzPts","buFontTx","buFont","buNone","buAutoNum","buChar","buBlip","tabLst","defRPr","extLst"]
                            for child in sorted(list(ppr),key=lambda n: order.index(ET.QName(n).localname) if ET.QName(n).localname in order else -1): ppr.append(child)
                data = ET.tostring(root,encoding="utf-8",xml_declaration=True)
            dst.writestr(entry,data)
