#!/usr/bin/env python
import sys
import subprocess
import shlex
import os.path
from itertools import islice,ifilter
from optparse import OptionParser
from collections import namedtuple
import ctags

KINDS = {
    "c": "class name",
    "d": "define (from #define XXX)",
    "e": "enumerator",
    "f": "function or method name",
    "F": "file name",
    "g": "enumeration name",
    "m": "member (of structure or class data)",
    "p": "function prototype",
    "s": "structure name",
    "t": "typedef",
    "u": "union name",
    "v": "variable",
}
def build_arg_parser():
    usage = "usage: %prog [-flags] name"
    parser = OptionParser(usage=usage)
    parser.add_option("-p", "--partial", dest="partial", 
                      help="Substring match on a query.  This will perform a linear scan.",
                      action="store_true", default=False)

    parser.add_option("-r", "--prefix", dest="prefix", 
                      help="Does a prefix search on query.  This is logarithmic if case "\
                           "sensitive",
                      action="store_true", default=False)

    parser.add_option("-i", "--insensitive", dest="insensitive", 
                      help="Case insensitive search on ctag, linear scan.",
                      action="store_true", default=False)

    parser.add_option("-c", "--ctag_file", dest="tag_file",
                      help="If provided, specifies a CTAG file to use instead "\
                           "of recursing up from the current directory", default="")

    parser.add_option("-f", "--force", dest="force",
                      help="Forces cts to use a ctag file even if it looks invalid", 
                      action="store_true", default=False)

    parser.add_option("-k", "--restrict-kinds", dest="kinds",
                      help="Restricts tags to only show certain one or more tag types", 
                      default=None)

    parser.add_option("-l", "--list-kinds", dest="listkinds", 
                      help="Lists default descriptive ctags types.  This is not an " \
                           "exhaustive list",
                      action="store_true", default=False)

    parser.add_option("-t", "--file-types", dest="types", 
                      help="CSV list of file suffixes to include in search",
                      default=None)
    
    parser.add_option("-e", "--exec", dest="execute",
                      help="Executes a command using execv, filling in the "\
                           "appropriate variables.  Default opens in $EDITOR", 
                      default='%s +"{pattern}" {file}' % os.environ['EDITOR'])

    parser.add_option("-s", "--print-std", dest="stdout",
                      help="Instead of executing program, prints the formated string "\
                           "to stdout", default=None)

    parser.add_option("-n", "--num", dest="num", type="int",
                      help="Number of tags to show at a time.  Default is 5",
                      default=5)

    parser.add_option("-R", "--no-read", dest="readline", 
                      help="Does not attempt to read the real line if provided a line number",
                      action="store_false", default=True)

    return parser

class EntryFilter(object):
    def __init__(self):
        self.kinds = None
        self.fileTypes = None
        self.partial = None
        self.insensitive = None

    def add_kinds_filter(self, kinds):
        self.kinds = frozenset(kinds)

    def add_file_types_filter(self, fts):
        if isinstance(fts, basestring):
            fts = fts.split(",") 

        self.fileTypes = set(fts)

    def add_partial_filter(self, query, insensenitive):
        self.insensenitive = insensenitive
        self.partial = query.lower() if self.insensenitive else query

    def ifilter(self, entry_set):
        # Filter out entries for files that no longer exist
        entries = ifilter(lambda e: os.path.isfile(e.file), entry_set)
        if self.kinds is not None:
            entry_set = ifilter(lambda e: e.kind in self.kinds, entry_set)

        if self.fileTypes is not None:
            sp = os.path.splitext
            filterfn = lambda e: sp(e.file)[1][1:] in self.fileTypes 
            entry_set = ifilter(filterfn, entry_set)

        if self.partial is not None:
            ins = self.insensenitive
            inName = lambda e: self.partial in (e.name.lower() if ins else e.name)
            entry_set = ifilter(inName, entry_set)

        return entry_set

def rec_dir_up(dir):
    if os.path.isdir(dir):
        while True:
            yield dir
            newdir = os.path.split(dir)[0]
            if newdir == dir: break
            dir = newdir

def find_ctag_file():
    directory = os.getcwd()
    for dir in rec_dir_up(directory):
        path = os.path.join(dir, "tags")
        if os.path.isfile(path):
            return path

    return None

def build_flags(partial, insensitive):
    search_type = ctags.TAG_PARTIALMATCH if partial else ctags.TAG_FULLMATCH
    case = ctags.TAG_IGNORECASE if insensitive else ctags.TAG_OBSERVECASE
    return search_type | case

ENTRY_FIELDS = ("name", "file", "pattern", "lineNumber", "kind", "fileScope")
Entry = namedtuple("Entry", ENTRY_FIELDS)
def entry_to_Entry(baseDir, entry):
    ed = dict((f, entry[f]) for f in ENTRY_FIELDS)
    ed['file'] = os.path.join(baseDir, ed['file'])
    return Entry(**ed)

def query_tag_file(baseDir, tags, query, partial, insensitive):
    entry = ctags.TagEntry()
    flags = build_flags(partial, insensitive)

    if tags.find(entry, query, flags):
        yield entry_to_Entry(baseDir, entry)
        while tags.findNext(entry):
            yield entry_to_Entry(baseDir, entry)

def read_line_at(f, num):
    return next(islice(f, num - 1, num), None)

def get_details(cache, entry, surrounding):
    # If a file, print nothing
    if entry.kind == 'F':
        return None

    # If -R or not a real line
    if not surrounding or entry.lineNumber == 0:
        return entry.pattern

    if entry not in cache:
        lineNum = entry.lineNumber
        with file(entry.file) as f:
            response = read_line_at(f, lineNum)
            if response is None or entry.name not in response:
                response = entry.pattern
            cache[entry] = response.strip()
    
    return cache[entry]

def truncate_filename(filename, length=70):
    reduce_amt = len(filename) - length

    if reduce_amt <= 0 or length <= 0:
        return filename

    reduce_amt += 3 # for the elipsis

    start = (len(filename) / 2) - (reduce_amt / 2)
    end = start + reduce_amt
    return "%s...%s" % (filename[:start], filename[end:])

def indent(s, indent):
    return s.rjust(len(s) + indent)

def single_format_entry(i, entry, details, width):
    line_prefix = " %d:  %s" % (i, entry.name)
    filename = truncate_filename(entry.file, width - len(line_prefix)-2)
    results = ["%s  %s" % (line_prefix, filename)]

    if details is not None:
        idetails = indent(details, line_prefix.index(':')+5)
        results.append(idetails)

    return '\n'.join(results)

def get_columns():
    # Attempt to get the columns of the terminal session
    try:
        return int(subprocess.check_output(['tput', 'cols']).strip())
    except OSError:
        return float('inf')
    except ValueError:
        # tput doesn't return an int?  
        return float('inf')

def select_entry(entries, num, surrounding=True):
    idx = 0

    columns = get_columns()

    # display in chunks of num
    i_entries = list(enumerate(entries))
    line_cache = {}
    while True:
        print_set = []
        midx = idx * num
        for i, entry in i_entries[midx:midx+num]:
            details = get_details(line_cache, entry, surrounding)
            fields = (entry.name, entry.file, details)
            print_set.append(single_format_entry(i, entry, details, columns))

        instructions = ["Select #"]
        not_at_end = (midx + num) < len(entries)
        if not_at_end:
            instructions.append("'m' for more")
        if idx > 0:
            instructions.append("'p' for prev")

        print '\n\n'.join(print_set)
        print
        print "%d - %d of %d entries" % (midx, midx+num, len(i_entries))
        print ', '.join(instructions)

        # Get the input
        results = raw_input("> ")
        if results.isdigit():
            linenum = int(results)
            if linenum < len(entries):
                return entries[linenum]

        elif results == 'm' and not_at_end:
            idx += 1
        elif results == 'p' and idx > 0:
            idx -= 1

def get_all_entries(baseDir, tag_file):
    entry = ctags.TagEntry()
    if tags.first(entry):
        yield entry_to_Entry(baseDir, entry)
        while tags.next(entry):
            yield entry_to_Entry(baseDir, entry)
 
def validate(tag_file):
    # we need attempt to verify that the file is actually a ctags file
    # since 'python-ctags' segfaults if it isn't
    valid = 1
    with file(tag_file) as f:
        valid &= "_TAG_FILE_FORMAT" in f.readline()
        valid &= "_TAG_FILE_SORTED" in f.readline()

    return valid
    
if __name__ == '__main__':
    parser = build_arg_parser()
    opts, args = parser.parse_args()

    if opts.listkinds:
        print "Tag Kinds"
        for k in sorted(KINDS.iterkeys()):
            print "%s\t%s" % (k, KINDS[k])

        sys.exit(0)

    if not args:
        print "Missing search string"
        parser.print_usage()
        sys.exit(1)

    # find the nearest ctag file
    tag_file = opts.tag_file or find_ctag_file()
    if tag_file is None or not os.path.isfile(tag_file):
        print "Could not find tag_file"
        sys.exit(1)
    
    if not validate(tag_file) and not options.force:
        print "file %s Does not look like a ctag file.  Aborting." % tag_file
        sys.exit(1)

    query = args[0]
    tags = ctags.CTags(tag_file)
    baseDir = os.path.split(tag_file)[0]
    if not opts.partial:
        results = query_tag_file(baseDir, tags, query, 
                                 opts.prefix, opts.insensitive)
    else:
        results = get_all_entries(baseDir, tags)

    # filter results
    entryFilter = EntryFilter()
    if opts.kinds is not None:
        entryFilter.add_kinds_filter(opts.kinds)

    if opts.types is not None:
        entryFilter.add_file_types_filter(opts.types)

    if opts.partial:
        entryFilter.add_partial_filter(query, opts.insensitive)

    results = list(entryFilter.ifilter(results))
    
    if not results:
        print "No tags for `%s` found." % query
        sys.exit(0)
    
    entry = None
    if len(results) == 1:
        entry = results[0]
    else:
        try:
            entry = select_entry(results, opts.num, opts.readline)
        except (KeyboardInterrupt,EOFError),e:
            sys.exit(1)

    # Prepare execution of script
    variables = dict((k,getattr(entry,k)) for k in ENTRY_FIELDS)

    # Override file for absolute path file
    tag_dir = os.path.split(tag_file)[0]
    filename = os.path.join(tag_dir, entry.file)
    variables['file'] = filename 
    if opts.stdout is not None:
        print opts.stdout.format(**variables)
        sys.exit(0)

    pieces = shlex.split(opts.execute.format(**variables))
    if pieces:
        os.execvp(pieces[0], pieces)

