#!/usr/bin/env python3
'''Assembles the licence file for one distribution from licenses/components.json.

Every distribution ships a different set of third-party code -- the Windows
installer carries libusb-win32 and Microsoft's DPInst, the Debian package
carries neither, the npm and crates.io packages carry only the binding -- so
each needs a licence text listing what is actually in it. The manifest records
which component goes where, and this writes the per-distribution files from it,
so a licence text is never maintained in two places.

Outputs (--dist / --format):
    source  markdown -> LICENSE.md
    debian  dep5     -> debian/copyright
    nodejs  markdown -> src/nodejs/LICENSE.md
    rust    markdown -> src/rust/LICENSE.md
    windows markdown -> written into the build tree by windows/create-installer.py

--check regenerates the tracked outputs and reports any that are stale.
'''

import argparse
import json
import sys
import textwrap
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
LICENSES = REPO / 'licenses'

# the tracked outputs, in the order --check reports them
TRACKED = (
    ('source', 'markdown', 'LICENSE.md'),
    ('debian', 'dep5', 'debian/copyright'),
    ('nodejs', 'markdown', 'src/nodejs/LICENSE.md'),
    ('rust', 'markdown', 'src/rust/LICENSE.md'),
)

GENERATED_BY = 'generated by support/generate-licenses.py from licenses/components.json'


def load_manifest() -> dict:
    with open(LICENSES / 'components.json', encoding='utf-8') as f:
        return json.load(f)


def read_text(name: str) -> str:
    return (LICENSES / name).read_text(encoding='utf-8').rstrip('\n')


def components_for(manifest: dict, dist: str, extras: set[str]) -> list[dict]:
    selected = []
    for c in manifest['components']:
        if dist not in c['distributions']:
            continue
        if c.get('optional') and c['id'] not in extras:
            continue
        selected.append(c)
    return selected


def wrap(text: str, indent: str = '    ') -> list[str]:
    return textwrap.wrap(text, width=76, initial_indent=indent, subsequent_indent=indent)


def copyright_line(component: dict) -> str:
    return '; '.join(component['copyright'])


def render_markdown(manifest: dict, dist: str, components: list[dict]) -> str:
    # no generated-by marker: the Windows installer shows this text verbatim on
    # its licence page, and --check is what catches a hand edit
    out = []
    primary = [c for c in components if c.get('primary')]
    contributed = [c for c in components if c.get('attribution_only')]
    third_party = [c for c in components
                   if not c.get('primary') and not c.get('attribution_only')]

    for c in primary:
        out.append(f"{c['name']} is Copyright (C) {copyright_line(c)}.  All rights reserved.")
        out.append('')
        out.append(read_text(c['notice']))
        out.append('')

    if contributed:
        out.append('')
        out.append('CONTRIBUTED UNDER THE SAME TERMS')
        out.append('')
        out.extend(wrap('The following are covered by the terms above, and are listed here'
                        ' to name the copyright holders who contributed them.', indent=''))
        out.append('')
        for c in contributed:
            out.append(f"{c['name']}")
            out.append(f"    Copyright (c) {copyright_line(c)}")
            out.append('    Covers:')
            out.extend(f'      {f}' for f in c['files'])
            out.append('')

    if third_party:
        out.append('')
        out.append('COMPONENTS UNDER OTHER TERMS')
        out.append('')
        blurb = manifest['distributions'][dist]
        out.extend(wrap(f'{blurb[0].upper()}{blurb[1:]} also carries the following,'
                        ' which is not covered by the terms above.', indent=''))
        out.append('')
        for c in third_party:
            out.append(f"{c['name']}")
            out.append(f"    Copyright (c) {copyright_line(c)}")
            out.append(f"    Licensed under {c['license']}")
            if c.get('used_for'):
                out.extend(wrap(f"Used for {c['used_for']}"))
            if c.get('license_note'):
                out.extend(wrap(c['license_note']))
            if c.get('source_offer'):
                out.append(f"    Source: {c['source_offer']}")
            out.append('    Covers:')
            out.extend(f'      {f}' for f in c['files'])
            out.append('')

    # every licence text referenced above, once, whichever components share it
    specs = (manifest['licenses'].get(c['license'], {}) for c in components)
    for text in dict.fromkeys(s['text'] for s in specs if s.get('text')):
        out.append('')
        out.append(f"--- {manifest['texts'][text]} ---")
        out.append('')
        out.append(read_text(f'texts/{text}'))
        out.append('')

    return '\n'.join(out).rstrip('\n') + '\n'


def indent_dep5(text: str) -> str:
    '''Indent a licence body for a DEP-5 field: one leading space, and a lone
    "." where the text has a blank line.'''
    return '\n'.join(f' {line}'.rstrip() if line.strip() else ' .'
                     for line in text.split('\n'))


def render_dep5(manifest: dict, dist: str, components: list[dict]) -> str:
    upstream = manifest['upstream']
    out = [
        f'# {GENERATED_BY}',
        'Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/',
        f"Upstream-Name: {upstream['name']}",
        f"Upstream-Contact: {upstream['contact']}",
        f"Source: {upstream['source']}",
        '',
    ]

    for c in components:
        out.append('Files: ' + '\n       '.join(c['files']))
        out.append('Copyright: ' + '\n           '.join(c['copyright']))
        if c.get('used_for'):
            lines = wrap(f"Used for {c['used_for']}", indent='')
            out.append('Comment: ' + '\n '.join(lines))
        out.append(f"License: {c['license']}")

        body = []
        if c.get('notice'):
            body.append(read_text(c['notice']))
        spec = manifest['licenses'].get(c['license'], {})
        common = spec.get('debian_common')
        if common:
            body.append(f'On Debian systems, the full text of this license can be found in\n'
                        f"the file '/usr/share/common-licenses/{common}'.")
        elif spec.get('text'):
            body.append(read_text(f"texts/{spec['text']}"))
        if c.get('license_note'):
            body.append(c['license_note'])

        out.append(indent_dep5('\n\n'.join(body)))
        out.append('')

    return '\n'.join(out).rstrip('\n') + '\n'


def generate(manifest: dict, dist: str, fmt: str, extras: set[str]) -> str:
    if dist not in manifest['distributions']:
        raise SystemExit(f'unknown distribution {dist!r}; '
                         f"expected one of {', '.join(manifest['distributions'])}")
    components = components_for(manifest, dist, extras)
    if not components:
        raise SystemExit(f'no components apply to {dist!r}')
    return (render_dep5 if fmt == 'dep5' else render_markdown)(manifest, dist, components)


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument('--dist', help='distribution to assemble the licence text for')
    p.add_argument('--format', choices=('markdown', 'dep5'), default='markdown')
    p.add_argument('--with', dest='extras', action='append', default=[], metavar='ID',
                   help='include an optional component (repeatable)')
    p.add_argument('-o', '--output', help='write here instead of stdout')
    p.add_argument('--check', action='store_true',
                   help='verify the tracked outputs match the manifest')
    args = p.parse_args()

    manifest = load_manifest()

    if args.check:
        stale = []
        for dist, fmt, path in TRACKED:
            expected = generate(manifest, dist, fmt, set())
            target = REPO / path
            if not target.exists() or target.read_text(encoding='utf-8') != expected:
                stale.append(path)
        if stale:
            print('stale licence files, re-run support/generate-licenses.py:', file=sys.stderr)
            for path in stale:
                print(f'  {path}', file=sys.stderr)
            return 1
        print(f'{len(TRACKED)} licence files up to date')
        return 0

    if not args.dist:
        # no arguments: refresh everything that is tracked
        for dist, fmt, path in TRACKED:
            (REPO / path).write_text(generate(manifest, dist, fmt, set()), encoding='utf-8')
            print(f'wrote {path}')
        return 0

    text = generate(manifest, args.dist, args.format, set(args.extras))
    if args.output:
        out = Path(args.output)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(text, encoding='utf-8')
        print(f'wrote {out}')
    else:
        sys.stdout.write(text)
    return 0


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