"""
Macer: Machine-learning accelerated Atomic Computational Environment for automated Research workflows
Copyright (c) 2025 The Macer Package Authors
Author: Soungmin Bae <soungminbae@gmail.com>
License: MIT
"""

import sys
import os
import shutil
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import yaml
from argparse import Namespace
from matplotlib import pyplot as plt
import numpy as np
from numpy.linalg import det
from monty.serialization import loadfn
from pymatgen.core import Structure, Composition, IStructure
from pymatgen.analysis.phase_diagram import PhaseDiagram, PDEntry

from pydefect.defaults import defaults
from pydefect.analyzer.make_defect_structure_info import MakeDefectStructureInfo
from pydefect.cli.make_defect_vesta_file import MakeDefectVestaFile
from pydefect.defaults import defaults as pydefect_defaults
from pydefect.input_maker.manual_supercell_maker import ManualSupercellMaker
from pydefect.input_maker.supercell import Supercell
from pydefect.input_maker.supercell_info import SimpleSite, SupercellInfo
from vise.util.structure_symmetrizer import StructureSymmetrizer, Site

from macer.calculator.factory import get_available_ffs, get_calculator
from macer.relaxation.optimizer import relax_structure
from macer.utils.logger import Logger
from macer.io.writers import write_pydefect_dummy_files
from macer.defaults import DEFAULT_MODELS, _macer_root, _model_root, DEFAULT_DEVICE, resolve_model_path

# Monkeypatch for pydefect 0.8.x which crashes if sites_yaml_filename is None
try:
    from pydefect.input_maker import manual_supercell_maker
    from pydefect.cli import main_functions
    
    _original_make_sites = manual_supercell_maker.make_sites_from_yaml_file

    def _patched_make_sites_from_yaml_file(filename):
        if filename is None:
            return {}
        return _original_make_sites(filename)

    manual_supercell_maker.make_sites_from_yaml_file = _patched_make_sites_from_yaml_file
    main_functions.make_sites_from_yaml_file = _patched_make_sites_from_yaml_file
except ImportError:
    pass

def generate_composition_energies_direct(directories, yaml_filename="composition_energies.yaml"):
    """
    Directly generates composition_energies.yaml from calc_results.json files 
    in the specified directories, bypassing vise/OUTCAR parsing.
    """
    data = {}
    for d in directories:
        d_path = Path(d)
        calc_results_path = d_path / "calc_results.json"
        
        if not calc_results_path.exists():
            print(f"Warning: {calc_results_path} not found. Skipping {d_path.name}.")
            continue
        
        try:
            # Load calc_results.json generated by macer
            calc_results = loadfn(calc_results_path)
            
            # Check if calc_results is a dict or an object
            if isinstance(calc_results, dict):
                energy = calc_results.get("energy")
                structure_dict = calc_results.get("structure")
                if structure_dict:
                    structure = Structure.from_dict(structure_dict)
                else:
                    structure = None
            else:
                # Assume it's a CalcResults object
                energy = getattr(calc_results, "energy", None)
                structure = getattr(calc_results, "structure", None)
            
            if energy is None or structure is None:
                print(f"Warning: Incomplete data in {calc_results_path}. Skipping.")
                continue
                
            # Use formula with spaces removed (e.g. "Zn2 O2" -> "Zn2O2") as key
            formula = structure.composition.formula.replace(" ", "")
            
            # If multiple phases have the same formula, we should theoretically keep the most stable one.
            # However, pydefect usually handles competing phases by ensuring they are distinct or 
            # by processing them such that the most stable ones define the hull.
            # Here we simply store it. If duplicates exist, the last one visited overwrites.
            # Ideally, directories should correspond to unique stable phases.
            
            data[formula] = {
                "energy": float(energy),
                "source": str(d_path.absolute())
            }
        except Exception as e:
            print(f"Error processing {d_path.name}: {e}")
            
    if data:
        with open(yaml_filename, "w") as f:
            yaml.dump(data, f)
        print(f"Directly generated {yaml_filename} from {len(data)} entries.")
        return True
    else:
        print(f"No data collected for {yaml_filename}.")
        return False

def get_unique_dir_name(base_name):
    if not Path(base_name).exists():
        return Path(base_name)
    i = 1
    while True:
        new_name = f"{base_name}-NEW{i:02d}"
        if not Path(new_name).exists():
            return Path(new_name)
        i += 1

def _as_float_list(seq):
    return [float(x) for x in seq]


def dim_to_matrix(dim):
    if dim is None:
        return None
    d = list(dim)
    if len(d) == 3:
        return [d[0], d[1], d[2]]
    if len(d) == 9:
        return d
    raise ValueError(
        "--dim must be 3 (diag) or 9 (full matrix) integers.")


def _dim_to_matrix_3x3(dim):
    if dim is None:
        return None
    d = list(dim)
    if len(d) == 3:
        return [[d[0], 0, 0], [0, d[1], 0], [0, 0, d[2]]]
    if len(d) == 9:
        return [d[0:3], d[3:6], d[6:9]]
    raise ValueError(
        "--dim must be 3 (diag) or 9 (full matrix) integers.")


def _make_simple_sites_from_structure(structure):
    counts = {}
    sites = {}
    for idx, site in enumerate(structure):
        elem = site.specie.symbol
        counts[elem] = counts.get(elem, 0) + 1
        name = f"{elem}{counts[elem]}"
        sites[name] = SimpleSite(elem, idx)
    return sites


def _make_irreducible_sites(structure, matrix,
                            symprec=pydefect_defaults.symmetry_length_tolerance,
                            angle_tolerance=pydefect_defaults.symmetry_angle_tolerance):
    symmetrizer = StructureSymmetrizer(structure,
                                       symprec=symprec,
                                       angle_tolerance=angle_tolerance)
    multiplicity = int(round(det(matrix)))

    sites = {}
    for site_name, site in symmetrizer.sites.items():
        atoms = []
        for i in site.equivalent_atoms:
            atoms.extend(list(range(i * multiplicity, (i + 1) * multiplicity)))
        sites[site_name] = Site(element=site.element,
                                wyckoff_letter=site.wyckoff_letter,
                                site_symmetry=site.site_symmetry,
                                equivalent_atoms=atoms)
    return symmetrizer.spglib_sym_data.international, sites


def make_manual_supercell_info(input_poscar: Path, dim, output_dir: Path = Path("."),
                               analyze_symmetry: bool = True):
    if dim is None:
        raise ValueError("--dim is required.")
    matrix = _dim_to_matrix_3x3(dim)
    input_poscar = Path(input_poscar).expanduser().resolve()
    if not input_poscar.exists():
        raise FileNotFoundError(f"{input_poscar} not found.")

    output_dir = Path(output_dir).expanduser().resolve()
    output_dir.mkdir(parents=True, exist_ok=True)

    cwd = Path.cwd()
    try:
        os.chdir(output_dir)
        # keep a local copy for traceability
        try:
            shutil.copy(str(input_poscar), "POSCAR-unitcell")
        except Exception:
            pass

        unitcell = IStructure.from_file(str(input_poscar))
        if analyze_symmetry:
            space_group, sites = _make_irreducible_sites(unitcell, matrix)
            supercell = Supercell(unitcell, matrix)
            supercell.structure.to(filename="SPOSCAR")
            supercell_info = SupercellInfo(
                structure=supercell.structure,
                space_group=space_group,
                transformation_matrix=matrix,
                sites=sites,
                unitcell_structure=unitcell
            )
            supercell_info.to_json_file()
        else:
            sites = _make_simple_sites_from_structure(unitcell)
            maker = ManualSupercellMaker(unitcell, sites=sites, matrix=matrix)
            maker.supercell.structure.to(filename="SPOSCAR")
            maker.supercell_info.to_json_file()
        print(f"Created supercell_info.json in {output_dir}")
    finally:
        os.chdir(cwd)


def plot_cpd_outputs(cpd,
                     plot_elements=None,
                     output_prefix="cpd",
                     make_pdf=True,
                     make_html=True,
                     project_elements=None):
    from pydefect.chem_pot_diag.chem_pot_diag import change_element_sequence, TargetVertices
    from pydefect.chem_pot_diag.cpd_plotter import ChemPotDiag2DMplPlotter, ChemPotDiag3DMplPlotter
    import plotly.graph_objects as go
    def _sort_coords_2d(coords_2d):
        arr = np.array(coords_2d, dtype=float)
        if len(arr) == 0:
            return []
        center = arr.mean(axis=0)
        angles = np.arctan2(arr[:, 1] - center[1], arr[:, 0] - center[0])
        order = np.argsort(angles)
        return arr[order].tolist()

    # Align element order (only when plotting full CPD for 2D/3D)
    if plot_elements and not project_elements:
        try:
            cpd = change_element_sequence(cpd, element_sequence=plot_elements)
        except Exception as e:
            print(f"Invalid plot element order {plot_elements}: {e}")
            return False
    elif not plot_elements and not project_elements:
        cpd = change_element_sequence(cpd)

    # Ensure target vertices are available for info panel
    if cpd.target and not cpd.target_vertices_dict:
        try:
            target_vertices = cpd.to_target_vertices
            cpd.target_vertices_dict = target_vertices.vertices
        except Exception:
            pass
    if Path("target_vertices.yaml").exists():
        try:
            target_vertices = TargetVertices.from_yaml("target_vertices.yaml")
            cpd.target_vertices_dict = target_vertices.vertices
        except Exception:
            pass

    project_indices = None
    project_elements_used = None
    if project_elements:
        if len(project_elements) != 2:
            print("project_elements must have exactly 2 elements.")
            return False
        missing = [e for e in project_elements if e not in cpd.vertex_elements]
        if missing:
            print(f"Invalid project_elements {missing}. Available: {cpd.vertex_elements}")
            return False
        project_indices = [cpd.vertex_elements.index(e) for e in project_elements]
        project_elements_used = list(project_elements)

    if cpd.dim not in (2, 3) and not project_indices:
        print(f"CPD dimension {cpd.dim} is not supported for plotting. Skipping outputs.")
        print("Tip: use 2D projection for higher-dimensional systems, e.g.:")
        print("  macer util pydefect cpd -r relative_energies.yaml -t <TARGET> --projection")
        print("  macer util pydefect cpd -r relative_energies.yaml -t <TARGET> --projection-elements E1 E2")
        return False

    if make_pdf:
        if cpd.dim == 2:
            plotter = ChemPotDiag2DMplPlotter(cpd)
        elif cpd.dim == 3:
            plotter = ChemPotDiag3DMplPlotter(cpd)
        else:
            plotter = None
        if plotter is None:
            return False
        plt_obj = plotter.draw_diagram()
        plt_obj.savefig(f"{output_prefix}.pdf")
        plt.close("all")
        print(f"Generated {output_prefix}.pdf")

    if make_html:
        title = f"Chemical potential diagram of {cpd.chemical_system}"
        traces = []
        all_vertex_elements = list(cpd.vertex_elements) if cpd.vertex_elements else []
        vertex_elements = project_elements_used if project_elements_used else all_vertex_elements

        def _fmt_mu(el, chem_pot):
            val = chem_pot.get(el, None)
            return f"μ_{el}={val:.4f}" if val is not None else f"μ_{el}=N/A"

        vertex_entries = []
        if cpd.target_vertices_dict:
            for label in sorted(cpd.target_vertices_dict.keys()):
                tv = cpd.target_vertices_dict[label]
                chem_pot = tv.chem_pot or {}
                comp_phases = ", ".join([str(x) for x in tv.competing_phases]) if tv.competing_phases else "-"
                imp_phases = ", ".join([str(x) for x in tv.impurity_phases]) if tv.impurity_phases else "-"
                mu_parts = [_fmt_mu(el, chem_pot) for el in vertex_elements]
                mu_parts_all = [_fmt_mu(el, chem_pot) for el in all_vertex_elements]
                vertex_entries.append(
                    {
                        "label": label,
                        "chem_pot": chem_pot,
                        "mu_parts": mu_parts,
                        "mu_parts_all": mu_parts_all,
                        "competing": comp_phases,
                        "impurity": imp_phases,
                    }
                )

        def _round_down(x, step):
            return np.floor(x / step) * step

        def _round_up(x, step):
            return np.ceil(x / step) * step
        
        def _close_loop(xs, ys):
            if not xs or not ys:
                return xs, ys
            if xs[0] != xs[-1] or ys[0] != ys[-1]:
                xs = xs + [xs[0]]
                ys = ys + [ys[0]]
            return xs, ys

        if cpd.dim == 2 or project_indices:
            for comp, verts in cpd.polygons.items():
                verts_f = [_as_float_list(v) for v in verts]
                if project_indices:
                    verts_f = [[vv[i] for i in project_indices] for vv in verts_f]
                # sort vertices to avoid self-crossing polygon
                verts_sorted = _sort_coords_2d(verts_f)
                xs = [v[0] for v in verts_sorted]
                ys = [v[1] for v in verts_sorted]
                xs, ys = _close_loop(xs, ys)
                try:
                    atomic_fractions = cpd.atomic_fractions(comp)
                    color = [(f + 1.5) / 2.5 for f in atomic_fractions]
                    rgb = [int(255 * min(max(c, 0.0), 1.0)) for c in color]
                    fill_color = f"rgba({rgb[0]},{rgb[1]},{rgb[2]},1.0)"
                except Exception:
                    fill_color = "rgba(120,120,120,1.0)"
                traces.append(go.Scatter(
                    x=xs, y=ys, mode="lines",
                    line=dict(color="black", width=1),
                    fill="toself",
                    fillcolor=fill_color,
                    name=comp, hoverinfo="skip"
                ))

            # Highlight target stability region if available
            if cpd.target_coords:
                target_coords = [_as_float_list(v) for v in cpd.target_coords.values()]
                if project_indices:
                    target_coords = [[vv[i] for i in project_indices] for vv in target_coords]
                if len(target_coords) >= 3:
                    target_sorted = _sort_coords_2d(target_coords)
                    txs = [v[0] for v in target_sorted]
                    tys = [v[1] for v in target_sorted]
                    txs, tys = _close_loop(txs, tys)
                    traces.append(go.Scatter(
                        x=txs, y=tys, mode="lines",
                        line=dict(color="rgba(30,144,255,0.8)", width=2),
                        fill="toself",
                        fillcolor="rgba(30,144,255,0.18)",
                        name=f"{cpd.target} stability region",
                        hoverinfo="skip"
                    ))
                elif len(target_coords) == 2:
                    txs = [target_coords[0][0], target_coords[1][0]]
                    tys = [target_coords[0][1], target_coords[1][1]]
                    traces.append(go.Scatter(
                        x=txs, y=tys, mode="lines",
                        line=dict(color="rgba(30,144,255,0.9)", width=4),
                        name=f"{cpd.target} stability line",
                        hoverinfo="skip"
                    ))
                # Optional quad fill for binary: B -> (x_min, B_y) -> (x_min, y_min) -> (A_x, y_min) -> A
                if (not project_indices) and cpd.target_coords and "A" in cpd.target_coords and "B" in cpd.target_coords:
                    a = _as_float_list(cpd.target_coords["A"])
                    b = _as_float_list(cpd.target_coords["B"])
                    all_pts = []
                    for verts in cpd.polygons.values():
                        for v in verts:
                            all_pts.append(_as_float_list(v))
                    if all_pts:
                        xs_all = [p[0] for p in all_pts]
                        ys_all = [p[1] for p in all_pts]
                        x_min = min(xs_all)
                        y_min = min(ys_all)
                        quad_x = [a[0], b[0], b[0], a[0], a[0]]
                        quad_y = [a[1], b[1], y_min, y_min, a[1]]
                        traces.append(go.Scatter(
                            x=quad_x, y=quad_y, mode="lines",
                            line=dict(color="rgba(30,144,255,0.8)", width=2),
                            fill="toself",
                            fillcolor="rgba(30,144,255,0.18)",
                            name=f"{cpd.target} stability quad",
                            hoverinfo="skip"
                        ))

            for comp, center in cpd.comp_centers.items():
                center_f = _as_float_list(center)
                if project_indices:
                    center_f = [center_f[i] for i in project_indices]
                traces.append(go.Scatter(
                    x=[center_f[0]], y=[center_f[1]],
                    mode="text", text=[comp],
                    textfont=dict(size=15, color="black"),
                    showlegend=False, hoverinfo="skip"
                ))

            if cpd.target_coords and vertex_entries:
                for entry in vertex_entries:
                    label = entry["label"]
                    coord = cpd.target_coords.get(label)
                    if coord is None:
                        continue
                    coord_f = _as_float_list(coord)
                    if project_indices:
                        coord_f = [coord_f[i] for i in project_indices]
                    hover_text = f"Vertex {label}<br>" + "<br>".join(entry["mu_parts_all"] if project_indices else entry["mu_parts"])
                    traces.append(go.Scatter(
                        x=[coord_f[0]], y=[coord_f[1]],
                        mode="markers+text",
                        text=[label],
                        textposition="top center",
                        textfont=dict(size=15, color="blue"),
                        marker=dict(size=8, color="blue"),
                        showlegend=False,
                        hovertemplate=hover_text + "<extra></extra>"
                    ))

            if project_indices:
                title_used = title + f" (projected: {'-'.join(project_elements_used)})"
                x_title = f"Chemical potential of {project_elements_used[0]}"
                y_title = f"Chemical potential of {project_elements_used[1]}"
            else:
                title_used = title
                x_title = f"Chemical potential of {cpd.vertex_elements[0]}"
                y_title = f"Chemical potential of {cpd.vertex_elements[1]}"

            layout = go.Layout(
                title=title_used,
                autosize=True,
                margin=dict(l=30, r=30, t=40, b=30),
                xaxis=dict(
                    title=x_title,
                    scaleanchor="y",
                    scaleratio=1
                ),
                yaxis=dict(title=y_title)
            )

            # Snap axis ranges to a fixed grid (0.5) for clean boundaries
            if cpd.polygons:
                all_pts = []
                for verts in cpd.polygons.values():
                    for v in verts:
                        vv = _as_float_list(v)
                        if project_indices:
                            vv = [vv[i] for i in project_indices]
                        all_pts.append(vv)
                if all_pts:
                    xs_all = [p[0] for p in all_pts]
                    ys_all = [p[1] for p in all_pts]
                    step = 0.5
                    x_min = _round_down(min(xs_all), step)
                    x_max = _round_up(max(xs_all), step)
                    y_min = _round_down(min(ys_all), step)
                    y_max = _round_up(max(ys_all), step)
                    layout.xaxis.update(range=[x_min, x_max])
                    layout.yaxis.update(range=[y_min, y_max])
            fig = go.Figure(data=traces, layout=layout)

        else:
            # 3D interactive plot (rotatable)
            from vise.util.sort_coords import sort_coords
            for comp, verts in cpd.polygons.items():
                verts_f = [_as_float_list(v) for v in verts]
                verts_sorted = sort_coords(np.array(verts_f)).tolist()
                xs = [v[0] for v in verts_sorted]
                ys = [v[1] for v in verts_sorted]
                zs = [v[2] for v in verts_sorted]

                if len(verts_sorted) >= 3:
                    i = []
                    j = []
                    k = []
                    for idx in range(1, len(verts_sorted) - 1):
                        i.append(0)
                        j.append(idx)
                        k.append(idx + 1)

                    # color by atomic fractions (similar to mpl)
                    try:
                        atomic_fractions = cpd.atomic_fractions(comp)
                        color = [(f + 1.5) / 2.5 for f in atomic_fractions]
                        rgb = [int(255 * min(max(c, 0.0), 1.0)) for c in color]
                        face_color = f"rgba({rgb[0]},{rgb[1]},{rgb[2]},1.0)"
                    except Exception:
                        face_color = "rgba(120,120,120,1.0)"

                    traces.append(go.Mesh3d(
                        x=xs, y=ys, z=zs,
                        i=i, j=j, k=k,
                        color=face_color,
                        opacity=1.0,
                        name=comp,
                        hoverinfo="skip",
                        showscale=False
                    ))

                xs_l = xs + [xs[0]]
                ys_l = ys + [ys[0]]
                zs_l = zs + [zs[0]]
                traces.append(go.Scatter3d(
                    x=xs_l, y=ys_l, z=zs_l,
                    mode="lines",
                    line=dict(color="black", width=2),
                    name=comp,
                    hoverinfo="skip",
                    showlegend=False
                ))

            for comp, center in cpd.comp_centers.items():
                center_f = _as_float_list(center)
                traces.append(go.Scatter3d(
                    x=[center_f[0]], y=[center_f[1]], z=[center_f[2]],
                    mode="text", text=[comp],
                    textfont=dict(size=15, color="black"),
                    showlegend=False, hoverinfo="skip"
                ))

            if cpd.target_coords and vertex_entries:
                for entry in vertex_entries:
                    label = entry["label"]
                    coord = cpd.target_coords.get(label)
                    if coord is None:
                        continue
                    coord_f = _as_float_list(coord)
                    hover_text = f"Vertex {label}<br>" + "<br>".join(entry["mu_parts"])
                    traces.append(go.Scatter3d(
                        x=[coord_f[0]], y=[coord_f[1]], z=[coord_f[2]],
                        mode="markers+text",
                        text=[label],
                        textposition="top center",
                        textfont=dict(size=15, color="blue"),
                        marker=dict(size=4, color="blue"),
                        showlegend=False,
                        hovertemplate=hover_text + "<extra></extra>"
                    ))

            layout = go.Layout(
                title=title,
                autosize=True,
                margin=dict(l=0, r=0, t=40, b=0),
                scene=dict(
                    aspectmode="data",
                    xaxis=dict(title=f"Chemical potential of {cpd.vertex_elements[0]}"),
                    yaxis=dict(title=f"Chemical potential of {cpd.vertex_elements[1]}"),
                    zaxis=dict(title=f"Chemical potential of {cpd.vertex_elements[2]}"),
                ),
                showlegend=False
            )
            fig = go.Figure(data=traces, layout=layout)

        target_formula = getattr(cpd, "target", None) or "N/A"
        vertex_info_lines = []
        if vertex_entries:
            for entry in vertex_entries:
                mu_str = ", ".join(entry["mu_parts"]) if entry["mu_parts"] else "-"
                vertex_info_lines.append(
                    "<li>"
                    f"<b>{entry['label']}</b>"
                    "<div class=\"vertex-meta\">"
                    f"<div><span class=\"k\">μ</span>: {mu_str}</div>"
                    f"<div><span class=\"k\">competing</span>: {entry['competing']}</div>"
                    f"<div><span class=\"k\">impurity</span>: {entry['impurity']}</div>"
                    "</div>"
                    "</li>"
                )
        vertex_html = "<ul>" + "".join(vertex_info_lines) + "</ul>" if vertex_info_lines else "<p>-</p>"

        plot_div = fig.to_html(include_plotlyjs="cdn", full_html=False, config={"responsive": True, "scrollZoom": True})
        is_2d_plot = cpd.dim == 2 or project_indices
        html = f"""<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>CPD</title>
    <style>
      html, body {{ height: 100%; margin: 0; }}
      body {{ display: grid; grid-template-columns: 1fr minmax(280px, 420px); font-family: Arial, sans-serif; }}
      #plot {{ width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center; overflow: hidden; }}
      #plot > div {{ width: 100%; height: 100%; }}
      #info {{
        padding: 16px 18px;
        box-sizing: border-box;
        border-left: 1px solid #ddd;
        background: #fafafa;
        overflow-y: auto;
        z-index: 10;
      }}
      h3 {{ margin: 0 0 8px 0; }}
      .section {{ margin-bottom: 14px; }}
      ul {{ padding-left: 18px; margin: 6px 0; }}
      .vertex-meta {{ margin: 4px 0 8px 0; font-size: 0.95em; }}
      .vertex-meta .k {{ font-weight: 600; }}
      .btn-row {{ display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }}
      .btn-row button {{
        padding: 6px 10px;
        border: 1px solid #bbb;
        background: #fff;
        border-radius: 4px;
        cursor: pointer;
      }}
      .btn-row button:disabled {{
        opacity: 0.5;
        cursor: not-allowed;
      }}
    </style>
  </head>
  <body>
    <div id="plot">{plot_div}</div>
    <div id="info">
      <div class="section"><h3>CPD Info</h3></div>
      <div class="section"><b>(1)</b> Chemical formula: {target_formula}</div>
      <div class="section">
        <b>Rotate (2D only)</b>
        <div class="btn-row">
          <button id="rotate-cw">Rotate 90° CW</button>
          <button id="rotate-ccw">Rotate 90° CCW</button>
        </div>
      </div>
      <div class="section"><b>(2)</b> Vertices A, B, C, D: competing phases / impurity phases
        {vertex_html}
      </div>
    </div>
    <script>
      (function() {{
        const is2d = {str(is_2d_plot).lower()};
        const btnCw = document.getElementById('rotate-cw');
        const btnCcw = document.getElementById('rotate-ccw');
        if (!is2d) {{
          btnCw.disabled = true;
          btnCcw.disabled = true;
          return;
        }}
        const plot = document.querySelector('#plot .plotly-graph-div');
        if (!plot) return;
        let swapped = false;
        const origXT = plot.layout?.xaxis?.title?.text || plot.layout?.xaxis?.title || '';
        const origYT = plot.layout?.yaxis?.title?.text || plot.layout?.yaxis?.title || '';

        function toArray(v) {{
          if (Array.isArray(v)) return v.slice();
          return [v];
        }}

        function computeRange(traces) {{
          let xs = [];
          let ys = [];
          traces.forEach(t => {{
            if (t.x && t.y) {{
              xs = xs.concat(toArray(t.x));
              ys = ys.concat(toArray(t.y));
            }}
          }});
          const xMin = Math.min.apply(null, xs);
          const xMax = Math.max.apply(null, xs);
          const yMin = Math.min.apply(null, ys);
          const yMax = Math.max.apply(null, ys);
          return {{ xMin, xMax, yMin, yMax }};
        }}

        function rotate(dir) {{
          const newData = plot.data.map(t => {{
            if (!t.x || !t.y) return t;
            const x = toArray(t.x);
            const y = toArray(t.y);
            let nx, ny;
            if (dir === 'cw') {{
              nx = y;
              ny = x.map(v => -v);
            }} else {{
              nx = y.map(v => -v);
              ny = x;
            }}
            return {{ ...t, x: nx, y: ny }};
          }});

          const r = computeRange(newData);
          const newLayout = {{
            ...plot.layout,
            xaxis: {{ ...plot.layout.xaxis, range: [r.xMin, r.xMax], scaleanchor: 'y', scaleratio: 1 }},
            yaxis: {{ ...plot.layout.yaxis, range: [r.yMin, r.yMax] }}
          }};

          if (!swapped) {{
            newLayout.xaxis.title = {{ text: origYT }};
            newLayout.yaxis.title = {{ text: origXT }};
          }} else {{
            newLayout.xaxis.title = {{ text: origXT }};
            newLayout.yaxis.title = {{ text: origYT }};
          }}
          swapped = !swapped;
          Plotly.react(plot, newData, newLayout, {{ responsive: true, scrollZoom: true }});
        }}

        btnCw.addEventListener('click', () => rotate('cw'));
        btnCcw.addEventListener('click', () => rotate('ccw'));
      }})();
    </script>
  </body>
</html>
"""
        Path(f"{output_prefix}.html").write_text(html)
        print(f"Generated {output_prefix}.html")

    return True


def maybe_plot_cpd_pdf(cpd_json_path="chem_pot_diag.json"):
    cpd_path = Path(cpd_json_path)
    if not cpd_path.exists():
        print(f"chem_pot_diag.json not found at {cpd_path}. Skipping cpd.pdf.")
        return False

    try:
        cpd = loadfn(cpd_path)
        return plot_cpd_outputs(cpd, output_prefix="cpd", make_pdf=True, make_html=True)
    except Exception as e:
        print(f"Failed to generate cpd.pdf: {e}")
        return False


def find_interstitial_sites(input_path: Path,
                            output_dir: Path = Path("."),
                            min_dist: float = 0.5,
                            insert_species: str = "H"):
    try:
        from pymatgen.analysis.defects.generators import VoronoiInterstitialGenerator
    except ImportError:
        print("Error: pymatgen-analysis-defects package is required for Voronoi interstitial finding.")
        print("Please install it via: pip install pymatgen-analysis-defects")
        return None

    from pydefect.input_maker.local_extrema import VolumetricDataLocalExtrema, CoordInfo, VolumetricDataAnalyzeParams
    from pydefect.util.structure_tools import Distances

    struct = Structure.from_file(str(input_path))
    gen = VoronoiInterstitialGenerator(min_dist=min_dist)
    interstitials = list(gen.generate(struct, insert_species=[insert_species]))

    if not interstitials:
        print("No interstitials found! Try decreasing min_dist.")
        return None

    extrema_points = []
    for i, defect in enumerate(interstitials, 1):
        site = defect.site
        coords = [float(x) for x in site.frac_coords]
        try:
            site_sym = defect.site_symmetry
        except Exception:
            site_sym = "unknown"

        coordination = Distances(struct, coords).coordination()
        coord_info = CoordInfo(
            site_symmetry=site_sym,
            coordination=coordination,
            frac_coords=[coords],
            quantities=[0.0]
        )
        extrema_points.append(coord_info)

        mult = getattr(defect, "multiplicity", None)
        mult_str = f", mult={mult}" if mult is not None else ""
        print(f"{i:>3} | {coords}{mult_str}")

    params = VolumetricDataAnalyzeParams(
        threshold_frac=None,
        threshold_abs=None,
        min_dist=float(min_dist),
        tol=0.0,
        radius=0.0
    )
    local_extrema = VolumetricDataLocalExtrema(
        unit_cell=struct,
        is_min=True,
        extrema_points=extrema_points,
        info=f"Voronoi_interstitials({insert_species})",
        params=params
    )

    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    stem = input_path.name
    if stem.startswith("POSCAR"):
        prefix = stem
    else:
        prefix = input_path.stem
    out_path = output_dir / f"interstitial-site-{prefix}.json"
    local_extrema.to_json_file(str(out_path))
    print(f"Wrote {out_path}")
    return out_path

def stabilize_target(relative_energies_path, target_formula, manual_shift=0.0, buffer=1e-5):
    """
    Checks if the target is above convex hull. If so, shifts its energy in relative_energies.yaml
    to make it stable. Returns the total applied shift value (0.0 if stable and no manual shift).
    """
    if not Path(relative_energies_path).exists():
        print(f"Error: {relative_energies_path} does not exist.")
        return 0.0

    with open(relative_energies_path, 'r') as f:
        rel_energies = yaml.safe_load(f)

    # 1. Apply manual shift first (temporarily to check stability or permanently if stable)
    if target_formula not in rel_energies:
        print(f"Error: Target {target_formula} not found in {relative_energies_path}")
        return 0.0
    
    original_energy = rel_energies[target_formula]
    # We apply the manual shift (subtracted, as per convention: positive shift means lowering energy? 
    # The user said "--energy-shift-target 0.05 이면 target energy 를 0.05 eV 낮춤" -> new = old - 0.05)
    # So if manual_shift is 0.05, we subtract 0.05.
    current_energy_after_manual = original_energy - manual_shift
    rel_energies[target_formula] = current_energy_after_manual
    
    entries = []
    target_entry = None

    # Re-build entries with the manual shift applied
    for formula, energy_per_atom in rel_energies.items():
        try:
            comp = Composition(formula)
            total_energy = energy_per_atom * comp.num_atoms
            entry = PDEntry(comp, total_energy)
            entries.append(entry)
            if formula == target_formula:
                target_entry = entry
        except Exception as e:
            print(f"Warning: Could not process formula {formula}: {e}")

    if target_entry is None:
        return 0.0

    entries = []
    target_entry = None
    target_formula_norm = Composition(target_formula).reduced_formula

    # Re-build entries with the manual shift applied and normalized formulas
    for formula, energy_per_atom in rel_energies.items():
        try:
            comp = Composition(formula)
            norm_f = comp.reduced_formula
            total_energy = energy_per_atom * comp.num_atoms
            # Use normalized formula as the name for consistent matching
            entry = PDEntry(comp, total_energy, name=norm_f)
            entries.append(entry)
            
            if norm_f == target_formula_norm and formula == target_formula:
                target_entry = entry
        except Exception as e:
            print(f"Warning: Could not process formula {formula}: {e}")

    if target_entry is None:
        print(f"Error: Target entry for {target_formula} could not be created.")
        return 0.0

    pd = PhaseDiagram(entries)
    e_above_hull = pd.get_e_above_hull(target_entry)
    
    # --- Generate convex_hull_energy.yaml ---
    try:
        # Use a small threshold to determine stability
        is_stable = e_above_hull <= 1e-8
        
        # 1. Standard stability info (including target in PD)
        if is_stable:
            competing_phases_info = {target_formula: 1.0}
            convex_hull_energy_per_atom = float(target_entry.energy_per_atom)
        else:
            decomp = pd.get_decomposition(target_entry.composition)
            convex_hull_energy_per_atom = target_entry.energy_per_atom - e_above_hull
            competing_phases_info = {entry.name: float(amount) for entry, amount in decomp.items()}

        # 2. Benchmark stability info (excluding target from PD)
        decomposition_info = None
        try:
            entries_excl_target = [e for e in entries if e.composition.reduced_formula != target_formula_norm]
            if entries_excl_target:
                pd_benchmark = PhaseDiagram(entries_excl_target)
                hull_energy_total = pd_benchmark.get_hull_energy(target_entry.composition)
                hull_energy_per_atom = hull_energy_total / target_entry.composition.num_atoms
                e_above_hull_benchmark = target_entry.energy_per_atom - hull_energy_per_atom
                
                comp_phases_bench = {}
                try:
                    decomp_benchmark = pd_benchmark.get_decomposition(target_entry.composition)
                    comp_phases_bench = {entry.name: float(amount) for entry, amount in decomp_benchmark.items()}
                except Exception: pass
                
                decomposition_info = {
                    "hull_distance": float(e_above_hull_benchmark),
                    "competing_phases": comp_phases_bench
                }
        except Exception as e_bench:
            print(f"Warning: Benchmark stability calculation failed: {e_bench}")

        # Construct dictionary in specific order
        convex_hull_data = {
            "target": target_formula,
            "formation_energy_per_atom": float(target_entry.energy_per_atom),
            "convex_hull_energy_per_atom": float(convex_hull_energy_per_atom),
            "e_above_hull": float(e_above_hull)
        }
        if decomposition_info:
            convex_hull_data["decomposition"] = decomposition_info

        # Write to yaml in the same directory as relative_energies.yaml
        output_dir = Path(relative_energies_path).parent
        with open(output_dir / "convex_hull_energy.yaml", "w") as f:
            yaml.dump(convex_hull_data, f, default_flow_style=False)
        print(f"Generated convex_hull_energy.yaml")
        
    except Exception as e:
        print(f"Warning: Failed to generate convex_hull_energy.yaml details: {e} (e_h: {e_above_hull})")
    # ----------------------------------------
    # ----------------------------------------

    extra_stabilization_shift = 0.0

    if e_above_hull > 1e-8:  # Use a small epsilon
        # It's still unstable after manual shift (or manual shift was 0)
        required_shift = -1.0 * e_above_hull
        # Add buffer
        extra_stabilization_shift = required_shift - buffer
        
        print(f"Target {target_formula} is unstable (e_above_hull={e_above_hull:.4f} eV/atom).")
        print(f"Additional stabilization shift required: {extra_stabilization_shift:.6f} eV/atom.")

    # Total shift = manual_shift (already subtracted) + extra_stabilization_shift (negative value to lower energy further)
    # But wait, user said "shift 0.05 means lower by 0.05".
    # My stabilize logic was "return recommended_shift" where recommended_shift was negative.
    # So let's keep consistent: return value is the net change in energy.
    # manual_shift argument is positive -> net change is -manual_shift.
    # extra_stabilization_shift is negative.
    
    total_change = -manual_shift + extra_stabilization_shift
    
    if total_change != 0.0:
        final_energy = float(original_energy + total_change)
        rel_energies[target_formula] = final_energy
        
        with open(relative_energies_path, 'w') as f:
            yaml.dump(rel_energies, f)
            
        print(f"Total applied energy shift: {total_change:.6f} eV/atom (Manual: -{manual_shift}, Stabilization: {extra_stabilization_shift:.6f}).")
        return total_change

    return 0.0

def write_summary_at_vertices(summary_path: Path, applied_shift=0.0):
    """
    Calculates and prints defect formation energies for each chemical potential
    vertex from a defect_energy_summary.json file.
    """
    if not summary_path.exists():
        print(f"Error: {summary_path} not found.")
        return

    try:
        # loadfn will deserialize into DefectEnergySummary object
        summary = loadfn(summary_path)
    except Exception as e:
        print(f"Error loading {summary_path}: {e}")
        return

    rel_chem_pots = summary.rel_chem_pots
    defect_energies_summary = summary.defect_energies
    output_filename = "defect_energy_summary-at-vertices.txt"

    with open(output_filename, "w") as f:
        f.write(f"title: {summary.title}\n")
        if applied_shift != 0.0:
            f.write(f"# Energy shift applied to make target stable: {applied_shift} eV/atom\n")
        f.write("\n")

        f.write("--- Standard Formation Energies (Chemical Potential = 0) ---\n")
        f.write(f"{ 'Defect Name':<20} {'Charge':<10} {'Formation Energy (eV)'}\n")
        f.write(f"{'-'*20} {'-'*10} {'-'*25}\n")
        
        for defect_name, defect_group in defect_energies_summary.items():
             if 0 in defect_group.charges:
                 idx = defect_group.charges.index(0)
                 energy = defect_group.defect_energies[idx].formation_energy
                 f.write(f"{defect_name:<20} {'0':<10} {energy:>25.4f}\n")
        f.write("\n")

        for vertex_label, chem_pots in rel_chem_pots.items():
            f.write(f"--- Vertex: {vertex_label} ---\n")
            chem_pot_str = ", ".join([f"{elem}: {pot:.4f}" for elem, pot in chem_pots.items()])
            f.write(f"Relative chemical potentials: {chem_pot_str}\n")
            f.write("-" * 40 + "\n")
            f.write(f"{ 'Defect Name':<20} {'Formation Energy (eV)'}\n")
            f.write(f"{'-'*20} {'-'*25}\n")

            formation_energies = {}
            for defect_name, defect_group in defect_energies_summary.items():
                charge_0_defect_energy = None
                try:
                    if 0 in defect_group.charges:
                        charge_0_index = defect_group.charges.index(0)
                        charge_0_defect_energy = defect_group.defect_energies[charge_0_index]
                except (ValueError, AttributeError):
                    pass

                if not charge_0_defect_energy:
                    continue

                atom_io = defect_group.atom_io
                base_energy = charge_0_defect_energy.formation_energy
                
                reservoir_contribution = 0.0
                for element, num_diff in atom_io.items():
                    if element in chem_pots:
                        reservoir_contribution -= num_diff * chem_pots[element]

                formation_energy = base_energy + reservoir_contribution
                formation_energies[defect_name] = formation_energy
            
            sorted_defects = sorted(formation_energies.items(), key=lambda item: item[0])
            for name, energy in sorted_defects:
                f.write(f"{name:<20} {energy:>25.4f}\n")
            
            f.write("\n")

    print(f"Output written to {output_filename}")


def _postprocess_defect_artifacts(d: Path, supercell_info):
    if not supercell_info:
        return
    try:
        if Path(d / "CONTCAR").exists() and Path(d / "defect_entry.json").exists():
            final_structure = Structure.from_file(d / "CONTCAR")
            defect_entry = loadfn(d / "defect_entry.json")
            dsi_maker = MakeDefectStructureInfo(
                perfect=supercell_info.structure,
                initial=defect_entry.structure,
                final=final_structure,
                symprec=defaults.symmetry_length_tolerance,
                dist_tol=defaults.dist_tol
            )
            dsi_maker.defect_structure_info.to_json_file(d / "defect_structure_info.json")
            print(f"Generated defect_structure_info.json for {d.name}")
            try:
                dsi_loaded = loadfn(d / "defect_structure_info.json")
                vesta_maker = MakeDefectVestaFile(dsi_loaded)
                vesta_maker.initial_vesta.write_file(d / "defect_initial.vesta")
                vesta_maker.final_vesta.write_file(d / "defect.vesta")
                print(f"Generated VESTA files for {d.name}")
            except Exception as e_vesta:
                print(f"Warning: Failed to generate VESTA files: {e_vesta}")
    except Exception as e:
        print(f"Warning: Failed to generate defect_structure_info.json for {d.name}: {e}")


def _relax_single_dir_worker(params):
    d, ff, model_path, device, fmax, isif, modal, verbose = params
    d = Path(d)
    try:
        calc_kwargs = {"model_path": model_path, "device": device, "modal": modal}
        if ff == "mace":
            calc_kwargs["model_paths"] = [calc_kwargs["model_path"]]
            del calc_kwargs["model_path"]
        calculator = get_calculator(ff_name=ff, **calc_kwargs)

        cwd = Path.cwd()
        os.chdir(d)
        try:
            if verbose:
                print(f"Relaxing in {d.name}...")
            with Logger("relax-POSCAR_log.txt") as lg:
                original_stdout = sys.stdout
                try:
                    sys.stdout = lg
                    write_pydefect_dummy_files(".")
                    relax_structure(
                        input_file="POSCAR",
                        fmax=fmax,
                        isif=isif,
                        device=device,
                        calculator=calculator,
                        ff=ff,
                        outcar_name="OUTCAR",
                        contcar_name="CONTCAR",
                        xml_name="vasprun.xml",
                        make_pdf=True,
                        write_json=True,
                        modal=modal
                    )
                finally:
                    sys.stdout = original_stdout
        finally:
            os.chdir(cwd)

        ok = (d / "vasprun.xml").exists() or (d / "OUTCAR").exists()
        return str(d), ok, None
    except Exception as e:
        return str(d), False, str(e)


def run_macer_relax(target_dirs, isif=3, supercell_info=None, ff=None, model_path=None, device=None, fmax=0.03, verbose=False, modal=None, n_jobs=1):
    """
    Unified relaxation function for CPD and Defect workflows.
    Iterates over target_dirs and runs macer relaxation.
    """
    if not target_dirs:
        return []

    # Determine FF and Model if not provided
    if ff is None:
        available_ffs = get_available_ffs()
        if not available_ffs:
            print("Error: No MLFF packages installed.")
            return []
        ff = available_ffs[0]
    
    if device is None:
        device = DEFAULT_DEVICE
    
    if model_path is None:
        default_model_name = DEFAULT_MODELS.get(ff)
        if default_model_name:
            FFS_USING_MODEL_NAME = {"fairchem", "orb", "chgnet", "m3gnet"}
            if ff in FFS_USING_MODEL_NAME:
                model_path = default_model_name
            else:
                model_path = resolve_model_path(default_model_name)
    else:
        model_path = resolve_model_path(model_path)
    
    if n_jobs < 1:
        raise ValueError("Error: --n-jobs must be >= 1.")

    print(f"Initializing {ff.upper()} calculator on {device}...")
    successful_dirs = []
    use_parallel = n_jobs > 1 and device == "cpu"
    if n_jobs > 1 and device != "cpu":
        print(f"WARNING: --n-jobs>1 is supported only with --device cpu (current: {device}). Falling back to serial.")

    if use_parallel:
        print(f"Using parallel relaxation with {n_jobs} workers.")
        params = [
            (str(Path(d).resolve()), ff, model_path, device, fmax, isif, modal, verbose)
            for d in target_dirs
        ]
        with ProcessPoolExecutor(max_workers=n_jobs) as pool:
            for d_str, ok, err in pool.map(_relax_single_dir_worker, params):
                d = Path(d_str)
                if ok:
                    successful_dirs.append(d)
                    _postprocess_defect_artifacts(d, supercell_info)
                else:
                    print(f"Relaxation failed in {d.name}: {err}")
        return successful_dirs

    calc_kwargs = {"model_path": model_path, "device": device, "modal": modal}
    if ff == "mace":
        calc_kwargs["model_paths"] = [calc_kwargs["model_path"]]
        del calc_kwargs["model_path"]

    try:
        calculator = get_calculator(ff_name=ff, **calc_kwargs)
        actual_path = None
        if hasattr(calculator, "model_path"):
            actual_path = calculator.model_path
        elif hasattr(calculator, "model_paths") and calculator.model_paths:
            actual_path = calculator.model_paths[0]
        if actual_path:
            print(f"  MLFF Model: {actual_path}")
        print("Calculator initialized.")
    except Exception as e:
        print(f"Failed to initialize calculator: {e}")
        return []

    cwd = Path.cwd()
    original_stdout = sys.stdout
    for d in target_dirs:
        if verbose:
            print(f"Relaxing in {d.name}...")
        os.chdir(d)
        try:
            with Logger("relax-POSCAR_log.txt") as lg:
                sys.stdout = lg
                write_pydefect_dummy_files(".")
                relax_structure(
                    input_file="POSCAR",
                    fmax=fmax,
                    isif=isif,
                    device=device,
                    calculator=calculator,
                    ff=ff,
                    outcar_name="OUTCAR",
                    contcar_name="CONTCAR",
                    xml_name="vasprun.xml",
                    make_pdf=True,
                    write_json=True,
                    modal=modal
                )
            if Path("vasprun.xml").exists() or Path("OUTCAR").exists():
                successful_dirs.append(d)
                _postprocess_defect_artifacts(d, supercell_info)
        except Exception as e:
            sys.stdout = original_stdout
            print(f"Relaxation failed in {d.name}: {e}")
        finally:
            sys.stdout = original_stdout
            os.chdir(cwd)

    return successful_dirs

def run_voronoi_interstitial_finding(supercell_info_path: Path):
    """
    Finds interstitial sites using Voronoi tessellation via pymatgen-analysis-defects,
    and appends unique symmetry-independent sites to supercell_info.json.
    """
    if not supercell_info_path.exists():
        print(f"Error: {supercell_info_path} not found.")
        return

    print(f"Loading {supercell_info_path} for interstitial finding...")
    
    try:
        from pymatgen.analysis.defects.generators import VoronoiInterstitialGenerator
    except ImportError:
        print("Error: pymatgen-analysis-defects package is required for Voronoi interstitial finding.")
        print("Please install it via: pip install pymatgen-analysis-defects")
        return

    try:
        from pydefect.input_maker.append_interstitial import append_interstitial
    except ImportError as e:
        print(f"Required package not found: {e}")
        return

    try:
        supercell_info = loadfn(supercell_info_path)
        unitcell = supercell_info.unitcell_structure
        # Create structure from unitcell dict
        struct = Structure.from_dict(unitcell.as_dict())

        print(f"Unitcell loaded: {struct.composition.reduced_formula}")
        
        # Parameters
        min_dist = 0.5
        insert_species = "H"
        
        print(f"Generating Voronoi interstitial candidates (min_dist={min_dist}, species={insert_species})...")
        
        gen = VoronoiInterstitialGenerator(min_dist=min_dist)
        # generate() returns a generator, convert to list
        interstitials = list(gen.generate(struct, insert_species=[insert_species]))
        
        if not interstitials:
            print("No interstitials found! Try decreasing min_dist.")
            return

        print(f"Found {len(interstitials)} distinct interstitial sites.")

        coords_list = []
        infos = []

        print("\n--- Sites to Add ---")
        header = f"{ 'Idx':<4} | {'Fractional Coords':<30} | {'Mult':<4}"
        print(header)
        print("-" * len(header))

        for i, defect in enumerate(interstitials):
            site = defect.site
            coords = site.frac_coords.tolist()
            mult = defect.multiplicity
            
            info_str = f"Voronoi_site_{i}_mult_{mult}"
            
            print(f"{i:<4} | {str(coords):<30} | {mult:<4}")
            
            coords_list.append(coords)
            infos.append(info_str)

        print("-" * len(header))

        print("\nAppending interstitials to SupercellInfo...")
        new_supercell_info = append_interstitial(
            supercell_info=supercell_info,
            unitcell_structure=unitcell,
            frac_coords=coords_list,
            infos=infos
        )
        
        new_supercell_info.to_json_file(supercell_info_path)
        print(f"Successfully updated {supercell_info_path} with {len(interstitials)} interstitial types.")

    except Exception as e:
        print(f"Error during interstitial finding: {e}")
        import traceback
        traceback.print_exc()

def write_simple_defect_summary(root_dir: Path):
    """
    Generates a simplified defect energy summary from individual defect_energy_info.json files.
    Used when target_vertices.yaml is not available.
    """
    root_dir = Path(root_dir)
    defect_energies = []

    print(f"Scanning for defect_energy_info.yaml files in {root_dir.absolute()}...")

    for d in root_dir.iterdir():
        if d.is_dir():
            info_path = d / "defect_energy_info.yaml"
            if info_path.exists():
                try:
                    info = loadfn(info_path)
                    # When loaded, it can be a dict-like object (CommentedMap)
                    name = info['name']
                    charge = info['charge']
                    formation_energy = info['formation_energy']
                    defect_energies.append((name, charge, formation_energy))
                except Exception as e:
                    print(f"Warning: Failed to load or parse {info_path}: {e}")

    if not defect_energies:
        print("No defect energy info found.")
        return

    # Sort by name
    defect_energies.sort(key=lambda x: x[0])

    output_filename = "defect_energy_summary-at-vertices.txt"
    with open(output_filename, "w") as f:
        f.write("title: Simplified Defect Summary\n\n")
        f.write("--- Standard Formation Energies (Chemical Potential = 0) ---\n")
        f.write(f"{ 'Defect Name':<20} {'Charge':<10} {'Formation Energy (eV)'}\n")
        f.write(f"{'-'*20} {'-'*10} {'-'*25}\n")
        
        for name, charge, energy in defect_energies:
             f.write(f"{name:<20} {charge:<10} {energy:>25.4f}\n")
    
    print(f"Output written to {output_filename}")
