#!/usr/bin/env python3
"""
Audita proyectos en /var/www/html y genera inventario en TSV, CSV y XLSX.

No requiere paquetes externos. El XLSX se crea con la biblioteca estándar
(zipfile + XML), por lo que funciona en Python 3.10 sin instalar openpyxl.

Ejemplos:
    python3 auditar.py
    python3 auditar.py --timestamp
    python3 auditar.py --print-tsv
    python3 auditar.py --output-dir /tmp/auditoria_demos
"""

from __future__ import annotations

import argparse
import csv
import getpass
import os
import re
import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from xml.sax.saxutils import escape


HEADERS = [
    "CLIENTE",
    "Nombre Proyecto",
    "URL BO",
    "STATUS BackOffice",
    "URL POS",
    "STATUS POSTouch",
    "RAMA",
    "OBSERVACIONES",
]

# Carpetas que deben aparecer aunque no comiencen con "demo" y todavía no
# tengan mbinv ni POSTouch.
EXTRA_PROJECTS: dict[str, str] = {
    # "proyecto_christian": "Proyecto Christian",
}

# Carpetas que no son demos. Si alguna contiene mbinv o POSTouch, se incluye
# de todos modos porque la aplicación real tiene prioridad sobre esta lista.
EXCLUDED_PROJECTS = {
    "back",
    "backup",
    "estructura",
    "instaladores",
    "manuales",
    "nuevosequipos",
}

ENTRYPOINTS = (
    "web/app_dev.php",
    "web/app.php",
    "public/index.php",
)


def find_child_case_insensitive(parent: Path, wanted_name: str) -> Optional[Path]:
    try:
        for child in parent.iterdir():
            if child.is_dir() and child.name.casefold() == wanted_name.casefold():
                return child
    except (PermissionError, OSError):
        return None
    return None


def detect_entrypoint(app_path: Optional[Path]) -> Optional[str]:
    if app_path is None:
        return None
    for relative_path in ENTRYPOINTS:
        if (app_path / relative_path).is_file():
            return relative_path
    return None


def application_status(app_path: Optional[Path]) -> str:
    if app_path is None:
        return "No Creado"
    if detect_entrypoint(app_path) is None:
        return "Incompleto"
    return "Creado"


def strip_yaml_scalar(raw_value: str) -> Optional[str]:
    value = raw_value.strip()
    if not value:
        return None

    if not (
        (value.startswith("'") and value.endswith("'"))
        or (value.startswith('"') and value.endswith('"'))
    ):
        value = value.split(" #", 1)[0].strip()

    if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
        value = value[1:-1]

    if value.strip().lower() in {"", "null", "~", "none"}:
        return None
    return value.strip()


def read_database_name(app_path: Optional[Path]) -> tuple[Optional[str], str]:
    """Lee database_name y usa database_central como respaldo."""
    if app_path is None:
        return None, "Aplicación no existe"

    parameters_path = app_path / "app/config/parameters.yml"
    if not parameters_path.is_file():
        return None, "Sin parameters.yml"

    try:
        content = parameters_path.read_text(encoding="utf-8", errors="replace")
    except PermissionError:
        return None, "Sin permiso para leer parameters.yml"
    except OSError as exc:
        return None, f"Error leyendo parameters.yml: {exc}"

    values: dict[str, Optional[str]] = {
        "database_name": None,
        "database_central": None,
    }

    pattern = re.compile(
        r"^\s*(database_name|database_central)\s*:\s*(.*?)\s*$",
        re.MULTILINE,
    )

    for match in pattern.finditer(content):
        values[match.group(1)] = strip_yaml_scalar(match.group(2))

    if values["database_name"]:
        return values["database_name"], ""
    if values["database_central"]:
        return values["database_central"], "Usó database_central"
    return None, "database_name no definido"


def resolve_git_dir(repo_path: Path) -> Optional[Path]:
    dot_git = repo_path / ".git"

    if dot_git.is_dir():
        return dot_git

    if dot_git.is_file():
        try:
            first_line = dot_git.read_text(
                encoding="utf-8", errors="replace"
            ).strip()
        except OSError:
            return None

        prefix = "gitdir:"
        if first_line.lower().startswith(prefix):
            raw_path = first_line[len(prefix):].strip()
            git_dir = Path(raw_path)
            if not git_dir.is_absolute():
                git_dir = (repo_path / git_dir).resolve()
            return git_dir

    return None


def read_git_branch(repo_path: Optional[Path]) -> Optional[str]:
    if repo_path is None:
        return None

    git_dir = resolve_git_dir(repo_path)
    if git_dir is None:
        return None

    try:
        head = (git_dir / "HEAD").read_text(
            encoding="utf-8", errors="replace"
        ).strip()
    except OSError:
        return None

    prefix = "ref: refs/heads/"
    if head.startswith(prefix):
        return head[len(prefix):]
    if head:
        return f"detached:{head[:8]}"
    return None


def branch_for_app(app_path: Optional[Path], project_path: Path) -> Optional[str]:
    return read_git_branch(app_path) or read_git_branch(project_path)


def combined_branch(
    bo_path: Optional[Path],
    pos_path: Optional[Path],
    project_path: Path,
) -> tuple[str, Optional[str]]:
    bo_branch = branch_for_app(bo_path, project_path) if bo_path else None
    pos_branch = branch_for_app(pos_path, project_path) if pos_path else None

    if bo_branch and pos_branch:
        if bo_branch == pos_branch:
            return bo_branch, None
        return (
            f"BO:{bo_branch} | POS:{pos_branch}",
            "BackOffice y POSTouch están en ramas distintas",
        )
    if bo_branch:
        return bo_branch, None
    if pos_branch:
        return pos_branch, None
    return "Sin Git", "No se detectó repositorio Git"


def build_url(
    host: str,
    project_name: str,
    app_path: Optional[Path],
    app_kind: str,
) -> str:
    if app_path is None:
        return ""

    entrypoint = detect_entrypoint(app_path)
    if entrypoint is None:
        return ""

    base = f"http://{host}/{project_name}/{app_path.name}"
    if entrypoint == "public/index.php":
        return f"{base}/public/"

    suffix = "/login" if app_kind == "bo" else "/"
    return f"{base}/{entrypoint}{suffix}"


def should_include_project(
    project_path: Path,
    bo_path: Optional[Path],
    pos_path: Optional[Path],
) -> bool:
    project_name = project_path.name

    if bo_path is not None or pos_path is not None:
        return True
    if project_name in EXTRA_PROJECTS:
        return True
    if project_name.casefold() in EXCLUDED_PROJECTS:
        return False
    return project_name.casefold().startswith("demo")


def combine_client_database(
    bo_database: Optional[str],
    pos_database: Optional[str],
) -> tuple[str, Optional[str]]:
    if bo_database and pos_database:
        if bo_database == pos_database:
            return bo_database, None
        return (
            f"BO:{bo_database} | POS:{pos_database}",
            "BackOffice y POSTouch apuntan a bases de datos distintas",
        )
    if bo_database:
        return bo_database, None
    if pos_database:
        return pos_database, None
    return "No detectado", "No se detectó database_name"


def scan_projects(base_dir: Path, host: str) -> list[list[str]]:
    rows: list[list[str]] = []

    try:
        project_paths = sorted(
            (path for path in base_dir.iterdir() if path.is_dir()),
            key=lambda path: path.name.casefold(),
        )
    except PermissionError as exc:
        raise RuntimeError(f"No se puede leer {base_dir}: {exc}") from exc

    for project_path in project_paths:
        project_name = project_path.name
        bo_path = find_child_case_insensitive(project_path, "mbinv")
        pos_path = find_child_case_insensitive(project_path, "POSTouch")

        if not should_include_project(project_path, bo_path, pos_path):
            continue

        observations: list[str] = []
        bo_status = application_status(bo_path)
        pos_status = application_status(pos_path)

        if bo_status == "Incompleto":
            observations.append(
                "mbinv existe pero no tiene web/app_dev.php, "
                "web/app.php ni public/index.php"
            )
        if pos_status == "Incompleto":
            observations.append(
                "POSTouch existe pero no tiene web/app_dev.php, "
                "web/app.php ni public/index.php"
            )

        bo_database, bo_database_note = read_database_name(bo_path)
        pos_database, pos_database_note = read_database_name(pos_path)

        if bo_path is not None and bo_database_note:
            observations.append(f"BO: {bo_database_note}")
        if pos_path is not None and pos_database_note:
            observations.append(f"POS: {pos_database_note}")

        client_database, database_note = combine_client_database(
            bo_database, pos_database
        )
        if database_note:
            observations.append(database_note)

        branch, branch_note = combined_branch(bo_path, pos_path, project_path)
        if branch_note:
            observations.append(branch_note)

        observations = list(dict.fromkeys(observations))

        rows.append(
            [
                client_database,
                project_name,
                build_url(host, project_name, bo_path, "bo"),
                bo_status,
                build_url(host, project_name, pos_path, "pos"),
                pos_status,
                branch,
                "; ".join(observations),
            ]
        )

    return rows


def is_directory_writable(path: Path) -> bool:
    try:
        path.mkdir(parents=True, exist_ok=True)
        test_path = path / f".write_test_{os.getpid()}"
        test_path.write_text("ok", encoding="utf-8")
        test_path.unlink()
        return True
    except OSError:
        return False


def choose_output_dir(requested: Optional[str]) -> Path:
    if requested:
        requested_path = Path(requested).expanduser().resolve()
        if not is_directory_writable(requested_path):
            raise RuntimeError(
                f"No se puede escribir en el directorio solicitado: "
                f"{requested_path}"
            )
        return requested_path

    candidates = [
        Path.home() / "auditoria_demos",
        Path("/tmp") / f"auditoria_demos_{getpass.getuser()}",
    ]

    for candidate in candidates:
        if is_directory_writable(candidate):
            return candidate

    raise RuntimeError(
        "No se encontró un directorio con permisos de escritura. "
        "Usa --output-dir con una ruta escribible."
    )


def write_delimited(
    path: Path,
    rows: list[list[str]],
    delimiter: str,
    encoding: str,
) -> None:
    with path.open("w", newline="", encoding=encoding) as file_handle:
        writer = csv.writer(
            file_handle,
            delimiter=delimiter,
            quoting=csv.QUOTE_MINIMAL,
            lineterminator="\n",
        )
        writer.writerow(HEADERS)
        writer.writerows(rows)


def excel_column_name(index: int) -> str:
    """Convierte 1 -> A, 26 -> Z, 27 -> AA."""
    result = ""
    while index:
        index, remainder = divmod(index - 1, 26)
        result = chr(65 + remainder) + result
    return result


def xml_text(value: object) -> str:
    text = "" if value is None else str(value)
    return escape(text, {"\"": "&quot;", "'": "&apos;"})


def cell_style(column_index: int, value: str, row_index: int) -> int:
    # 0 normal; 1 encabezado; 2 hipervínculo; 3 verde; 4 rojo; 5 amarillo.
    if row_index == 1:
        return 1
    if column_index in {3, 5} and value:
        return 2
    if column_index in {4, 6}:
        if value == "Creado":
            return 3
        if value == "No Creado":
            return 4
        if value == "Incompleto":
            return 5
    return 0


def calculate_column_widths(all_rows: list[list[str]]) -> list[float]:
    minimums = [16, 22, 34, 19, 34, 17, 22, 38]
    maximums = [38, 34, 58, 22, 58, 22, 38, 70]
    widths: list[float] = []

    for col_index in range(len(HEADERS)):
        longest = max(
            len(str(row[col_index])) if col_index < len(row) else 0
            for row in all_rows
        )
        width = max(minimums[col_index], min(maximums[col_index], longest + 2))
        widths.append(float(width))

    return widths


def build_sheet_xml(rows: list[list[str]]) -> tuple[str, str]:
    all_rows = [HEADERS] + rows
    last_row = len(all_rows)
    last_col = excel_column_name(len(HEADERS))
    widths = calculate_column_widths(all_rows)

    hyperlinks: list[tuple[str, str, str]] = []
    relationship_lines: list[str] = []
    row_xml: list[str] = []
    rel_counter = 1

    for row_index, row in enumerate(all_rows, start=1):
        cells: list[str] = []

        for col_index, raw_value in enumerate(row, start=1):
            value = "" if raw_value is None else str(raw_value)
            cell_ref = f"{excel_column_name(col_index)}{row_index}"
            style_id = cell_style(col_index, value, row_index)
            preserve = ' xml:space="preserve"' if value != value.strip() else ""

            cells.append(
                f'<c r="{cell_ref}" s="{style_id}" t="inlineStr">'
                f'<is><t{preserve}>{xml_text(value)}</t></is></c>'
            )

            if row_index > 1 and col_index in {3, 5} and value:
                rel_id = f"rId{rel_counter}"
                hyperlinks.append((cell_ref, rel_id, value))
                relationship_lines.append(
                    '<Relationship '
                    f'Id="{rel_id}" '
                    'Type="http://schemas.openxmlformats.org/officeDocument/'
                    '2006/relationships/hyperlink" '
                    f'Target="{xml_text(value)}" TargetMode="External"/>'
                )
                rel_counter += 1

        row_height = 25 if row_index == 1 else 20
        row_xml.append(
            f'<row r="{row_index}" ht="{row_height}" customHeight="1">'
            + "".join(cells)
            + "</row>"
        )

    cols_xml = "".join(
        f'<col min="{index}" max="{index}" width="{width}" '
        'customWidth="1"/>'
        for index, width in enumerate(widths, start=1)
    )

    hyperlinks_xml = ""
    if hyperlinks:
        hyperlinks_xml = "<hyperlinks>" + "".join(
            f'<hyperlink ref="{cell_ref}" r:id="{rel_id}"/>'
            for cell_ref, rel_id, _ in hyperlinks
        ) + "</hyperlinks>"

    sheet_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
  <dimension ref="A1:{last_col}{last_row}"/>
  <sheetViews>
    <sheetView workbookViewId="0">
      <pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>
      <selection pane="bottomLeft" activeCell="A2" sqref="A2"/>
    </sheetView>
  </sheetViews>
  <sheetFormatPr defaultRowHeight="15"/>
  <cols>{cols_xml}</cols>
  <sheetData>{''.join(row_xml)}</sheetData>
  <autoFilter ref="A1:{last_col}{last_row}"/>
  {hyperlinks_xml}
  <pageMargins left="0.25" right="0.25" top="0.5" bottom="0.5" header="0.2" footer="0.2"/>
  <pageSetup orientation="landscape" fitToWidth="1" fitToHeight="0"/>
</worksheet>'''

    rels_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  {''.join(relationship_lines)}
</Relationships>'''

    return sheet_xml, rels_xml


def write_xlsx(path: Path, rows: list[list[str]]) -> None:
    """Crea un XLSX real sin dependencias externas."""
    sheet_xml, sheet_rels_xml = build_sheet_xml(rows)
    now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")

    content_types = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
  <Default Extension="xml" ContentType="application/xml"/>
  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
  <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
  <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
  <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>'''

    root_rels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
  <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>'''

    workbook_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
  <bookViews><workbookView xWindow="0" yWindow="0" windowWidth="24000" windowHeight="12000"/></bookViews>
  <sheets><sheet name="Control Demos" sheetId="1" r:id="rId1"/></sheets>
  <calcPr calcId="191029"/>
</workbook>'''

    workbook_rels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>'''

    styles_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
  <fonts count="3">
    <font><sz val="10"/><name val="Arial"/><family val="2"/></font>
    <font><b/><color rgb="FFFFFFFF"/><sz val="10"/><name val="Arial"/><family val="2"/></font>
    <font><u/><color rgb="FF1155CC"/><sz val="10"/><name val="Arial"/><family val="2"/></font>
  </fonts>
  <fills count="6">
    <fill><patternFill patternType="none"/></fill>
    <fill><patternFill patternType="gray125"/></fill>
    <fill><patternFill patternType="solid"><fgColor rgb="FF0B5D55"/><bgColor indexed="64"/></patternFill></fill>
    <fill><patternFill patternType="solid"><fgColor rgb="FFD9EAD3"/><bgColor indexed="64"/></patternFill></fill>
    <fill><patternFill patternType="solid"><fgColor rgb="FFF4CCCC"/><bgColor indexed="64"/></patternFill></fill>
    <fill><patternFill patternType="solid"><fgColor rgb="FFFFF2CC"/><bgColor indexed="64"/></patternFill></fill>
  </fills>
  <borders count="2">
    <border><left/><right/><top/><bottom/><diagonal/></border>
    <border>
      <left style="thin"><color rgb="FFD9E2E3"/></left>
      <right style="thin"><color rgb="FFD9E2E3"/></right>
      <top style="thin"><color rgb="FFD9E2E3"/></top>
      <bottom style="thin"><color rgb="FFD9E2E3"/></bottom>
      <diagonal/>
    </border>
  </borders>
  <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
  <cellXfs count="6">
    <xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyBorder="1" applyAlignment="1"><alignment vertical="center" wrapText="1"/></xf>
    <xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf>
    <xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment vertical="center" wrapText="1"/></xf>
    <xf numFmtId="0" fontId="0" fillId="3" borderId="1" xfId="0" applyFill="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center"/></xf>
    <xf numFmtId="0" fontId="0" fillId="4" borderId="1" xfId="0" applyFill="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center"/></xf>
    <xf numFmtId="0" fontId="0" fillId="5" borderId="1" xfId="0" applyFill="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center"/></xf>
  </cellXfs>
  <cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
  <dxfs count="0"/>
  <tableStyles count="0" defaultTableStyle="TableStyleMedium2" defaultPivotStyle="PivotStyleLight16"/>
</styleSheet>'''

    core_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
 xmlns:dc="http://purl.org/dc/elements/1.1/"
 xmlns:dcterms="http://purl.org/dc/terms/"
 xmlns:dcmitype="http://purl.org/dc/dcmitype/"
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <dc:creator>Auditor de demos</dc:creator>
  <cp:lastModifiedBy>Auditor de demos</cp:lastModifiedBy>
  <dcterms:created xsi:type="dcterms:W3CDTF">{now}</dcterms:created>
  <dcterms:modified xsi:type="dcterms:W3CDTF">{now}</dcterms:modified>
  <dc:title>Control de demos</dc:title>
</cp:coreProperties>'''

    app_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
 xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
  <Application>Python</Application>
  <DocSecurity>0</DocSecurity>
  <ScaleCrop>false</ScaleCrop>
  <HeadingPairs>
    <vt:vector size="2" baseType="variant">
      <vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant>
      <vt:variant><vt:i4>1</vt:i4></vt:variant>
    </vt:vector>
  </HeadingPairs>
  <TitlesOfParts><vt:vector size="1" baseType="lpstr"><vt:lpstr>Control Demos</vt:lpstr></vt:vector></TitlesOfParts>
  <Company></Company>
  <LinksUpToDate>false</LinksUpToDate>
  <SharedDoc>false</SharedDoc>
  <HyperlinksChanged>false</HyperlinksChanged>
  <AppVersion>1.0</AppVersion>
</Properties>'''

    path.parent.mkdir(parents=True, exist_ok=True)

    with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        archive.writestr("[Content_Types].xml", content_types)
        archive.writestr("_rels/.rels", root_rels)
        archive.writestr("docProps/core.xml", core_xml)
        archive.writestr("docProps/app.xml", app_xml)
        archive.writestr("xl/workbook.xml", workbook_xml)
        archive.writestr("xl/_rels/workbook.xml.rels", workbook_rels)
        archive.writestr("xl/styles.xml", styles_xml)
        archive.writestr("xl/worksheets/sheet1.xml", sheet_xml)
        archive.writestr("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels_xml)


def print_tsv(rows: list[list[str]]) -> None:
    writer = csv.writer(
        sys.stdout,
        delimiter="\t",
        quoting=csv.QUOTE_MINIMAL,
        lineterminator="\n",
    )
    writer.writerow(HEADERS)
    writer.writerows(rows)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Audita demos, database_name, BackOffice, POSTouch y ramas Git."
        )
    )
    parser.add_argument(
        "--base-dir",
        default="/var/www/html",
        help="Directorio que contiene los proyectos.",
    )
    parser.add_argument(
        "--host",
        default="172.10.10.13",
        help="IP o host usado para construir las URL.",
    )
    parser.add_argument(
        "--output-dir",
        help="Directorio de salida. Predeterminado: ~/auditoria_demos",
    )
    parser.add_argument(
        "--prefix",
        default="inventario_demos",
        help="Nombre base de los archivos generados.",
    )
    parser.add_argument(
        "--timestamp",
        action="store_true",
        help="Agrega fecha y hora al nombre de los archivos.",
    )
    parser.add_argument(
        "--print-tsv",
        action="store_true",
        help="También imprime el TSV completo en la terminal.",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    base_dir = Path(args.base_dir).expanduser().resolve()

    if not base_dir.is_dir():
        print(f"ERROR: no existe el directorio: {base_dir}", file=sys.stderr)
        return 1

    try:
        rows = scan_projects(base_dir, args.host)
        output_dir = choose_output_dir(args.output_dir)
    except RuntimeError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1

    suffix = datetime.now().strftime("_%Y%m%d_%H%M%S") if args.timestamp else ""
    basename = f"{args.prefix}{suffix}"

    tsv_path = output_dir / f"{basename}.tsv"
    csv_path = output_dir / f"{basename}.csv"
    xlsx_path = output_dir / f"{basename}.xlsx"

    try:
        write_delimited(tsv_path, rows, "\t", "utf-8")
        write_delimited(csv_path, rows, ",", "utf-8-sig")
        write_xlsx(xlsx_path, rows)
    except PermissionError as exc:
        print(f"ERROR: no se pudieron guardar los archivos: {exc}", file=sys.stderr)
        return 1
    except (OSError, zipfile.BadZipFile) as exc:
        print(f"ERROR guardando los archivos: {exc}", file=sys.stderr)
        return 1

    if args.print_tsv:
        print_tsv(rows)

    bo_created = sum(row[3] == "Creado" for row in rows)
    bo_incomplete = sum(row[3] == "Incompleto" for row in rows)
    pos_created = sum(row[5] == "Creado" for row in rows)
    pos_incomplete = sum(row[5] == "Incompleto" for row in rows)
    undetected_database = sum(row[0] == "No detectado" for row in rows)
    with_observations = sum(bool(row[7]) for row in rows)

    print()
    print("AUDITORÍA COMPLETADA")
    print(f"Proyectos encontrados : {len(rows)}")
    print(f"BackOffice creados     : {bo_created}")
    print(f"BackOffice incompletos : {bo_incomplete}")
    print(f"POSTouch creados       : {pos_created}")
    print(f"POSTouch incompletos   : {pos_incomplete}")
    print(f"Cliente no detectado   : {undetected_database}")
    print(f"Con observaciones      : {with_observations}")
    print()
    print(f"TSV para Google Sheets : {tsv_path}")
    print(f"CSV para Excel         : {csv_path}")
    print(f"XLSX listo para usar   : {xlsx_path}")
    print()
    print("Para verificar los archivos:")
    print(f"  ls -lh '{output_dir}'")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
