#!/usr/bin/env python3
"""Publish the reuse-map page to Confluence Server / Data Center, creating or updating it.

The page is located through the database-backed `content?spaceKey=&title=`
lookup rather than CQL, because the CQL index lags and would let a second copy
be created. An existing page is updated in place with the next version number;
otherwise a new one is created under the parent. Attachments are then added
or replaced. curl carries every request, which keeps the system trust store in
play, and the Bearer token sits in a curl config file with mode 600 instead of
on the command line.
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
import urllib.parse
from xml.sax.saxutils import escape, quoteattr

from _shared import add_token_flag, reject_token_flag

EMBED_EXTENSIONS = {".xlsx", ".xls", ".csv", ".docx", ".pdf"}
EMBED_HEADING = "CMS Import File"
CONTENT = "/rest/api/content"
CONNECT_TIMEOUT = 15
REQUEST_TIMEOUT = 120
PROCESS_TIMEOUT = 150


class Client:
    def __init__(self, base, token):
        self.base = base.rstrip("/")
        self.tmp = tempfile.mkdtemp(prefix="confluence-")
        self.config = os.path.join(self.tmp, "auth.curlrc")
        quoted = token.replace("\\", "\\\\").replace('"', '\\"')
        descriptor = os.open(self.config, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(descriptor, "w") as sink:
            sink.write(f'header = "Authorization: Bearer {quoted}"\n')

    def close(self):
        for leftover in os.listdir(self.tmp):
            os.remove(os.path.join(self.tmp, leftover))
        os.rmdir(self.tmp)

    def call(self, method, path, body=None, form_file=None):
        argv = [
            "curl", "-sS", "--connect-timeout", str(CONNECT_TIMEOUT), "--max-time", str(REQUEST_TIMEOUT),
            "-K", self.config, "-X", method,
        ]
        if body is not None:
            payload = os.path.join(self.tmp, "body.json")
            with open(payload, "w", encoding="utf-8") as sink:
                json.dump(body, sink, ensure_ascii=False)
            argv += ["--data", "@" + payload, "-H", "Content-Type: application/json"]
        if form_file is not None:
            argv += ["-F", "file=@" + form_file, "-H", "X-Atlassian-Token: nocheck"]
        argv.append(self.base + path)
        try:
            done = subprocess.run(argv, text=True, capture_output=True, timeout=PROCESS_TIMEOUT)
        except subprocess.TimeoutExpired:
            sys.exit(f"curl did not finish within {PROCESS_TIMEOUT}s: {method} {path}")
        if done.returncode != 0:
            sys.exit(f"curl did not complete: {done.stderr.strip()}")
        try:
            reply = json.loads(done.stdout)
        except ValueError:
            sys.exit(f"Expected JSON from Confluence, got: {done.stdout[:400]}")
        if isinstance(reply, dict) and "statusCode" in reply and "message" in reply:
            sys.exit(f"Confluence answered {reply['statusCode']}: {reply['message']}")
        return reply


def resolve_token(keychain_name):
    if os.environ.get("CONFLUENCE_API_TOKEN"):
        return os.environ["CONFLUENCE_API_TOKEN"]
    try:
        lookup = subprocess.run(
            ["security", "find-generic-password", "-s", keychain_name, "-w"],
            capture_output=True, text=True, timeout=10,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    secret = lookup.stdout.strip()
    return secret if lookup.returncode == 0 and secret else None


def view_file_macro(path):
    target = f"<ri:attachment ri:filename={quoteattr(os.path.basename(path))}/>"
    parameter = f'<ac:parameter ac:name="name">{target}</ac:parameter>'
    return f'<p><ac:structured-macro ac:name="view-file">{parameter}</ac:structured-macro></p>'


def embed_block(files, heading):
    return f"<h2>{escape(heading)}</h2>" + "".join(map(view_file_macro, files))


def keyshot_files(manifest_path):
    with open(manifest_path, encoding="utf-8") as stream:
        manifest = json.load(stream)
    folder = os.path.dirname(os.path.abspath(manifest_path))
    names = [str(shot.get("file", "")) for shot in manifest.get("shots") or []]
    return [os.path.join(folder, name) for name in names if name.endswith(".png")]


def page_fields(title, space):
    return {"type": "page", "title": title, "space": {"key": space}}


def build_cli():
    cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    cli.add_argument("--xml", required=True, help="page body in storage format (<slug>.confluence.xml)")
    cli.add_argument("--screen", help="name of the screen; feeds the default title")
    cli.add_argument("--title", help="explicit page title; otherwise '<title-prefix> - <screen>'")
    cli.add_argument("--title-prefix", default=os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization"))
    cli.add_argument("--screenshot", help="screen image to upload as an attachment")
    cli.add_argument("--overlay", help="key-map overlay PNG to upload as an attachment")
    cli.add_argument("--keyshots", metavar="MANIFEST", help="keyshots manifest; each .png it lists is uploaded")
    cli.add_argument("--attach", metavar="FILE", action="append", default=[], help="additional file to upload; may be repeated")
    cli.add_argument("--base", "--base-url", dest="base", default=os.environ.get("CONFLUENCE_BASE_URL"))
    cli.add_argument("--space", default=os.environ.get("CONFLUENCE_SPACE"))
    cli.add_argument("--parent", default=os.environ.get("CONFLUENCE_PARENT"), help="id of the parent page")
    add_token_flag(cli)
    cli.add_argument("--keychain-name", default="CONFLUENCE_API_TOKEN", help="keychain service that stores the PAT")
    cli.add_argument("--embed-heading", default=EMBED_HEADING, help="heading above the embedded spreadsheet / document previews")
    cli.add_argument("--dry-run", action="store_true", help="report the planned actions without touching Confluence")
    return cli


def check_inputs(cli, opts):
    if not opts.title and not opts.screen:
        cli.error("supply --title or --screen")
    if not opts.dry_run:
        target = {"--base": opts.base, "--space": opts.space, "--parent": opts.parent}
        missing = [flag for flag, value in target.items() if not value]
        if missing:
            cli.error(f"missing {', '.join(missing)} (or set CONFLUENCE_BASE_URL / CONFLUENCE_SPACE / CONFLUENCE_PARENT)")
    named = [("--xml", opts.xml), ("--keyshots", opts.keyshots), ("--screenshot", opts.screenshot), ("--overlay", opts.overlay)]
    named += [("--attach", extra) for extra in opts.attach]
    for label, path in named:
        if path and not os.path.isfile(path):
            cli.error(f"{label} file not found: {path}")


def upsert_page(client, opts, title, body):
    lookup = urllib.parse.urlencode({"spaceKey": opts.space, "title": title, "type": "page", "expand": "version", "limit": 5})
    matches = client.call("GET", f"{CONTENT}?{lookup}").get("results") or []
    storage = {"storage": {"value": body, "representation": "storage"}}
    if not matches:
        page = client.call("POST", CONTENT, {
            **page_fields(title, opts.space), "ancestors": [{"id": opts.parent}], "body": storage,
        })
        return page, "created"
    page_id = matches[0]["id"]
    version = int((matches[0].get("version") or {}).get("number", 1))
    page = client.call("PUT", f"{CONTENT}/{page_id}", {
        "id": page_id, **page_fields(title, opts.space), "version": {"number": version + 1}, "body": storage,
    })
    return page, f"updated (v{version}->{version + 1})"


def upload(client, page_id, path):
    name = os.path.basename(path)
    attachments = f"{CONTENT}/{page_id}/child/attachment"
    query = urllib.parse.urlencode({"filename": name, "limit": 1})
    present = client.call("GET", f"{attachments}?{query}").get("results") or []
    endpoint = f"{attachments}/{present[0]['id']}/data" if present else attachments
    client.call("POST", endpoint, form_file=path)
    return name


def main():
    cli = build_cli()
    opts = cli.parse_args()
    reject_token_flag(
        cli, opts,
        f"export CONFLUENCE_API_TOKEN, or keep it in the macOS keychain under the --keychain-name service ({opts.keychain_name})",
    )
    check_inputs(cli, opts)
    title = opts.title or f"{opts.title_prefix} - {opts.screen}"

    files = [p for p in (opts.screenshot, opts.overlay) if p]
    if opts.keyshots:
        files += keyshot_files(opts.keyshots)
    files += opts.attach

    with open(opts.xml, encoding="utf-8") as stream:
        body = stream.read()
    embedded = [p for p in opts.attach if os.path.splitext(p)[1].lower() in EMBED_EXTENSIONS]
    if embedded:
        body += embed_block(embedded, opts.embed_heading)

    if opts.dry_run:
        plan = {
            "base": opts.base, "space": opts.space, "parent": opts.parent, "title": title,
            "xmlBytes": len(body.encode("utf-8")), "attachments": files, "action": "dry-run",
        }
        print(json.dumps(plan, indent=2, ensure_ascii=False))
        return

    token = resolve_token(opts.keychain_name)
    if not token:
        sys.exit(
            "No Confluence token available. Put a Server/DC personal access token in the keychain "
            f"(service '{opts.keychain_name}') or export CONFLUENCE_API_TOKEN."
        )

    client = Client(opts.base, token)
    try:
        page, action = upsert_page(client, opts, title, body)
        page_id = page["id"]
        uploaded = [upload(client, page_id, path) for path in files]
    finally:
        client.close()

    root = opts.base.rstrip("/")
    link = (page.get("_links") or {}).get("webui")
    url = root + link if link else f"{root}/pages/viewpage.action?pageId={page_id}"
    report = {"action": action, "id": page_id, "title": title, "attachments": uploaded, "url": url}
    print(json.dumps(report, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()
