from importlib import import_module
import argparse
from collections import OrderedDict, defaultdict, Counter
import gzip
from datetime import date
import sys
import logging
import re

# provide a safe no-op `profile` decorator when line_profiler isn't active
try:
    from line_profiler import profile  # type: ignore
except Exception:
    def profile(func):
        return func

CLOSING = ''

try:
    from .utils import *
except (ImportError, ModuleNotFoundError):
    from utils import *


# compute which of six frames a feature is in using GFF coords (1-based coords)
# Frames: 0,1,2 -> forward strand frames based on start position modulo 3
#         3,4,5 -> reverse strand frames based on stop position modulo 3 (offset by 3)
# Returns integer 0..5 or None if strand or coords unknown
def compute_frame(start, stop, strand):
    try:
        if start is None or stop is None:
            return None
        if strand == '+':
            return (int(start) - 1) % 3
        elif strand == '-':
            return 3 + ((int(stop) - 1) % 3)
        else:
            return None
    except Exception:
        return None


# remove any existing ID=... from an attributes string and normalise
def sanitise_attr_value(attr_text: str) -> str:
    if not attr_text:
        return ''
    s = str(attr_text).strip()
    # remove leading/trailing semicolons and whitespace
    s = s.strip(';').strip()
    # remove any ID=... tokens to avoid duplicate ID attribute
    s = re.sub(r'(^|;)\s*ID=[^;]*(;|$)', ';', s)
    # collapse repeated semicolons
    s = re.sub(r';{2,}', ';', s)
    s = s.strip(';').strip()
    return s


# Parse a plain GFF (possibly gzipped) and return mapping contig -> {"start,stop": [strand, source, type, attr, frame, start, stop]}
def parse_plain_gff_to_mapping(gff_path, dna_regions):
    mapping = defaultdict(dict)
    try:
        # detect gz
        with open(gff_path, 'rb') as _probe:
            magic = _probe.read(2)
            is_gz = (magic == b'\x1f\x8b')
    except Exception:
        is_gz = False
    try:
        fh = gzip.open(gff_path, 'rt', errors='replace') if is_gz else open(gff_path, 'r', encoding='utf-8', errors='replace')
    except Exception:
        return mapping
    try:
        for line in fh:
            if not line or line.startswith('#'):
                continue
            parts = line.strip().split('\t')
            if len(parts) < 9:
                continue
            contig = parts[0]
            try:
                start_i = int(parts[3]); stop_i = int(parts[4])
            except Exception:
                continue
            source = parts[1]; ftype = parts[2]; strand = parts[6]; attr = parts[8].lstrip(';').strip()
            frame = compute_frame(start_i, stop_i, strand)
            pos = f"{start_i},{stop_i}"
            mapping[contig][pos] = [strand, source, ftype, attr, frame, start_i, stop_i]
    finally:
        try:
            fh.close()
        except Exception:
            pass
    return mapping


########################################
@profile
def gff_writer(options, combined_ORFs_by_contig, output_file, reference_annotation, additional_annotation):
    # Ensure output filename ends with .gff - append if missing, and open that final path
    try:
        outp = str(options.output_file)
        if not outp.lower().endswith('.gff'):
            options.output_file = outp + '.gff'
            if getattr(options, 'verbose', False):
                print(f"Adjusted output filename to have .gff extension: {options.output_file}")
    except Exception:
        pass

    # open the (possibly adjusted) output file path
    write_out = open(options.output_file, 'w')
    # Minimal GFF header only; detailed metadata (genome file, input files, counters) is written
    # to a separate meta file when --meta is requested.
    write_out.write("##gff-version\t3\n#\tGFF-Adder\n#\tRun Date:" + str(date.today()) + '\n')
    write_out.write("# Combined GFF generated by GFF-Adder\n")

    # meta counts
    Ref_Only = 0
    Ref_Combined = defaultdict(int)
    Non_Ref_Combined = defaultdict(int)

    # New counters: per-tool totals and per-contig per-tool breakdown
    per_tool_total = Counter()                       # counts for additional-only entries
    per_contig_per_tool = defaultdict(Counter)      # contig -> Counter(tool -> count)
    ref_per_tool_total = Counter()                  # counts for reference entries matched by tools
    ref_per_contig_per_tool = defaultdict(Counter)  # contig -> Counter(tool -> count)
    # Collect detailed matched-pair metrics for metadata output
    matches = []  # list of tuples: (contig, ref_start, ref_stop, tool, add_start, add_stop, overlap_pct_ref, pred_pct_ref)

    # Iterate contigs in deterministic order
    for contig in combined_ORFs_by_contig:
        combined_ORFs = combined_ORFs_by_contig[contig]
        # ref_gene_set for this contig: use keys from ref portion (we can detect by data[1]=='ref')
        ref_gene_set = [k for k, v in combined_ORFs.items() if len(v) > 1 and v[1] == 'ref']

        for pos, data in combined_ORFs.items():
            # pos may be like 'start,stop' or 'contig,start,stop' but prefer numeric coords stored in data
            if len(data) > 8 and data[7] is not None and data[8] is not None:
                start = str(data[7])
                stop = str(data[8])
            else:
                pos_ = pos.split(',')
                if len(pos_) >= 2:
                    start = pos_[0]
                    stop = pos_[-1]
                else:
                    # fallback: skip malformed
                    continue
            strand = data[0]

            # Build additional_annotation_info from the combined entry's additional list if present.
            # Normalise entries and prefer the info portion after any 'ToolName:info' prefix.
            additional_annotation_info = ''
            additional_items = []
            if len(data) > 4 and data[4]:
                for add in data[4]:
                    # support structured provenance tuples (tool, info[, start, stop]) introduced by GFF_Adder
                    info_part = ''
                    try:
                        if isinstance(add, (list, tuple)) and len(add) >= 2:
                            info_part = str(add[1]).strip()
                            # if coordinates were recorded in the provenance, compute overlap stats when possible and append them to the info
                            if len(add) >= 4:
                                try:
                                    a_start = int(add[2])
                                    a_stop = int(add[3])
                                    # if writer has numeric ref coords, compute percent overlap and pred size relative to ref
                                    if len(data) > 7 and data[7] is not None and data[8] is not None:
                                        rstart = int(data[7]); rstop = int(data[8])
                                        overlap_start = max(a_start, rstart)
                                        overlap_stop = min(a_stop, rstop)
                                        overlap_len = max(0, overlap_stop - overlap_start + 1)
                                        ref_len = max(1, rstop - rstart + 1)
                                        overlap_pct_ref = (overlap_len / ref_len) * 100.0
                                        pred_len = max(1, a_stop - a_start + 1)
                                        pred_pct_ref = (pred_len / ref_len) * 100.0
                                        # append these measurements to the info text so they appear in Matched_Annotations
                                        info_part = info_part.lstrip(';').strip()
                                        info_part = info_part + ('' if not info_part else ';') + f"Matched_pct_ref={overlap_pct_ref:.2f};Pred_len_pct_ref={pred_pct_ref:.2f}"
                                except Exception:
                                    pass
                        else:
                            s = str(add).strip()
                            if not s:
                                continue
                            if ':' in s:
                                # split tool:info -> take info part
                                _, info_part = s.split(':', 1)
                                info_part = info_part.strip()
                            else:
                                info_part = s
                    except Exception:
                        info_part = str(add).strip()
                    # strip leading semicolons/whitespace to avoid double-semicolon when joined into attributes
                    if info_part:
                        info_part = info_part.lstrip(';').strip()
                        if info_part:
                            additional_items.append(info_part)
                # join collected additional items into a single attributes string (no leading ';')
                if additional_items:
                    additional_annotation_info = ';'.join(additional_items)
            elif len(data) > 3 and data[3]:
                # reference-stored info_field fallback (already raw), but normalise leading semicolons
                additional_annotation_info = str(data[3]).lstrip(';').strip()

            # tools list from options (maybe empty)
            tools = options.additional_tool.split(',') if getattr(options, 'additional_tool', None) else []

            # Determine matched_tools_list reliably:
            # prefer extracting tool names from data[4] entries, otherwise fallback to scanning values
            matched_tools_list = []
            try:
                if len(data) > 4 and data[4]:
                    for add in data[4]:
                        # if provenance is a tuple (tool,info), extract the tool directly
                        if isinstance(add, (list, tuple)) and len(add) >= 1:
                            t = str(add[0]).strip()
                            if t:
                                matched_tools_list.append(t)
                        else:
                            s = str(add)
                            if ':' in s:
                                t = s.split(':', 1)[0].strip()
                                if t:
                                    matched_tools_list.append(t)
                            else:
                                # fallback: check known tool names in the string
                                for tool in tools:
                                    if tool and tool in s:
                                        matched_tools_list.append(tool)
                else:
                    # fallback: scan the whole data structure for tool names (previous behaviour)
                    for tool in tools:
                        if tool and any(tool in str(x) for x in data):
                            matched_tools_list.append(tool)
            except Exception:
                matched_tools_list = []

            # Normalise matched tools: unique, sorted
            matched_tools_list = sorted(set(matched_tools_list))
            matched_tools_str = ','.join(matched_tools_list)
            matched_count = len(matched_tools_list)

            # Build GFF entry and update meta counters
            if pos not in ref_gene_set:  # Additional-only entry (not in reference)
                # For additional-only entries, preserve adapter-provided source (data[5]) and feature type (data[2])
                source_field = data[5] if len(data) > 5 and data[5] else (matched_tools_str if matched_tools_str else '')
                type_field = data[2] if len(data) > 2 and data[2] else '.'

                Non_Ref_Combined[matched_count] += 1

                # Update per-tool counters for additional-only entries
                if matched_tools_list:
                    for t in matched_tools_list:
                        per_tool_total[t] += 1
                        per_contig_per_tool[contig][t] += 1
                else:
                    # track unassigned additional entries (no tool name found)
                    per_tool_total['unassigned'] += 1
                    per_contig_per_tool[contig]['unassigned'] += 1

                # Always include the tool name provided by -at (fallback to first additional_tool) for clarity
                tool_for_entry = matched_tools_str if matched_tools_str else (options.additional_tool.split(',')[0] if getattr(options, 'additional_tool', None) else 'unassigned')

                # Build clean 9th-column attributes for additional-only entry
                attrs = []
                attrs.append('ID=Additional_Annotation')
                attrs.append('Tool=' + str(tool_for_entry))
                cleaned_add_info = sanitise_attr_value(additional_annotation_info)
                if cleaned_add_info:
                    attrs.append('Add_attrs=' + cleaned_add_info)

                attrs_str = ';'.join(attrs)
                entry = f"{contig}\t{source_field}\t{type_field}\t{start}\t{stop}\t.\t{strand}\t.\t{attrs_str}\n"

            else:
                # Reference entry
                if len(data) > 3 and data[3]:
                    info_field = data[3].replace('\n', '').strip()
                else:
                    info_field = '.'

                # Determine type and source fields
                type_field = data[2] if len(data) > 2 and data[2] else (
                    reference_annotation.split('/')[-1].split('.')[0] if reference_annotation else '.')
                source_field = data[5] if len(data) > 5 and data[5] else (
                    reference_annotation.split('/')[-1].split('.')[0] if reference_annotation else '')

                # If additional_annotation_info duplicates info_field content, remove duplicate fragments.
                filtered_additional = ''
                if additional_annotation_info:
                    add_parts = [p.strip() for p in str(additional_annotation_info).split(';') if p.strip()]
                    info_parts = [p.strip() for p in str(info_field).split(';') if p.strip() and p.strip() != '.']
                    filtered = []
                    for ap in add_parts:
                        dup = False
                        for ip in info_parts:
                            # treat duplication if exact match or obvious substring relationship
                            if ip and (ap == ip or ip in ap or ap in ip):
                                dup = True
                                break
                        if not dup:
                            filtered.append(ap)
                    filtered_additional = ';'.join(filtered)

                if not filtered_additional:
                    # Reference-only (no meaningful unique additional annotations)
                    Ref_Only += 1
                    if getattr(options, 'annotate', True):
                        entry = (
                                    contig + '\t' + source_field + '\t' + type_field + '\t' + start + '\t' + stop + '\t.\t' + strand + '\t.\tID=Original_Annotation;' + info_field + '\n')
                    else:
                        entry = (
                                    contig + '\t' + source_field + '\t' + type_field + '\t' + start + '\t' + stop + '\t.\t' + strand + '\t.\t' + info_field + '\n')
                else:
                    # Reference entry that had additional annotations (combined)
                    Ref_Combined[matched_count] += 1

                    # Update per-tool counters for reference-matched entries
                    if matched_tools_list:
                        for t in matched_tools_list:
                            ref_per_tool_total[t] += 1
                            ref_per_contig_per_tool[contig][t] += 1
                    else:
                        ref_per_tool_total['unassigned'] += 1
                        ref_per_contig_per_tool[contig]['unassigned'] += 1

                    # Optionally mark consensus with tool names that matched this reference
                    consensus_str = ''
                    if getattr(options, 'mark_consensus', False) and matched_tools_str:
                        consensus_str = ';Consensus=' + matched_tools_str

                    if getattr(options, 'annotate', True):
                        # Build a clear 9th-column with separated reference and match information
                        attrs = []
                        attrs.append('ID=Original_Annotation')
                        # include original reference attributes (cleaned, no ID=)
                        cleaned_ref_info = sanitise_attr_value(info_field if info_field and info_field != '.' else '')
                        if cleaned_ref_info:
                            attrs.append('Ref_attrs=' + cleaned_ref_info)

                        # matched tools
                        if matched_tools_list:
                            attrs.append('Matched_Tools=' + matched_tools_str)

                        # matched annotations: filtered_additional may already include Matched_pct_ref/Pred_len_pct_ref
                        if filtered_additional:
                            attrs.append('Matched_Annotations=' + sanitise_attr_value(filtered_additional))

                        # consensus
                        if consensus_str:
                            attrs.append('Consensus=' + matched_tools_str)

                        # Also extract numeric provenance entries (tool,info,add_start,add_stop) to record match metrics for meta
                        try:
                            ref_start_i = int(start)
                            ref_stop_i = int(stop)
                        except Exception:
                            ref_start_i = None
                            ref_stop_i = None

                        if len(data) > 4 and data[4]:
                            for add in data[4]:
                                try:
                                    if isinstance(add, (list, tuple)) and len(add) >= 4:
                                        tname = str(add[0])
                                        info_txt = str(add[1])
                                        a_start = int(add[2])
                                        a_stop = int(add[3])
                                        if ref_start_i is not None and ref_stop_i is not None:
                                            overlap_start = max(a_start, ref_start_i)
                                            overlap_stop = min(a_stop, ref_stop_i)
                                            overlap_len = max(0, overlap_stop - overlap_start + 1)
                                            ref_len = max(1, ref_stop_i - ref_start_i + 1)
                                            overlap_pct_ref = (overlap_len / ref_len) * 100.0
                                            pred_len = max(1, a_stop - a_start + 1)
                                            pred_pct_ref = (pred_len / ref_len) * 100.0
                                        else:
                                            overlap_pct_ref = None
                                            pred_pct_ref = None
                                        matches.append((contig, ref_start_i, ref_stop_i, tname, a_start, a_stop, overlap_pct_ref, pred_pct_ref))
                                except Exception:
                                    continue

                        attrs_str = ';'.join([a for a in attrs if a])
                        entry = f"{contig}\t{source_field}\t{type_field}\t{start}\t{stop}\t.\t{strand}\t.\t{attrs_str}\n"
                    else:
                        entry = f"{contig}\t{source_field}\t{type_field}\t{start}\t{stop}\t.\t{strand}\t.\t{info_field}\n"

            write_out.write(entry)

    # Close the GFF file before producing the metadata output
    try:
        write_out.close()
    except Exception:
        pass

    # Produce metadata output if requested
    if getattr(options, 'output_meta', False) == True:
        # Summaries
        total_ref_combined = sum(Ref_Combined.values())
        total_nonref = sum(Non_Ref_Combined.values())
        total_reference_genes = Ref_Only + total_ref_combined
        Ref_Combined_counter = Counter(Ref_Combined)
        Non_Ref_Combined_counter = Counter(Non_Ref_Combined)

        meta_path = options.output_file.replace('.gff','_meta.txt')
        with open(meta_path, 'w') as meta_out:
             meta_out.write("GFF-Adder Metadata Report\n")
             meta_out.write("=========================\n")
             meta_out.write("Run Date: {}\n".format(date.today()))
             meta_out.write("Genome DNA: {}\n".format(getattr(options, 'genome_DNA', 'N/A')))
             meta_out.write("Reference annotation: {}\n".format(reference_annotation))
             meta_out.write("Additional annotation(s): {}\n\n".format(additional_annotation))

             meta_out.write("Summary counts\n")
             meta_out.write("--------------\n")
             meta_out.write(f"Reference-only genes (no matching additional annotation): {Ref_Only}\n")
             meta_out.write(f"Reference genes with additional matches (combined): {total_ref_combined}\n")
             meta_out.write(f"TOTAL reference genes observed: {total_reference_genes}\n")
             meta_out.write(f"Additional-only genes (not present in reference): {total_nonref}\n\n")

             meta_out.write("Distribution of matches for reference-combined entries (num matched tools -> count)\n")
             if Ref_Combined_counter:
                 for k in sorted(Ref_Combined_counter):
                     meta_out.write(f"  {k:>3}    {Ref_Combined_counter[k]}\n")
             else:
                 meta_out.write("  None\n")
             meta_out.write("\n")

             meta_out.write("Distribution of matches for non-reference entries (num matched tools -> count)\n")
             if Non_Ref_Combined_counter:
                 for k in sorted(Non_Ref_Combined_counter):
                     meta_out.write(f"  {k:>3}    {Non_Ref_Combined_counter[k]}\n")
             else:
                 meta_out.write("  None\n")
             meta_out.write("\n")

             # Per-tool totals for additional-only entries
             meta_out.write("Per-tool additional-only annotation totals\n")
             meta_out.write("-----------------------------------------\n")
             if per_tool_total:
                 for t, c in per_tool_total.most_common():
                     meta_out.write(f"  {t}: {c}\n")
             else:
                 meta_out.write("  None\n")
             meta_out.write("\n")

             # Per-contig breakdown for additional-only entries: only contigs with additional genes
             meta_out.write("Per-contig breakdown for additional-only annotations (only contigs with additions shown)\n")
             meta_out.write("---------------------------------------------------------------------------------------\n")
             if per_contig_per_tool:
                 for contig in sorted(per_contig_per_tool):
                     counter = per_contig_per_tool[contig]
                     if sum(counter.values()) == 0:
                         continue
                     meta_out.write(f"  {contig}:\n")
                     for t, c in counter.most_common():
                         meta_out.write(f"    {t}: {c}\n")
                     meta_out.write("\n")
             else:
                 meta_out.write("  None\n\n")

             # Per-tool totals and per-contig breakdown for reference genes matched by additional tools
             meta_out.write("Per-tool totals for reference genes matched by additional tools\n")
             meta_out.write("---------------------------------------------------------------\n")
             if ref_per_tool_total:
                 for t, c in ref_per_tool_total.most_common():
                     meta_out.write(f"  {t}: {c}\n")
             else:
                 meta_out.write("  None\n")
             meta_out.write("\n")

             meta_out.write("Per-contig breakdown for reference genes matched by additional tools\n")
             meta_out.write("--------------------------------------------------------------------\n")
             if ref_per_contig_per_tool:
                 for contig in sorted(ref_per_contig_per_tool):
                     counter = ref_per_contig_per_tool[contig]
                     if sum(counter.values()) == 0:
                         continue
                     meta_out.write(f"  {contig}:\n")
                     for t, c in counter.most_common():
                         meta_out.write(f"    {t}: {c}\n")
                     meta_out.write("\n")
             else:
                 meta_out.write("  None\n\n")

             # Matched annotation pairs: detailed metrics for reference entries with additional matches
             meta_out.write("Matched annotation pairs (detailed metrics for reference entries with additional matches)\n")
             meta_out.write("---------------------------------------------------------------------------------------\n")
             if matches:
                 for m in matches:
                     contig, ref_start, ref_stop, tool, add_start, add_stop, overlap_pct_ref, pred_pct_ref = m
                     meta_out.write(f"  {contig}\t{ref_start}\t{ref_stop}\t{tool}\t{add_start}\t{add_stop}\t{overlap_pct_ref:.2f}\t{pred_pct_ref:.2f}\n")
             else:
                 meta_out.write("  None\n")
             meta_out.write("\n")

             meta_out.write("Notes\n")
             meta_out.write("-----\n")
             meta_out.write(" - 'Reference-only' are reference entries that had no recorded additional annotation information.\n")
             meta_out.write(" - 'Per-tool' counts are based on the tool names extracted from the additional-annotation provenance\n")
             meta_out.write("   (expected format in combined entries: 'ToolName:info'). Entries with no tool detected are shown as 'unassigned'.\n")
             meta_out.write("\nEnd of report\n")
        if getattr(options, 'verbose', False):
            print(f"Wrote metadata file: {meta_path}")


@profile
def gff_adder(options):
    # Debug: when verbose, print options to help trace defaults
    try:
        if getattr(options, 'verbose', False):
            print('DEBUG options:', {k: getattr(options, k) for k in vars(options)})
    except Exception:
        pass
    # Build dna_regions: prefer FASTA if provided, otherwise collect contig names from GFFs and create placeholders
    dna_regions = OrderedDict()

    def _open_text_maybe_gz(path):
        # open a text file whether plain or gzipped
        try:
            with open(path, 'rb') as _probe:
                magic = _probe.read(2)
                is_gz = (magic == b'\x1f\x8b')
        except Exception:
            is_gz = False
        if is_gz:
            return gzip.open(path, 'rt', errors='replace')
        else:
            return open(path, 'r', encoding='utf-8', errors='replace')

    if getattr(options, 'genome_DNA', None):
        # Load fasta into dna_regions (supports multi-contig)
        try:
            try:
                fasta_in = gzip.open(options.genome_DNA, 'rt')
                dna_regions = fasta_load(fasta_in)
            except Exception:
                fasta_in = open(options.genome_DNA, 'r', encoding='unicode_escape')
                dna_regions = fasta_load(fasta_in)
        except Exception:
            # Fallback to legacy single-contig behaviour
            genome_seq = ""
            with open(options.genome_DNA, 'r') as genome_fasta:
                for line in genome_fasta:
                    line = line.replace("\n", "")
                    if not line.startswith('>'):
                        genome_seq += str(line)
                    else:
                        genome_ID = line.split()[0].replace('>','')
            # Create dna_regions with single entry
            dna_regions = OrderedDict()
            dna_regions[genome_ID] = (genome_seq, len(genome_seq), list(), None)
    else:
        # No genome fasta provided: collect contig names from reference and additional GFF(s)
        contigs = set()

        # Collect from a GFF-like file path; handle gz or plain
        def collect_contigs_from_gff(path):
            try:
                fh = _open_text_maybe_gz(path)
            except Exception:
                return
            try:
                for line in fh:
                    if not line or line.startswith('#'):
                        continue
                    parts = line.strip().split('\t')
                    if len(parts) >= 1 and parts[0]:
                        contigs.add(parts[0])
            finally:
                try:
                    fh.close()
                except Exception:
                    pass

        # Reference annotation
        try:
            collect_contigs_from_gff(options.reference_annotation)
        except Exception:
            pass

        # Additional annotation(s) may be comma-separated
        if getattr(options, 'additional_annotation', None):
            for ann in str(options.additional_annotation).split(','):
                ann = ann.strip()
                if not ann:
                    continue
                try:
                    collect_contigs_from_gff(ann)
                except Exception:
                    continue

        if not contigs:
            # fallback single placeholder contig
            contigs.add('unknown')

        for contig in sorted(contigs):
            dna_regions[contig] = ('', 0, list(), None)

    ###########################################
    # Build reference gene dict per-contig
    ref_genes_by_contig = defaultdict(OrderedDict)

    if not options.reference_tool:
        # Parse reference gff to populate ref_genes_by_contig (retain original info fields)
        # Detect gzip by magic bytes (first two bytes)
        is_gz = False
        try:
            with open(options.reference_annotation, 'rb') as _probe:
                magic = _probe.read(2)
                is_gz = (magic == b'\x1f\x8b')
        except Exception:
            is_gz = False

        if is_gz:
            gff_in = gzip.open(options.reference_annotation, 'rt', errors='replace')
        else:
            # Open as plain text, replace undecodable bytes rather than fail
            gff_in = open(options.reference_annotation, 'r', encoding='utf-8', errors='replace')

        count = 0
        try:
            for line in gff_in:
                if line.startswith('#') or line.strip() == '':
                    continue
                parts = line.strip().split('\t')
                if len(parts) < 9:
                    continue
                contig = parts[0]
                if contig not in dna_regions:
                    # skip records for contigs not in provided fasta/GFF scan
                    continue
                try:
                    if 'CDS' in options.gene_ident and len(options.gene_ident) == 1:
                        if "CDS" in parts[2] and len(parts) == 9:
                            start = int(parts[3])
                            stop = int(parts[4])
                            strand = parts[6]
                            # compute 6-frame from coordinates and strand
                            frame = compute_frame(start, stop, strand)
                            pos = f"{start},{stop}"
                            info = parts[8]
                            # store start/stop ints in the value to avoid repeated parsing later
                            ref_genes_by_contig[contig][pos] = [strand, parts[1], parts[2], info, frame, start, stop]
                            count += 1
                    else:
                        gene_types = options.gene_ident.split(',')
                        if any(gene_type in parts[2] for gene_type in gene_types):
                            start = int(parts[3])
                            stop = int(parts[4])
                            strand = parts[6]
                            frame = compute_frame(start, stop, strand)
                            pos = f"{start},{stop}"
                            info = parts[8]
                            ref_genes_by_contig[contig][pos] = [strand, parts[1], parts[2], info, frame, start, stop]
                            count += 1
                except IndexError:
                    continue
        finally:
            try:
                gff_in.close()
            except Exception:
                pass

    else:
        # Reference tool provided: attempt to call it with dna_regions first (multi-contig aware), fallback to legacy signature
        reference_tool = options.reference_tool if options.reference_tool != 'StORF-Reporter' else 'StORF-Reporter'
        try:
            reference_tool_mod = import_module('Tools.' + reference_tool + '.' + reference_tool, package='my_current_pkg')
        except ModuleNotFoundError:
            try:
                reference_tool_mod = import_module('ORForise.Tools.' + reference_tool + '.' + reference_tool, package='my_current_pkg')
            except ModuleNotFoundError:
                sys.exit("Tool not available")
        reference_tool_func = getattr(reference_tool_mod, reference_tool)
        # Try multi-contig signature
        try:
            ref_result = reference_tool_func(options.reference_annotation, dna_regions)
        except TypeError:
            # Fallback to legacy signature, try passing genome seq string
            genome_seq = ''.join([dna_regions[c][0] for c in dna_regions])
            ref_result = reference_tool_func(reference_annotation=options.reference_annotation, genome_seq=genome_seq, gene_ident=options.gene_ident)
        # Expect ref_result to be dict of contig -> {pos: data}
        for contig, mapping in ref_result.items() if isinstance(ref_result, dict) else []:
            ref_genes_by_contig[contig].update(mapping)

    # Ensure each contig has an OrderedDict even if empty
    for contig in dna_regions:
        if contig not in ref_genes_by_contig:
            ref_genes_by_contig[contig] = OrderedDict()

    # Collect additional annotations per contig
    additional_annotations_by_contig = defaultdict(OrderedDict)
    # Prepare lists of tools and annotation files; allow single tool repeated across many files
    add_files = [f.strip() for f in str(options.additional_annotation).split(',') if f.strip()]
    tools = [t.strip() for t in str(options.additional_tool).split(',') if t.strip()] if getattr(options, 'additional_tool', None) else []
    if not tools and add_files:
        # default tool name when none provided
        tools = ['unassigned'] * len(add_files)
    if len(tools) == 1 and len(add_files) > 1:
        # convenience: repeat single tool name for all add files
        tools = tools * len(add_files)
    if len(tools) != len(add_files):
        sys.exit('Error: number of tools provided to -at must match number of files provided to -add (or provide a single -at to apply to all).')

    for idx, anno_file in enumerate(add_files):
        tool = tools[idx]
        additional_tool_func = None
        adapter_found = True
        try:
            additional_tool_mod = import_module('Tools.' + tool + '.' + tool, package='my_current_pkg')
            additional_tool_func = getattr(additional_tool_mod, tool)
        except Exception:
            try:
                additional_tool_mod = import_module('ORForise.Tools.' + tool + '.' + tool, package='my_current_pkg')
                additional_tool_func = getattr(additional_tool_mod, tool)
            except Exception:
                # Adapter not found: fall back to plain GFF parsing but keep the tool name as provided
                adapter_found = False
                if getattr(options, 'verbose', False):
                    print(f"WARNING: Adapter for tool '{tool}' not found; falling back to plain GFF parsing for file {anno_file}")

        # Try calling adapter if found, otherwise parse plain GFF
        tool_orfs = None
        if adapter_found and additional_tool_func:
            try:
                tool_orfs = additional_tool_func(anno_file, dna_regions)
            except TypeError:
                genome_seq = ''.join([dna_regions[c][0] for c in dna_regions])
                try:
                    tool_orfs = additional_tool_func(anno_file, genome_seq, options.gene_ident)
                except Exception:
                    tool_orfs = None
        if tool_orfs is None:
            # fallback: parse the GFF file directly into the multi-contig mapping format
            tool_orfs = parse_plain_gff_to_mapping(anno_file, dna_regions)

        # tool_orfs may be either {contig: {pos: data}} or a flat {pos: data}
        if isinstance(tool_orfs, dict):
             # If top-level keys look like contig names (present in dna_regions) then treat as multi-contig
             top_keys = list(tool_orfs.keys())
             if top_keys and top_keys[0] in dna_regions:
                 for contig, mapping in tool_orfs.items():
                     # Merge mapping into additional_annotations_by_contig and record tool provenance
                     for pos_k, pos_v in mapping.items():
                         # parse pos_k into numeric coords if possible to avoid split/int later
                         add_start_i = None
                         add_stop_i = None
                         try:
                             parts_k = pos_k.split(',')
                             if len(parts_k) >= 2:
                                 # keys could be 'start,stop' or 'contig,start,stop'
                                 add_start_i = int(parts_k[-2])
                                 add_stop_i = int(parts_k[-1])
                         except Exception:
                             add_start_i = None
                             add_stop_i = None
                         # store tuple (value, tool, start_int, stop_int). Use provided tool name for provenance.
                         additional_annotations_by_contig[contig][pos_k] = (pos_v, tool, add_start_i, add_stop_i)
             else:
                 # Treat as flat mapping — assume single contig if only one contig present
                 if len(dna_regions) == 1:
                     only_contig = next(iter(dna_regions))
                     for pos_k, pos_v in tool_orfs.items():
                         add_start_i = None
                         add_stop_i = None
                         try:
                             parts_k = pos_k.split(',')
                             if len(parts_k) >= 2:
                                 add_start_i = int(parts_k[-2])
                                 add_stop_i = int(parts_k[-1])
                         except Exception:
                             add_start_i = None
                             add_stop_i = None
                         additional_annotations_by_contig[only_contig][pos_k] = (pos_v, tool, add_start_i, add_stop_i)
                 else:
                     # If multiple contigs but mapping has contig-prefixed keys like 'contig,start,stop', split them
                     for k, v in tool_orfs.items():
                         parts = k.split(',')
                         if len(parts) == 3 and parts[0] in dna_regions:
                             contig = parts[0]
                             pos = parts[1] + ',' + parts[2]
                             try:
                                 add_start_i = int(parts[1])
                                 add_stop_i = int(parts[2])
                             except Exception:
                                 add_start_i = None
                                 add_stop_i = None
                             additional_annotations_by_contig[contig][pos] = (v, tool, add_start_i, add_stop_i)
                         else:
                             # Unknown format: assign nowhere (skip)
                             continue

    # Enrich additional annotations by parsing the original additional GFF files directly (to capture exact source and type)
    try:
        add_files = [f.strip() for f in str(options.additional_annotation).split(',') if f.strip()]
    except Exception:
        add_files = []

    # Build fast lookup of (contig,start,stop) -> (source,type,strand,attributes)
    add_lookup = {}
    for add_file in add_files:
        try:
            is_gz = False
            with open(add_file, 'rb') as _probe:
                magic = _probe.read(2)
                is_gz = (magic == b'\x1f\x8b')
        except Exception:
            is_gz = False
        try:
            fh = gzip.open(add_file, 'rt', errors='replace') if is_gz else open(add_file, 'r', encoding='utf-8', errors='replace')
        except Exception:
            continue
        try:
            for line in fh:
                if not line or line.startswith('#'):
                    continue
                parts = line.strip().split('\t')
                if len(parts) < 9:
                    continue
                contig = parts[0]
                try:
                    start_i = int(parts[3])
                    stop_i = int(parts[4])
                except Exception:
                    continue
                src_col = parts[1]
                type_col = parts[2]
                strand_col = parts[6]
                # Normalise attributes: remove a leading semicolon so later joining is clean
                attr_col = parts[8].lstrip(';').strip()
                add_lookup[(contig, start_i, stop_i)] = (src_col, type_col, strand_col, attr_col)
        finally:
            try:
                fh.close()
            except Exception:
                pass

    # Apply lookup enrichment to existing additional_annotations_by_contig entries
    for contig, mapping in additional_annotations_by_contig.items():
        for pos_k, stored in list(mapping.items()):
            try:
                stored_val, stored_tool, s_start, s_stop = stored
            except Exception:
                continue
            if s_start is None or s_stop is None:
                continue
            key = (contig, s_start, s_stop)
            if key in add_lookup:
                src_col, type_col, strand_col, attr_col = add_lookup[key]
                std_val = [strand_col if strand_col else '.', src_col if src_col else '', type_col if type_col else '.', attr_col if attr_col else '']
                additional_annotations_by_contig[contig][pos_k] = (std_val, stored_tool, s_start, s_stop)

    # Combine per-contig: keep reference entries and append additional annotations as supplemental
    combined_ORFs_by_contig = OrderedDict()
    for contig in dna_regions:
        combined = OrderedDict()
        # Add reference entries first; normalise to [strand, 'ref', type, ref_info, additional_list, source_field, frame, start_int, stop_int]
        ref_intervals = []  # list of tuples (start, stop, pos) built while adding refs
        for pos, val in ref_genes_by_contig.get(contig, {}).items():
            strand = val[0] if len(val) > 0 else '.'
            src = 'ref'
            source_field = val[1] if len(val) > 1 else 'ref'
            ftype = val[2] if len(val) > 2 else '.'
            ref_info = val[3] if len(val) > 3 else '.'
            frame = val[4] if len(val) > 4 else None
            # start/stop ints if present
            start_int = val[5] if len(val) > 5 else None
            stop_int = val[6] if len(val) > 6 else None
            # Keep existing structure and append frame and numeric coords as extra elements for fast access
            combined[pos] = [strand, src, ftype, ref_info, [], source_field, frame, start_int, stop_int]
            # If numeric coords available, add to ref_intervals immediately (avoid a second pass)
            try:
                rstart = start_int if start_int is not None else int(pos.split(',')[0])
                rstop = stop_int if stop_int is not None else int(pos.split(',')[-1])
                ref_intervals.append((rstart, rstop, pos))
            except Exception:
                # ignore entries with unparsable coords
                continue

        # sort intervals by start and create separate starts list for bisect
        ref_intervals.sort(key=lambda x: x[0])
        ref_starts = [r[0] for r in ref_intervals]

        # Now incorporate additional annotations without overwriting reference entries
        # Cache locals for performance in tight loop
        local_combined = combined
        local_ref_intervals = ref_intervals
        local_ref_starts = ref_starts
        local_compute_frame = compute_frame
        local_int = int
        local_str = str
        local_max = max
        local_min = min
        # If not provided, default overlap threshold to 100 (only fully-reference-covering matches considered reference-matched). Allow override via options for more lenient matching if desired.
        overlap_threshold = float(getattr(options, 'overlap', 100))
        # detect if any reference has frame info; only compute additional frames if needed
        has_ref_frames = any((rv[6] is not None) for rv in local_combined.values()) if local_combined else False
        for pos, wrapped in additional_annotations_by_contig.get(contig, {}).items():
            # wrapped may be (value, tool, start_int, stop_int) or (value, tool)
            val = None
            toolname = ''
            add_start = None
            add_stop = None
            if isinstance(wrapped, tuple):
                lw = wrapped
                lw_len = len(lw)
                if lw_len >= 4:
                    val, toolname, add_start, add_stop = lw[0], lw[1], lw[2], lw[3]
                elif lw_len == 3:
                    val, toolname, add_start = lw[0], lw[1], lw[2]
                    add_stop = None
                elif lw_len == 2:
                    val, toolname = lw[0], lw[1]
                else:
                    val = lw
            else:
                val = wrapped

            # Extract strand/type/info from value heuristically. Support multiple common formats.
            strand_a = '.'
            ftype_a = '.'
            source_a = ''
            info_a = ''
            frame_a = None
            if isinstance(val, (list, tuple)):
                vlen = len(val)
                if vlen > 0:
                    strand_a = val[0] if val[0] else '.'
                if vlen > 2:
                    # common structure: [strand, source, type, info, ...]
                    ftype_a = val[2] if val[2] else '.'
                if vlen > 3:
                    info_a = val[3]
                if vlen > 1:
                    # try to capture the source column if adapter provided it
                    source_a = val[1] if val[1] else ''
            elif isinstance(val, str):
                # If adapter provided a GFF-like line as a string, parse fields
                if '\t' in val:
                    parts_val = val.strip().split('\t')
                    if len(parts_val) >= 9:
                        # GFF format: seqid source type start end score strand phase attributes
                        # capture source and type, strand and attributes
                        source_a = parts_val[1] if parts_val[1] else source_a
                        ftype_a = parts_val[2] if parts_val[2] else ftype_a
                        try:
                            strand_a = parts_val[6] if parts_val[6] else strand_a
                        except Exception:
                            pass
                        info_a = parts_val[8]
                    else:
                        info_a = val
                else:
                    info_a = val

            # Parse the additional annotation coordinates from the pos key
            if add_start is None or add_stop is None:
                try:
                    parts = pos.split(',')
                    if len(parts) >= 2:
                        add_start = local_int(parts[-2])
                        add_stop = local_int(parts[-1])
                except Exception:
                    add_start = None
                    add_stop = None

            # Compute frame for additional annotation from coordinates and strand (override any adapter-provided phase)
            frame_a = None
            if add_start is not None and add_stop is not None:
                computed_frame_a = local_compute_frame(add_start, add_stop, strand_a if strand_a != '.' else None)
                if computed_frame_a is not None:
                    frame_a = computed_frame_a

            matched_ref_pos = None
            matched_ref_overlap_pct = 0.0

            # If coordinates parsed and we have reference entries, search candidate refs using bisect on starts
            if add_start is not None and add_stop is not None and local_ref_intervals:
                import bisect
                 # find rightmost index where ref.start <= add_stop
                idx = bisect.bisect_right(local_ref_starts, add_stop)
                # candidate intervals are local_ref_intervals[0:idx] (those starting at or before add_stop)
                for rstart, rstop, rpos in local_ref_intervals[0:idx]:
                    # quick filter by stop >= add_start
                    if rstop < add_start:
                        continue
                    # get reference entry
                    ref_val = local_combined.get(rpos)
                    if not ref_val:
                        continue
                    ref_strand = ref_val[0] if len(ref_val) > 0 else '.'
                    ref_ftype = ref_val[2] if len(ref_val) > 2 else '.'
                    ref_frame = ref_val[6] if len(ref_val) > 6 else None
                    # numeric coords available at indices 7 and 8
                    rstart = ref_val[7] if len(ref_val) > 7 else rstart
                    rstop = ref_val[8] if len(ref_val) > 8 else rstop

                    strand_match = False
                    try:
                        if strand_a and ref_strand and strand_a != '.' and ref_strand != '.':
                            strand_match = (strand_a == ref_strand)
                        else:
                            # unknown strand information -> treat as neutral (not penalised)
                            strand_match = False
                    except Exception:
                        strand_match = False

                    # require same feature type if both have types
                    if ftype_a and ref_ftype and ftype_a != '.' and ref_ftype != '.' and ftype_a.lower() != ref_ftype.lower():
                        continue

                    # require same frame (strict): both frames must be computable and equal
                    if ref_frame is None or frame_a is None or frame_a != ref_frame:
                        continue

                    # compute overlap relative to reference
                    overlap_start = local_max(add_start, rstart)
                    overlap_stop = local_min(add_stop, rstop)
                    overlap_len = max(0, overlap_stop - overlap_start + 1)
                    ref_len = max(1, rstop - rstart + 1)
                    overlap_pct = (overlap_len / ref_len) * 100.0
                    if getattr(options, 'verbose', False):
                        try:
                            print(f"DEBUG_CANDIDATE: contig={contig} ref={rstart}-{rstop} add={add_start}-{add_stop} overlap_len={overlap_len} ref_len={ref_len} overlap_pct={overlap_pct:.2f}")
                        except Exception:
                            pass

                    # small strand bonus to prefer same-strand matches in case of ties
                    effective_score = overlap_pct + (0.001 if strand_match else 0.0)

                    # choose the reference with maximum overlap percent
                    if overlap_len > 0 and effective_score > matched_ref_overlap_pct:
                        matched_ref_overlap_pct = overlap_pct
                        matched_ref_pos = rpos

            # If best match meets the overlap threshold, attach to that reference
            if matched_ref_pos and overlap_threshold is not None and matched_ref_overlap_pct >= overlap_threshold:
                 # attach structured provenance as (tool, info, add_start, add_stop)
                if getattr(options, 'verbose', False):
                    try:
                        print(f"DEBUG_MATCH: matched_ref_pos={matched_ref_pos} matched_ref_overlap_pct={matched_ref_overlap_pct} overlap_threshold={overlap_threshold}")
                    except Exception:
                        pass
                combined[matched_ref_pos][4].append((toolname if toolname else 'unassigned', info_a if info_a else '.', add_start, add_stop))
                if getattr(options, 'verbose', False):
                    try:
                        # compute and show overlap pct (normalised to reference length)
                        ref_entry = local_combined.get(matched_ref_pos)
                        if ref_entry and len(ref_entry) > 7 and ref_entry[7] is not None and ref_entry[8] is not None and add_start is not None and add_stop is not None:
                            rstart = int(ref_entry[7]); rstop = int(ref_entry[8])
                            overlap_start = max(add_start, rstart)
                            overlap_stop = min(add_stop, rstop)
                            overlap_len = max(0, overlap_stop - overlap_start + 1)
                            ref_len = max(1, rstop - rstart + 1)
                            overlap_pct = (overlap_len / ref_len) * 100.0
                            pred_len = max(1, add_stop - add_start + 1)
                            pred_pct_ref = (pred_len / ref_len) * 100.0
                            print(f"VERBOSE: Matched additional {toolname} {add_start}-{add_stop} to ref {rstart}-{rstop} overlap_pct_ref={overlap_pct:.2f} pred_len_pct_ref={pred_pct_ref:.2f}")
                    except Exception:
                        pass
            else:
                # No suitable reference match found -> create additional-only entry
                # create structured provenance list for additional-only entry
                addstr = (toolname if toolname else 'unassigned', info_a if info_a else '.')
                # Use strand from value if present else '.'
                strand_for_entry = strand_a if strand_a and strand_a != '.' else '.'
                # Keep array length compatible with ref entries: append frame value to the end
                # also store numeric coords for this additional-only entry so later processing doesn't need to split
                # Use provided source_a if present (e.g., 'Prodigal_v2.6.3'), else fall back to toolname
                # Avoid overwriting an existing reference entry with identical key. If the pos key
                # already exists and is a reference, create a unique additional key instead.
                target_key = pos
                if target_key in local_combined and len(local_combined[target_key]) > 1 and local_combined[target_key][1] == 'ref':
                    target_key = f"add_{toolname}_{add_start}_{add_stop}"
                local_combined[target_key] = [strand_for_entry, 'add', ftype_a if ftype_a else '.', '.', [addstr], source_a if source_a else (toolname if toolname else 'unassigned'), frame_a if frame_a is not None else None, add_start, add_stop]
        # Sort ORFs for this contig
        combined = sortORFs(local_combined)
        combined_ORFs_by_contig[contig] = combined

    # Apply add_lookup to combined entries to ensure additional-only entries use exact source/type when available
    try:
        for contig, combined in combined_ORFs_by_contig.items():
            for pos, data in combined.items():
                # only consider additional-only entries
                if not (len(data) > 1 and data[1] == 'add'):
                    continue
                # numeric coords
                if len(data) > 8 and data[7] is not None and data[8] is not None:
                    s = data[7]
                    e = data[8]
                else:
                    parts = str(pos).split(',')
                    if len(parts) >= 2:
                        try:
                            s = int(parts[-2])
                            e = int(parts[-1])
                        except Exception:
                            continue
                    else:
                        continue
                key = (contig, int(s), int(e))
                if key in add_lookup:
                    src_col, type_col, strand_col, attr_col = add_lookup[key]
                    # Always set the source and type from the original additional GFF when available
                    try:
                        # ensure indices exist
                        while len(data) <= 5:
                            data.append(None)
                        if src_col:
                            data[5] = src_col
                        if type_col:
                            # ensure index 2 exists
                            while len(data) <= 2:
                                data.append('.')
                            data[2] = type_col
                    except Exception:
                        pass
                    # update strand if generic
                    if (not data[0] or data[0] == '.') and strand_col:
                        data[0] = strand_col
                    # If attr_col exists, add it into the provenance list so writer will include it in the 9th column
                    if attr_col:
                        prov_tool = None
                        # prefer existing provenance tool name if present
                        try:
                            if len(data) > 4 and data[4]:
                                first = data[4][0]
                                if isinstance(first, (list, tuple)) and len(first) > 0:
                                    prov_tool = first[0]
                        except Exception:
                            prov_tool = None
                        if not prov_tool:
                            prov_tool = data[5] if len(data) > 5 and data[5] else 'unassigned'
                        # append attr as provenance info (no leading semicolon), but avoid duplicates
                        try:
                            existing = []
                            if len(data) > 4 and isinstance(data[4], list):
                                for it in data[4]:
                                    try:
                                        if isinstance(it, (list, tuple)) and len(it) >= 2:
                                            existing.append(str(it[1]).strip())
                                        else:
                                            existing.append(str(it).strip())
                                    except Exception:
                                        continue
                            # only append if attr_col not already present
                            if attr_col not in existing:
                                if len(data) > 4 and isinstance(data[4], list):
                                    data[4].append((prov_tool, attr_col))
                                else:
                                    data[4] = [(prov_tool, attr_col)]
                        except Exception:
                            data[4] = [(prov_tool, attr_col)]
                    combined[pos] = data
    except Exception:
        pass

    # Call writer
    gff_writer(options, combined_ORFs_by_contig, options.output_file, options.reference_annotation, options.additional_annotation)


@profile
def main():
    parser = argparse.ArgumentParser(description='ORForise ' + ORForise_Version + ': GFF-Adder combine reference and additional GFFs with overlap matching')
    parser._action_groups.pop()

    required = parser.add_argument_group('Required Arguments')
    required.add_argument('-dna', dest='genome_DNA', required=False, help='(Optional) Genome DNA file (.fa). If omitted the script will use contig names from GFFs and not require a FASTA')
    required.add_argument('-ref', dest='reference_annotation', required=True,
                        help='Which reference annotation file to use as reference?')
    required.add_argument('-at', dest='additional_tool', required=True,
                        help='Which format to use for additional annotation? - Can provide multiple annotations (Tool1,Tool2)')
    required.add_argument('-add', dest='additional_annotation', required=True,
                        help='Which annotation file to add to reference annotation? - Can provide multiple annotations (1.GFF,2.GFF)')
    required.add_argument('-o', dest='output_file', required=True,
                        help='Output filename')

    optional = parser.add_argument_group('Optional Arguments')
    optional.add_argument('-rt', dest='reference_tool', required=False,
                        help='Which tool format to use as reference? - If not provided, will default to the '
                             'standard GFF format and will only look for "CDS" features')
    optional.add_argument('--gene_ident', action='store', dest='gene_ident', default='CDS',
                        help='Identifier used for identifying genomic features in reference annotation "CDS,rRNA,tRNA"')
    # Consensus marking: enabled by default; provide --no-consensus to disable
    optional.add_argument('--mark-consensus', '-mc', dest='mark_consensus', action='store_true', default=True, required=False,
                        help='(Default: True) Mark reference annotations which were present in the additional tool annotation; use --no-consensus to disable')
    optional.add_argument('--no-consensus', dest='mark_consensus', action='store_false', required=False,
                        help=argparse.SUPPRESS)
    # Annotate/clean behaviour: include Original/Matched/Additional annotations in 9th column by default.
    optional.add_argument('--annotate', dest='annotate', action='store_true', default=True, required=False,
                        help='(Default: True) Include Original/Matched/Additional tags in the 9th (attributes) column; use --no-annotate to disable')
    optional.add_argument('--no-annotate', dest='annotate', action='store_false', required=False,
                        help=argparse.SUPPRESS)

    # Overlap threshold: percentage overlap (0-100) required to match an additional annotation to a reference
    optional.add_argument('--olap', dest='overlap', type=float, default=100, required=False,
                        help='(Optional) Percent overlap threshold (0-100) used to match additional annotations to reference entries; e.g. --olap 90 (default: 100 meaning complete overlap with referenece)')


    misc = parser.add_argument_group('Misc')
    misc.add_argument('-v', dest='verbose', action='store_true', default=False, required=False,
                      help='Default - False: Print out runtime status')
    misc.add_argument('--meta', dest='output_meta', action='store_true', default=False, required=False,
                      help='Write a separate metadata report file with detailed matched-pair metrics')

    options = parser.parse_args()
    # Debug in main: print parsed options when verbose
    try:
        if getattr(options, 'verbose', False):
            print('MAIN DEBUG options:', {k: getattr(options, k) for k in vars(options)})
    except Exception:
        pass


    gff_adder(options)


if __name__ == "__main__":
    try:
        try:
            main()
        except Exception:
            logging.exception('Unhandled exception in main')
    finally:
        print(CLOSING)

